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