blob: 9ee307408c5a8abeaff39907dac9c904e7bae6ad [file] [log] [blame]
iomodo5a7e4e72025-07-25 13:21:41 +04001package git
2
3import (
4 "context"
5 "fmt"
iomodo0c203b12025-07-26 19:44:57 +04006 "log/slog"
iomodo5a7e4e72025-07-25 13:21:41 +04007 "os"
8 "os/exec"
9 "path/filepath"
10 "strconv"
11 "strings"
12 "time"
iomodoa53240a2025-07-30 17:33:35 +040013
14 "github.com/iomodo/staff/config"
iomodo5a7e4e72025-07-25 13:21:41 +040015)
16
17// GitInterface defines the contract for Git operations
18type GitInterface interface {
19 // Repository operations
20 Init(ctx context.Context, path string) error
21 Clone(ctx context.Context, url, path string) error
22 IsRepository(ctx context.Context, path string) (bool, error)
23
24 // Status and information
25 Status(ctx context.Context) (*Status, error)
26 Log(ctx context.Context, options LogOptions) ([]Commit, error)
27 Show(ctx context.Context, ref string) (*Commit, error)
28
29 // Branch operations
30 ListBranches(ctx context.Context) ([]Branch, error)
31 CreateBranch(ctx context.Context, name string, startPoint string) error
32 Checkout(ctx context.Context, ref string) error
33 DeleteBranch(ctx context.Context, name string, force bool) error
34 GetCurrentBranch(ctx context.Context) (string, error)
iomodo13a10fc2025-07-31 17:47:06 +040035 GetUpstreamBranch(ctx context.Context, branch string) (string, error)
iomodo5a7e4e72025-07-25 13:21:41 +040036
37 // Commit operations
38 Add(ctx context.Context, paths []string) error
39 AddAll(ctx context.Context) error
40 Commit(ctx context.Context, message string, options CommitOptions) error
41
42 // Remote operations
43 ListRemotes(ctx context.Context) ([]Remote, error)
44 AddRemote(ctx context.Context, name, url string) error
45 RemoveRemote(ctx context.Context, name string) error
46 Fetch(ctx context.Context, remote string, options FetchOptions) error
47 Pull(ctx context.Context, remote, branch string) error
48 Push(ctx context.Context, remote, branch string, options PushOptions) error
49
50 // Merge operations
51 Merge(ctx context.Context, ref string, options MergeOptions) error
52 MergeBase(ctx context.Context, ref1, ref2 string) (string, error)
53
54 // Configuration
55 GetConfig(ctx context.Context, key string) (string, error)
56 SetConfig(ctx context.Context, key, value string) error
57 GetUserConfig(ctx context.Context) (*UserConfig, error)
58 SetUserConfig(ctx context.Context, config UserConfig) error
iomodo1d173602025-07-26 15:35:57 +040059
60 // Pull Request operations
61 CreatePullRequest(ctx context.Context, options PullRequestOptions) (*PullRequest, error)
62 GetPullRequest(ctx context.Context, id string) (*PullRequest, error)
63 ListPullRequests(ctx context.Context, options ListPullRequestOptions) ([]PullRequest, error)
64 UpdatePullRequest(ctx context.Context, id string, options PullRequestOptions) (*PullRequest, error)
65 ClosePullRequest(ctx context.Context, id string) error
66 MergePullRequest(ctx context.Context, id string, options MergePullRequestOptions) error
iomodoa53240a2025-07-30 17:33:35 +040067
68 // Clone Manager Operations
69 GetAgentClonePath(agentName string) (string, error)
70 RefreshAgentClone(agentName string) error
71 CleanupAgentClone(agentName string) error
72 CleanupAllClones() error
iomodo8acd08d2025-07-31 16:22:08 +040073
74 // Webhook operations (GitHub only)
75 ListWebhooks(ctx context.Context) ([]GitHubWebhookResponse, error)
76 CreateWebhook(ctx context.Context, webhookURL, secret string) (*GitHubWebhookResponse, error)
77 UpdateWebhook(ctx context.Context, webhookID int, webhookURL, secret string) (*GitHubWebhookResponse, error)
iomodo5a7e4e72025-07-25 13:21:41 +040078}
79
80// Status represents the current state of the repository
81type Status struct {
82 Branch string
83 IsClean bool
84 Staged []FileStatus
85 Unstaged []FileStatus
86 Untracked []string
87 Conflicts []string
88}
89
90// FileStatus represents the status of a file
91type FileStatus struct {
92 Path string
93 Status string // "modified", "added", "deleted", "renamed", etc.
94 Staged bool
95}
96
97// Commit represents a Git commit
98type Commit struct {
99 Hash string
100 Author Author
101 Committer Author
102 Message string
103 Parents []string
104 Timestamp time.Time
105 Files []CommitFile
106}
107
108// Author represents a Git author or committer
109type Author struct {
110 Name string
111 Email string
112 Time time.Time
113}
114
115// CommitFile represents a file in a commit
116type CommitFile struct {
117 Path string
118 Status string // "added", "modified", "deleted", "renamed"
119 Additions int
120 Deletions int
121}
122
123// Branch represents a Git branch
124type Branch struct {
125 Name string
126 IsCurrent bool
127 IsRemote bool
128 Commit string
129 Message string
130}
131
132// Remote represents a Git remote
133type Remote struct {
134 Name string
135 URL string
136}
137
138// UserConfig represents Git user configuration
139type UserConfig struct {
140 Name string
141 Email string
142}
143
144// LogOptions defines options for log operations
145type LogOptions struct {
146 MaxCount int
147 Since time.Time
148 Until time.Time
149 Author string
150 Path string
151 Oneline bool
152}
153
154// CommitOptions defines options for commit operations
155type CommitOptions struct {
156 Author *Author
157 Committer *Author
158 Sign bool
159 AllowEmpty bool
160}
161
162// FetchOptions defines options for fetch operations
163type FetchOptions struct {
164 All bool
165 Tags bool
166 Depth int
167 Prune bool
168}
169
170// PushOptions defines options for push operations
171type PushOptions struct {
172 Force bool
173 Tags bool
174 SetUpstream bool
175}
176
177// MergeOptions defines options for merge operations
178type MergeOptions struct {
179 NoFF bool
180 Message string
181 Strategy string
182}
183
184// GitError represents a Git-specific error
185type GitError struct {
186 Command string
187 Output string
188 Err error
189}
190
191func (e *GitError) Error() string {
192 if e.Err != nil {
193 return fmt.Sprintf("git %s failed: %v\nOutput: %s", e.Command, e.Err, e.Output)
194 }
195 return fmt.Sprintf("git %s failed\nOutput: %s", e.Command, e.Output)
196}
197
198func (e *GitError) Unwrap() error {
199 return e.Err
200}
201
202// Git implementation using os/exec to call git commands
203type Git struct {
iomodoa53240a2025-07-30 17:33:35 +0400204 repoPath string
205 prProvider PullRequestProvider
206 cloneManager *CloneManager
207 logger *slog.Logger
iomodo5a7e4e72025-07-25 13:21:41 +0400208}
209
210// NewGit creates a new Git instance
iomodoa53240a2025-07-30 17:33:35 +0400211func New(cfg *config.Config, logger *slog.Logger) GitInterface {
212 var prProvider PullRequestProvider
213 var repoURL string
214
215 switch cfg.GetPrimaryGitProvider() {
216 case "github":
217 githubConfig := GitHubConfig{
218 Token: cfg.GitHub.Token,
219 Logger: logger,
220 }
221 prProvider = NewGitHubPullRequestProvider(cfg.GitHub.Owner, cfg.GitHub.Repo, githubConfig)
222 repoURL = fmt.Sprintf("https://github.com/%s/%s.git", cfg.GitHub.Owner, cfg.GitHub.Repo)
223 logger.Info("Using GitHub as pull request provider",
224 slog.String("owner", cfg.GitHub.Owner),
225 slog.String("repo", cfg.GitHub.Repo))
226 case "gerrit":
227 gerritConfig := GerritConfig{
228 Username: cfg.Gerrit.Username,
229 Password: cfg.Gerrit.Password,
230 BaseURL: cfg.Gerrit.BaseURL,
231 Logger: logger,
232 }
233 prProvider = NewGerritPullRequestProvider(cfg.Gerrit.Project, gerritConfig)
234 repoURL = fmt.Sprintf("%s/%s", cfg.Gerrit.BaseURL, cfg.Gerrit.Project)
235 logger.Info("Using Gerrit as pull request provider",
236 slog.String("base_url", cfg.Gerrit.BaseURL),
237 slog.String("project", cfg.Gerrit.Project))
238 default:
239 panic("no valid Git provider configured")
iomodo5a7e4e72025-07-25 13:21:41 +0400240 }
241
iomodoe7477eb2025-07-30 20:26:30 +0400242 cloneManager := NewCloneManager(repoURL, cfg.Git.WorkspacePath)
iomodoa53240a2025-07-30 17:33:35 +0400243
iomodo5a7e4e72025-07-25 13:21:41 +0400244 return &Git{
iomodoa53240a2025-07-30 17:33:35 +0400245 repoPath: cfg.Git.RepoPath,
246 prProvider: prProvider,
247 cloneManager: cloneManager,
248 logger: logger,
iomodo5a7e4e72025-07-25 13:21:41 +0400249 }
250}
251
iomodo5a7e4e72025-07-25 13:21:41 +0400252// Ensure Git implements GitInterface
253var _ GitInterface = (*Git)(nil)
254
255// Init initializes a new Git repository
256func (g *Git) Init(ctx context.Context, path string) error {
257 cmd := exec.CommandContext(ctx, "git", "init")
258 cmd.Dir = path
259 return g.runCommand(cmd, "init")
260}
261
262// Clone clones a repository from URL to path
263func (g *Git) Clone(ctx context.Context, url, path string) error {
264 cmd := exec.CommandContext(ctx, "git", "clone", url, path)
265 return g.runCommand(cmd, "clone")
266}
267
268// IsRepository checks if a path is a valid Git repository
269func (g *Git) IsRepository(ctx context.Context, path string) (bool, error) {
270 return g.isValidRepo(path), nil
271}
272
273// Status returns the current status of the repository
274func (g *Git) Status(ctx context.Context) (*Status, error) {
275 cmd := exec.CommandContext(ctx, "git", "status", "--porcelain", "--branch")
276 cmd.Dir = g.repoPath
277 output, err := g.runCommandWithOutput(cmd, "status")
278 if err != nil {
279 return nil, err
280 }
281
282 return g.parseStatus(output)
283}
284
285// Log returns commit history
286func (g *Git) Log(ctx context.Context, options LogOptions) ([]Commit, error) {
287 args := []string{"log", "--format=%H%n%an%n%ae%n%at%n%s%n%cn%n%ce%n%ct%n%P"}
288
289 if options.MaxCount > 0 {
290 args = append(args, fmt.Sprintf("-%d", options.MaxCount))
291 }
292
293 if !options.Since.IsZero() {
294 args = append(args, fmt.Sprintf("--since=%s", options.Since.Format(time.RFC3339)))
295 }
296
297 if !options.Until.IsZero() {
298 args = append(args, fmt.Sprintf("--until=%s", options.Until.Format(time.RFC3339)))
299 }
300
301 if options.Author != "" {
302 args = append(args, fmt.Sprintf("--author=%s", options.Author))
303 }
304
305 if options.Path != "" {
306 args = append(args, "--", options.Path)
307 }
308
309 if options.Oneline {
310 args = append(args, "--oneline")
311 }
312
313 cmd := exec.CommandContext(ctx, "git", args...)
314 cmd.Dir = g.repoPath
315 output, err := g.runCommandWithOutput(cmd, "log")
316 if err != nil {
317 return nil, err
318 }
319
320 return g.parseLog(output)
321}
322
323// Show shows commit details
324func (g *Git) Show(ctx context.Context, ref string) (*Commit, error) {
325 cmd := exec.CommandContext(ctx, "git", "show", "--format=json", ref)
326 cmd.Dir = g.repoPath
327 output, err := g.runCommandWithOutput(cmd, "show")
328 if err != nil {
329 return nil, err
330 }
331
332 commits, err := g.parseLog(output)
333 if err != nil || len(commits) == 0 {
334 return nil, &GitError{Command: "show", Output: "failed to parse commit"}
335 }
336
337 return &commits[0], nil
338}
339
340// ListBranches returns all branches
341func (g *Git) ListBranches(ctx context.Context) ([]Branch, error) {
342 cmd := exec.CommandContext(ctx, "git", "branch", "-a", "--format=%(refname:short)%09%(objectname)%09%(contents:subject)")
343 cmd.Dir = g.repoPath
344 output, err := g.runCommandWithOutput(cmd, "branch")
345 if err != nil {
346 return nil, err
347 }
348
349 return g.parseBranches(output)
350}
351
352// CreateBranch creates a new branch
353func (g *Git) CreateBranch(ctx context.Context, name string, startPoint string) error {
354 args := []string{"branch", name}
355 if startPoint != "" {
356 args = append(args, startPoint)
357 }
358
359 cmd := exec.CommandContext(ctx, "git", args...)
360 cmd.Dir = g.repoPath
361 return g.runCommand(cmd, "branch")
362}
363
364// Checkout switches to a branch or commit
365func (g *Git) Checkout(ctx context.Context, ref string) error {
366 cmd := exec.CommandContext(ctx, "git", "checkout", ref)
367 cmd.Dir = g.repoPath
368 return g.runCommand(cmd, "checkout")
369}
370
371// DeleteBranch deletes a branch
372func (g *Git) DeleteBranch(ctx context.Context, name string, force bool) error {
373 args := []string{"branch"}
374 if force {
375 args = append(args, "-D")
376 } else {
377 args = append(args, "-d")
378 }
379 args = append(args, name)
380
381 cmd := exec.CommandContext(ctx, "git", args...)
382 cmd.Dir = g.repoPath
383 return g.runCommand(cmd, "branch")
384}
385
386// GetCurrentBranch returns the current branch name
387func (g *Git) GetCurrentBranch(ctx context.Context) (string, error) {
388 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "HEAD")
389 cmd.Dir = g.repoPath
390 output, err := g.runCommandWithOutput(cmd, "rev-parse")
391 if err != nil {
392 return "", err
393 }
394
395 return strings.TrimSpace(output), nil
396}
397
iomodo13a10fc2025-07-31 17:47:06 +0400398// GetUpstreamBranch returns the upstream branch for a given branch
399func (g *Git) GetUpstreamBranch(ctx context.Context, branch string) (string, error) {
400 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", branch+"@{upstream}")
401 cmd.Dir = g.repoPath
402 output, err := g.runCommandWithOutput(cmd, "rev-parse")
403 if err != nil {
404 return "", err
405 }
406
407 // The output will be in format "origin/branch-name", we want just "branch-name"
408 upstream := strings.TrimSpace(output)
409 upstream = strings.TrimPrefix(upstream, "origin/")
410
411 return upstream, nil
412}
413
iomodo5a7e4e72025-07-25 13:21:41 +0400414// Add stages files for commit
415func (g *Git) Add(ctx context.Context, paths []string) error {
416 args := append([]string{"add"}, paths...)
417 cmd := exec.CommandContext(ctx, "git", args...)
iomodo97555d02025-07-27 15:07:14 +0400418 g.logger.Info("Adding files", slog.String("paths", strings.Join(paths, ", ")))
iomodo5a7e4e72025-07-25 13:21:41 +0400419 cmd.Dir = g.repoPath
420 return g.runCommand(cmd, "add")
421}
422
423// AddAll stages all changes
424func (g *Git) AddAll(ctx context.Context) error {
425 cmd := exec.CommandContext(ctx, "git", "add", "-A")
426 cmd.Dir = g.repoPath
427 return g.runCommand(cmd, "add")
428}
429
430// Commit creates a new commit
431func (g *Git) Commit(ctx context.Context, message string, options CommitOptions) error {
432 args := []string{"commit", "-m", message}
433
434 if options.Author != nil {
435 args = append(args, fmt.Sprintf("--author=%s <%s>", options.Author.Name, options.Author.Email))
436 }
437
438 if options.Sign {
439 args = append(args, "-S")
440 }
441
442 if options.AllowEmpty {
443 args = append(args, "--allow-empty")
444 }
445
446 cmd := exec.CommandContext(ctx, "git", args...)
447 cmd.Dir = g.repoPath
448 return g.runCommand(cmd, "commit")
449}
450
451// ListRemotes returns all remotes
452func (g *Git) ListRemotes(ctx context.Context) ([]Remote, error) {
453 cmd := exec.CommandContext(ctx, "git", "remote", "-v")
454 cmd.Dir = g.repoPath
455 output, err := g.runCommandWithOutput(cmd, "remote")
456 if err != nil {
457 return nil, err
458 }
459
460 return g.parseRemotes(output)
461}
462
463// AddRemote adds a new remote
464func (g *Git) AddRemote(ctx context.Context, name, url string) error {
465 cmd := exec.CommandContext(ctx, "git", "remote", "add", name, url)
466 cmd.Dir = g.repoPath
467 return g.runCommand(cmd, "remote")
468}
469
470// RemoveRemote removes a remote
471func (g *Git) RemoveRemote(ctx context.Context, name string) error {
472 cmd := exec.CommandContext(ctx, "git", "remote", "remove", name)
473 cmd.Dir = g.repoPath
474 return g.runCommand(cmd, "remote")
475}
476
477// Fetch fetches from a remote
478func (g *Git) Fetch(ctx context.Context, remote string, options FetchOptions) error {
479 args := []string{"fetch"}
480
481 if options.All {
482 args = append(args, "--all")
483 } else if remote != "" {
484 args = append(args, remote)
485 }
486
487 if options.Tags {
488 args = append(args, "--tags")
489 }
490
491 if options.Depth > 0 {
492 args = append(args, fmt.Sprintf("--depth=%d", options.Depth))
493 }
494
495 if options.Prune {
496 args = append(args, "--prune")
497 }
498
499 cmd := exec.CommandContext(ctx, "git", args...)
500 cmd.Dir = g.repoPath
501 return g.runCommand(cmd, "fetch")
502}
503
504// Pull pulls from a remote
505func (g *Git) Pull(ctx context.Context, remote, branch string) error {
506 args := []string{"pull"}
507 if remote != "" {
508 args = append(args, remote)
509 if branch != "" {
510 args = append(args, branch)
511 }
512 }
513
514 cmd := exec.CommandContext(ctx, "git", args...)
515 cmd.Dir = g.repoPath
516 return g.runCommand(cmd, "pull")
517}
518
519// Push pushes to a remote
520func (g *Git) Push(ctx context.Context, remote, branch string, options PushOptions) error {
521 args := []string{"push"}
522
523 if options.Force {
524 args = append(args, "--force")
525 }
526
527 if options.Tags {
528 args = append(args, "--tags")
529 }
530
531 if options.SetUpstream {
532 args = append(args, "--set-upstream")
533 }
534
535 if remote != "" {
536 args = append(args, remote)
537 if branch != "" {
538 args = append(args, branch)
539 }
540 }
541
542 cmd := exec.CommandContext(ctx, "git", args...)
543 cmd.Dir = g.repoPath
544 return g.runCommand(cmd, "push")
545}
546
547// Merge merges a branch into current branch
548func (g *Git) Merge(ctx context.Context, ref string, options MergeOptions) error {
549 args := []string{"merge"}
550
551 if options.NoFF {
552 args = append(args, "--no-ff")
553 }
554
555 if options.Message != "" {
556 args = append(args, "-m", options.Message)
557 }
558
559 if options.Strategy != "" {
560 args = append(args, "-s", options.Strategy)
561 }
562
563 args = append(args, ref)
564
565 cmd := exec.CommandContext(ctx, "git", args...)
566 cmd.Dir = g.repoPath
567 return g.runCommand(cmd, "merge")
568}
569
570// MergeBase finds the common ancestor of two commits
571func (g *Git) MergeBase(ctx context.Context, ref1, ref2 string) (string, error) {
572 cmd := exec.CommandContext(ctx, "git", "merge-base", ref1, ref2)
573 cmd.Dir = g.repoPath
574 output, err := g.runCommandWithOutput(cmd, "merge-base")
575 if err != nil {
576 return "", err
577 }
578
579 return strings.TrimSpace(output), nil
580}
581
582// GetConfig gets a Git configuration value
583func (g *Git) GetConfig(ctx context.Context, key string) (string, error) {
584 cmd := exec.CommandContext(ctx, "git", "config", "--get", key)
585 cmd.Dir = g.repoPath
586 output, err := g.runCommandWithOutput(cmd, "config")
587 if err != nil {
588 return "", err
589 }
590
591 return strings.TrimSpace(output), nil
592}
593
594// SetConfig sets a Git configuration value
595func (g *Git) SetConfig(ctx context.Context, key, value string) error {
596 cmd := exec.CommandContext(ctx, "git", "config", key, value)
597 cmd.Dir = g.repoPath
598 return g.runCommand(cmd, "config")
599}
600
601// GetUserConfig gets user configuration
602func (g *Git) GetUserConfig(ctx context.Context) (*UserConfig, error) {
603 name, err := g.GetConfig(ctx, "user.name")
604 if err != nil {
605 return nil, err
606 }
607
608 email, err := g.GetConfig(ctx, "user.email")
609 if err != nil {
610 return nil, err
611 }
612
613 return &UserConfig{
614 Name: name,
615 Email: email,
616 }, nil
617}
618
619// SetUserConfig sets user configuration
620func (g *Git) SetUserConfig(ctx context.Context, config UserConfig) error {
621 if err := g.SetConfig(ctx, "user.name", config.Name); err != nil {
622 return err
623 }
624
625 return g.SetConfig(ctx, "user.email", config.Email)
626}
627
iomodo1d173602025-07-26 15:35:57 +0400628// Pull Request operations
629
630// CreatePullRequest creates a new pull request
631func (g *Git) CreatePullRequest(ctx context.Context, options PullRequestOptions) (*PullRequest, error) {
632 if g.prProvider == nil {
633 return nil, &GitError{Command: "CreatePullRequest", Output: "no pull request provider configured"}
634 }
635 return g.prProvider.CreatePullRequest(ctx, options)
636}
637
638// GetPullRequest retrieves a pull request by ID
639func (g *Git) GetPullRequest(ctx context.Context, id string) (*PullRequest, error) {
640 if g.prProvider == nil {
641 return nil, &GitError{Command: "GetPullRequest", Output: "no pull request provider configured"}
642 }
643 return g.prProvider.GetPullRequest(ctx, id)
644}
645
646// ListPullRequests lists pull requests
647func (g *Git) ListPullRequests(ctx context.Context, options ListPullRequestOptions) ([]PullRequest, error) {
648 if g.prProvider == nil {
649 return nil, &GitError{Command: "ListPullRequests", Output: "no pull request provider configured"}
650 }
651 return g.prProvider.ListPullRequests(ctx, options)
652}
653
654// UpdatePullRequest updates a pull request
655func (g *Git) UpdatePullRequest(ctx context.Context, id string, options PullRequestOptions) (*PullRequest, error) {
656 if g.prProvider == nil {
657 return nil, &GitError{Command: "UpdatePullRequest", Output: "no pull request provider configured"}
658 }
659 return g.prProvider.UpdatePullRequest(ctx, id, options)
660}
661
662// ClosePullRequest closes a pull request
663func (g *Git) ClosePullRequest(ctx context.Context, id string) error {
664 if g.prProvider == nil {
665 return &GitError{Command: "ClosePullRequest", Output: "no pull request provider configured"}
666 }
667 return g.prProvider.ClosePullRequest(ctx, id)
668}
669
670// MergePullRequest merges a pull request
671func (g *Git) MergePullRequest(ctx context.Context, id string, options MergePullRequestOptions) error {
672 if g.prProvider == nil {
673 return &GitError{Command: "MergePullRequest", Output: "no pull request provider configured"}
674 }
675 return g.prProvider.MergePullRequest(ctx, id, options)
676}
677
iomodoa53240a2025-07-30 17:33:35 +0400678// Clone manage methods
679func (g *Git) GetAgentClonePath(agentName string) (string, error) {
680 if g.cloneManager == nil {
681 return "", &GitError{Command: "GetAgentClonePath", Output: "no clone manager configured"}
682 }
683 return g.cloneManager.GetAgentClonePath(agentName)
684}
685func (g *Git) RefreshAgentClone(agentName string) error {
686 if g.cloneManager == nil {
687 return &GitError{Command: "RefreshAgentClone", Output: "no clone manager configured"}
688 }
689 return g.cloneManager.RefreshAgentClone(agentName)
690}
691func (g *Git) CleanupAgentClone(agentName string) error {
692 if g.cloneManager == nil {
693 return &GitError{Command: "CleanupAgentClone", Output: "no clone manager configured"}
694 }
695 return g.cloneManager.CleanupAgentClone(agentName)
696}
697func (g *Git) CleanupAllClones() error {
698 if g.cloneManager == nil {
699 return &GitError{Command: "CleanupAllClones", Output: "no clone manager configured"}
700 }
701 return g.cloneManager.CleanupAllClones()
702}
703
iomodo8acd08d2025-07-31 16:22:08 +0400704// Webhook operations (GitHub only)
705
706// ListWebhooks lists existing webhooks for the repository
707func (g *Git) ListWebhooks(ctx context.Context) ([]GitHubWebhookResponse, error) {
708 webhookProvider, ok := g.prProvider.(WebhookProvider)
709 if !ok {
710 return nil, &GitError{Command: "ListWebhooks", Output: "webhook operations not supported by current provider"}
711 }
712 return webhookProvider.ListWebhooks(ctx)
713}
714
715// CreateWebhook creates a new webhook for the repository
716func (g *Git) CreateWebhook(ctx context.Context, webhookURL, secret string) (*GitHubWebhookResponse, error) {
717 webhookProvider, ok := g.prProvider.(WebhookProvider)
718 if !ok {
719 return nil, &GitError{Command: "CreateWebhook", Output: "webhook operations not supported by current provider"}
720 }
721 return webhookProvider.CreateWebhook(ctx, webhookURL, secret)
722}
723
724// UpdateWebhook updates an existing webhook
725func (g *Git) UpdateWebhook(ctx context.Context, webhookID int, webhookURL, secret string) (*GitHubWebhookResponse, error) {
726 webhookProvider, ok := g.prProvider.(WebhookProvider)
727 if !ok {
728 return nil, &GitError{Command: "UpdateWebhook", Output: "webhook operations not supported by current provider"}
729 }
730 return webhookProvider.UpdateWebhook(ctx, webhookID, webhookURL, secret)
731}
732
iomodo5a7e4e72025-07-25 13:21:41 +0400733// Helper methods
734
735func (g *Git) runCommand(cmd *exec.Cmd, command string) error {
736 output, err := cmd.CombinedOutput()
737 if err != nil {
738 return &GitError{
739 Command: command,
740 Output: string(output),
741 Err: err,
742 }
743 }
744 return nil
745}
746
747func (g *Git) runCommandWithOutput(cmd *exec.Cmd, command string) (string, error) {
748 output, err := cmd.CombinedOutput()
749 if err != nil {
750 return "", &GitError{
751 Command: command,
752 Output: string(output),
753 Err: err,
754 }
755 }
756 return string(output), nil
757}
758
759func (g *Git) isValidRepo(path string) bool {
760 gitDir := filepath.Join(path, ".git")
761 info, err := os.Stat(gitDir)
762 return err == nil && info.IsDir()
763}
764
765func (g *Git) parseStatus(output string) (*Status, error) {
766 lines := strings.Split(strings.TrimSpace(output), "\n")
767 status := &Status{
768 Staged: []FileStatus{},
769 Unstaged: []FileStatus{},
770 Untracked: []string{},
771 Conflicts: []string{},
772 }
773
774 for _, line := range lines {
775 if strings.HasPrefix(line, "## ") {
776 // Parse branch info
777 parts := strings.Fields(line[3:])
778 if len(parts) > 0 {
iomodoccbc0092025-07-25 20:29:47 +0400779 // Extract local branch name from tracking information
780 // Format can be: "main" or "main...origin/master"
781 branchInfo := parts[0]
782 if strings.Contains(branchInfo, "...") {
783 // Split on "..." and take the local branch name
784 branchParts := strings.Split(branchInfo, "...")
785 status.Branch = branchParts[0]
786 } else {
787 status.Branch = branchInfo
788 }
iomodo5a7e4e72025-07-25 13:21:41 +0400789 }
790 continue
791 }
792
793 if len(line) < 3 {
794 continue
795 }
796
797 // Parse file status
798 staged := line[0:1]
799 unstaged := line[1:2]
800 path := strings.TrimSpace(line[3:])
801
802 if staged != " " {
803 status.Staged = append(status.Staged, FileStatus{
804 Path: path,
805 Status: g.parseStatusCode(staged),
806 Staged: true,
807 })
808 }
809
810 if unstaged != " " {
811 status.Unstaged = append(status.Unstaged, FileStatus{
812 Path: path,
813 Status: g.parseStatusCode(unstaged),
814 Staged: false,
815 })
816 }
817
818 if staged == " " && unstaged == "?" {
819 status.Untracked = append(status.Untracked, path)
820 }
821 }
822
823 status.IsClean = len(status.Staged) == 0 && len(status.Unstaged) == 0 && len(status.Untracked) == 0
824 return status, nil
825}
826
827func (g *Git) parseStatusCode(code string) string {
828 switch code {
829 case "M":
830 return "modified"
831 case "A":
832 return "added"
833 case "D":
834 return "deleted"
835 case "R":
836 return "renamed"
837 case "C":
838 return "copied"
839 case "U":
840 return "unmerged"
841 default:
842 return "unknown"
843 }
844}
845
846func (g *Git) parseLog(output string) ([]Commit, error) {
847 lines := strings.Split(strings.TrimSpace(output), "\n")
848 var commits []Commit
849
850 // Each commit takes 9 lines in the format:
851 // Hash, AuthorName, AuthorEmail, AuthorTime, Subject, CommitterName, CommitterEmail, CommitterTime, Parents
852 for i := 0; i < len(lines); i += 9 {
853 if i+8 >= len(lines) {
854 break
855 }
856
857 hash := lines[i]
858 authorName := lines[i+1]
859 authorEmail := lines[i+2]
860 authorTimeStr := lines[i+3]
861 subject := lines[i+4]
862 committerName := lines[i+5]
863 committerEmail := lines[i+6]
864 committerTimeStr := lines[i+7]
865 parentsStr := lines[i+8]
866
867 // Parse timestamps
868 authorTime, _ := strconv.ParseInt(authorTimeStr, 10, 64)
869 committerTime, _ := strconv.ParseInt(committerTimeStr, 10, 64)
870
871 // Parse parents
872 var parents []string
873 if parentsStr != "" {
874 parents = strings.Fields(parentsStr)
875 }
876
877 commit := Commit{
878 Hash: hash,
879 Author: Author{
880 Name: authorName,
881 Email: authorEmail,
882 Time: time.Unix(authorTime, 0),
883 },
884 Committer: Author{
885 Name: committerName,
886 Email: committerEmail,
887 Time: time.Unix(committerTime, 0),
888 },
889 Message: subject,
890 Parents: parents,
891 Timestamp: time.Unix(authorTime, 0),
892 }
893
894 commits = append(commits, commit)
895 }
896
897 return commits, nil
898}
899
900func (g *Git) parseBranches(output string) ([]Branch, error) {
901 lines := strings.Split(strings.TrimSpace(output), "\n")
902 var branches []Branch
903
904 for _, line := range lines {
905 if strings.TrimSpace(line) == "" {
906 continue
907 }
908
909 parts := strings.Split(line, "\t")
910 if len(parts) < 3 {
911 continue
912 }
913
914 branch := Branch{
915 Name: parts[0],
916 Commit: parts[1],
917 Message: parts[2],
918 IsRemote: strings.HasPrefix(parts[0], "remotes/"),
919 }
920
921 // Check if this is the current branch
922 if !branch.IsRemote {
923 branch.IsCurrent = strings.HasPrefix(line, "*")
924 }
925
926 branches = append(branches, branch)
927 }
928
929 return branches, nil
930}
931
932func (g *Git) parseRemotes(output string) ([]Remote, error) {
933 lines := strings.Split(strings.TrimSpace(output), "\n")
934 var remotes []Remote
935 seen := make(map[string]bool)
936
937 for _, line := range lines {
938 if strings.TrimSpace(line) == "" {
939 continue
940 }
941
942 parts := strings.Fields(line)
943 if len(parts) < 3 {
944 continue
945 }
946
947 name := parts[0]
948 if seen[name] {
949 continue
950 }
951
952 remotes = append(remotes, Remote{
953 Name: name,
954 URL: parts[1],
955 })
956 seen[name] = true
957 }
958
959 return remotes, nil
960}
iomodo1d173602025-07-26 15:35:57 +0400961
962// PullRequest represents a pull request or merge request
963type PullRequest struct {
964 ID string
965 Number int
966 Title string
967 Description string
968 State string // "open", "closed", "merged"
969 Author Author
970 CreatedAt time.Time
971 UpdatedAt time.Time
972 BaseBranch string
973 HeadBranch string
974 BaseRepo string
975 HeadRepo string
976 Labels []string
977 Assignees []Author
978 Reviewers []Author
979 Commits []Commit
980 Comments []PullRequestComment
iomodoa53240a2025-07-30 17:33:35 +0400981 URL string
iomodo1d173602025-07-26 15:35:57 +0400982}
983
984// PullRequestComment represents a comment on a pull request
985type PullRequestComment struct {
986 ID string
987 Author Author
988 Content string
989 CreatedAt time.Time
990 UpdatedAt time.Time
991 Path string
992 Line int
993}
994
995// PullRequestOptions defines options for creating/updating pull requests
996type PullRequestOptions struct {
997 Title string
998 Description string
999 BaseBranch string
1000 HeadBranch string
1001 BaseRepo string
1002 HeadRepo string
1003 Labels []string
1004 Assignees []string
1005 Reviewers []string
1006 Draft bool
1007}
1008
1009// ListPullRequestOptions defines options for listing pull requests
1010type ListPullRequestOptions struct {
1011 State string // "open", "closed", "all"
1012 Author string
1013 Assignee string
1014 BaseBranch string
1015 HeadBranch string
1016 Labels []string
1017 Limit int
1018}
1019
1020// MergePullRequestOptions defines options for merging pull requests
1021type MergePullRequestOptions struct {
1022 MergeMethod string // "merge", "squash", "rebase"
1023 CommitTitle string
1024 CommitMsg string
1025}
1026
1027// PullRequestProvider defines the interface for pull request operations
1028type PullRequestProvider interface {
1029 CreatePullRequest(ctx context.Context, options PullRequestOptions) (*PullRequest, error)
1030 GetPullRequest(ctx context.Context, id string) (*PullRequest, error)
1031 ListPullRequests(ctx context.Context, options ListPullRequestOptions) ([]PullRequest, error)
1032 UpdatePullRequest(ctx context.Context, id string, options PullRequestOptions) (*PullRequest, error)
1033 ClosePullRequest(ctx context.Context, id string) error
1034 MergePullRequest(ctx context.Context, id string, options MergePullRequestOptions) error
1035}
iomodo8acd08d2025-07-31 16:22:08 +04001036
1037// WebhookProvider defines the interface for webhook operations
1038type WebhookProvider interface {
1039 ListWebhooks(ctx context.Context) ([]GitHubWebhookResponse, error)
1040 CreateWebhook(ctx context.Context, webhookURL, secret string) (*GitHubWebhookResponse, error)
1041 UpdateWebhook(ctx context.Context, webhookID int, webhookURL, secret string) (*GitHubWebhookResponse, error)
1042}