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