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