blob: fdcd467d01016d932dcc2b275e95902678eaee49 [file] [log] [blame]
iomodo5a7e4e72025-07-25 13:21:41 +04001package git
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "strconv"
10 "strings"
11 "time"
12)
13
14// GitInterface defines the contract for Git operations
15type GitInterface interface {
16 // Repository operations
17 Init(ctx context.Context, path string) error
18 Clone(ctx context.Context, url, path string) error
19 IsRepository(ctx context.Context, path string) (bool, error)
20
21 // Status and information
22 Status(ctx context.Context) (*Status, error)
23 Log(ctx context.Context, options LogOptions) ([]Commit, error)
24 Show(ctx context.Context, ref string) (*Commit, error)
25
26 // Branch operations
27 ListBranches(ctx context.Context) ([]Branch, error)
28 CreateBranch(ctx context.Context, name string, startPoint string) error
29 Checkout(ctx context.Context, ref string) error
30 DeleteBranch(ctx context.Context, name string, force bool) error
31 GetCurrentBranch(ctx context.Context) (string, error)
32
33 // Commit operations
34 Add(ctx context.Context, paths []string) error
35 AddAll(ctx context.Context) error
36 Commit(ctx context.Context, message string, options CommitOptions) error
37
38 // Remote operations
39 ListRemotes(ctx context.Context) ([]Remote, error)
40 AddRemote(ctx context.Context, name, url string) error
41 RemoveRemote(ctx context.Context, name string) error
42 Fetch(ctx context.Context, remote string, options FetchOptions) error
43 Pull(ctx context.Context, remote, branch string) error
44 Push(ctx context.Context, remote, branch string, options PushOptions) error
45
46 // Merge operations
47 Merge(ctx context.Context, ref string, options MergeOptions) error
48 MergeBase(ctx context.Context, ref1, ref2 string) (string, error)
49
50 // Configuration
51 GetConfig(ctx context.Context, key string) (string, error)
52 SetConfig(ctx context.Context, key, value string) error
53 GetUserConfig(ctx context.Context) (*UserConfig, error)
54 SetUserConfig(ctx context.Context, config UserConfig) error
55}
56
57// Status represents the current state of the repository
58type Status struct {
59 Branch string
60 IsClean bool
61 Staged []FileStatus
62 Unstaged []FileStatus
63 Untracked []string
64 Conflicts []string
65}
66
67// FileStatus represents the status of a file
68type FileStatus struct {
69 Path string
70 Status string // "modified", "added", "deleted", "renamed", etc.
71 Staged bool
72}
73
74// Commit represents a Git commit
75type Commit struct {
76 Hash string
77 Author Author
78 Committer Author
79 Message string
80 Parents []string
81 Timestamp time.Time
82 Files []CommitFile
83}
84
85// Author represents a Git author or committer
86type Author struct {
87 Name string
88 Email string
89 Time time.Time
90}
91
92// CommitFile represents a file in a commit
93type CommitFile struct {
94 Path string
95 Status string // "added", "modified", "deleted", "renamed"
96 Additions int
97 Deletions int
98}
99
100// Branch represents a Git branch
101type Branch struct {
102 Name string
103 IsCurrent bool
104 IsRemote bool
105 Commit string
106 Message string
107}
108
109// Remote represents a Git remote
110type Remote struct {
111 Name string
112 URL string
113}
114
115// UserConfig represents Git user configuration
116type UserConfig struct {
117 Name string
118 Email string
119}
120
121// LogOptions defines options for log operations
122type LogOptions struct {
123 MaxCount int
124 Since time.Time
125 Until time.Time
126 Author string
127 Path string
128 Oneline bool
129}
130
131// CommitOptions defines options for commit operations
132type CommitOptions struct {
133 Author *Author
134 Committer *Author
135 Sign bool
136 AllowEmpty bool
137}
138
139// FetchOptions defines options for fetch operations
140type FetchOptions struct {
141 All bool
142 Tags bool
143 Depth int
144 Prune bool
145}
146
147// PushOptions defines options for push operations
148type PushOptions struct {
149 Force bool
150 Tags bool
151 SetUpstream bool
152}
153
154// MergeOptions defines options for merge operations
155type MergeOptions struct {
156 NoFF bool
157 Message string
158 Strategy string
159}
160
161// GitError represents a Git-specific error
162type GitError struct {
163 Command string
164 Output string
165 Err error
166}
167
168func (e *GitError) Error() string {
169 if e.Err != nil {
170 return fmt.Sprintf("git %s failed: %v\nOutput: %s", e.Command, e.Err, e.Output)
171 }
172 return fmt.Sprintf("git %s failed\nOutput: %s", e.Command, e.Output)
173}
174
175func (e *GitError) Unwrap() error {
176 return e.Err
177}
178
179// Git implementation using os/exec to call git commands
180type Git struct {
181 repoPath string
182 config GitConfig
183}
184
185// GitConfig holds configuration for Git operations
186type GitConfig struct {
187 Timeout time.Duration
188 Env map[string]string
189}
190
191// NewGit creates a new Git instance
192func NewGit(repoPath string, config GitConfig) GitInterface {
193 if config.Timeout == 0 {
194 config.Timeout = 30 * time.Second
195 }
196
197 return &Git{
198 repoPath: repoPath,
199 config: config,
200 }
201}
202
203// DefaultGit creates a Git instance with default configuration
204func DefaultGit(repoPath string) GitInterface {
205 return NewGit(repoPath, GitConfig{
206 Timeout: 30 * time.Second,
207 Env: make(map[string]string),
208 })
209}
210
211// Ensure Git implements GitInterface
212var _ GitInterface = (*Git)(nil)
213
214// Init initializes a new Git repository
215func (g *Git) Init(ctx context.Context, path string) error {
216 cmd := exec.CommandContext(ctx, "git", "init")
217 cmd.Dir = path
218 return g.runCommand(cmd, "init")
219}
220
221// Clone clones a repository from URL to path
222func (g *Git) Clone(ctx context.Context, url, path string) error {
223 cmd := exec.CommandContext(ctx, "git", "clone", url, path)
224 return g.runCommand(cmd, "clone")
225}
226
227// IsRepository checks if a path is a valid Git repository
228func (g *Git) IsRepository(ctx context.Context, path string) (bool, error) {
229 return g.isValidRepo(path), nil
230}
231
232// Status returns the current status of the repository
233func (g *Git) Status(ctx context.Context) (*Status, error) {
234 cmd := exec.CommandContext(ctx, "git", "status", "--porcelain", "--branch")
235 cmd.Dir = g.repoPath
236 output, err := g.runCommandWithOutput(cmd, "status")
237 if err != nil {
238 return nil, err
239 }
240
241 return g.parseStatus(output)
242}
243
244// Log returns commit history
245func (g *Git) Log(ctx context.Context, options LogOptions) ([]Commit, error) {
246 args := []string{"log", "--format=%H%n%an%n%ae%n%at%n%s%n%cn%n%ce%n%ct%n%P"}
247
248 if options.MaxCount > 0 {
249 args = append(args, fmt.Sprintf("-%d", options.MaxCount))
250 }
251
252 if !options.Since.IsZero() {
253 args = append(args, fmt.Sprintf("--since=%s", options.Since.Format(time.RFC3339)))
254 }
255
256 if !options.Until.IsZero() {
257 args = append(args, fmt.Sprintf("--until=%s", options.Until.Format(time.RFC3339)))
258 }
259
260 if options.Author != "" {
261 args = append(args, fmt.Sprintf("--author=%s", options.Author))
262 }
263
264 if options.Path != "" {
265 args = append(args, "--", options.Path)
266 }
267
268 if options.Oneline {
269 args = append(args, "--oneline")
270 }
271
272 cmd := exec.CommandContext(ctx, "git", args...)
273 cmd.Dir = g.repoPath
274 output, err := g.runCommandWithOutput(cmd, "log")
275 if err != nil {
276 return nil, err
277 }
278
279 return g.parseLog(output)
280}
281
282// Show shows commit details
283func (g *Git) Show(ctx context.Context, ref string) (*Commit, error) {
284 cmd := exec.CommandContext(ctx, "git", "show", "--format=json", ref)
285 cmd.Dir = g.repoPath
286 output, err := g.runCommandWithOutput(cmd, "show")
287 if err != nil {
288 return nil, err
289 }
290
291 commits, err := g.parseLog(output)
292 if err != nil || len(commits) == 0 {
293 return nil, &GitError{Command: "show", Output: "failed to parse commit"}
294 }
295
296 return &commits[0], nil
297}
298
299// ListBranches returns all branches
300func (g *Git) ListBranches(ctx context.Context) ([]Branch, error) {
301 cmd := exec.CommandContext(ctx, "git", "branch", "-a", "--format=%(refname:short)%09%(objectname)%09%(contents:subject)")
302 cmd.Dir = g.repoPath
303 output, err := g.runCommandWithOutput(cmd, "branch")
304 if err != nil {
305 return nil, err
306 }
307
308 return g.parseBranches(output)
309}
310
311// CreateBranch creates a new branch
312func (g *Git) CreateBranch(ctx context.Context, name string, startPoint string) error {
313 args := []string{"branch", name}
314 if startPoint != "" {
315 args = append(args, startPoint)
316 }
317
318 cmd := exec.CommandContext(ctx, "git", args...)
319 cmd.Dir = g.repoPath
320 return g.runCommand(cmd, "branch")
321}
322
323// Checkout switches to a branch or commit
324func (g *Git) Checkout(ctx context.Context, ref string) error {
325 cmd := exec.CommandContext(ctx, "git", "checkout", ref)
326 cmd.Dir = g.repoPath
327 return g.runCommand(cmd, "checkout")
328}
329
330// DeleteBranch deletes a branch
331func (g *Git) DeleteBranch(ctx context.Context, name string, force bool) error {
332 args := []string{"branch"}
333 if force {
334 args = append(args, "-D")
335 } else {
336 args = append(args, "-d")
337 }
338 args = append(args, name)
339
340 cmd := exec.CommandContext(ctx, "git", args...)
341 cmd.Dir = g.repoPath
342 return g.runCommand(cmd, "branch")
343}
344
345// GetCurrentBranch returns the current branch name
346func (g *Git) GetCurrentBranch(ctx context.Context) (string, error) {
347 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "HEAD")
348 cmd.Dir = g.repoPath
349 output, err := g.runCommandWithOutput(cmd, "rev-parse")
350 if err != nil {
351 return "", err
352 }
353
354 return strings.TrimSpace(output), nil
355}
356
357// Add stages files for commit
358func (g *Git) Add(ctx context.Context, paths []string) error {
359 args := append([]string{"add"}, paths...)
360 cmd := exec.CommandContext(ctx, "git", args...)
361 cmd.Dir = g.repoPath
362 return g.runCommand(cmd, "add")
363}
364
365// AddAll stages all changes
366func (g *Git) AddAll(ctx context.Context) error {
367 cmd := exec.CommandContext(ctx, "git", "add", "-A")
368 cmd.Dir = g.repoPath
369 return g.runCommand(cmd, "add")
370}
371
372// Commit creates a new commit
373func (g *Git) Commit(ctx context.Context, message string, options CommitOptions) error {
374 args := []string{"commit", "-m", message}
375
376 if options.Author != nil {
377 args = append(args, fmt.Sprintf("--author=%s <%s>", options.Author.Name, options.Author.Email))
378 }
379
380 if options.Sign {
381 args = append(args, "-S")
382 }
383
384 if options.AllowEmpty {
385 args = append(args, "--allow-empty")
386 }
387
388 cmd := exec.CommandContext(ctx, "git", args...)
389 cmd.Dir = g.repoPath
390 return g.runCommand(cmd, "commit")
391}
392
393// ListRemotes returns all remotes
394func (g *Git) ListRemotes(ctx context.Context) ([]Remote, error) {
395 cmd := exec.CommandContext(ctx, "git", "remote", "-v")
396 cmd.Dir = g.repoPath
397 output, err := g.runCommandWithOutput(cmd, "remote")
398 if err != nil {
399 return nil, err
400 }
401
402 return g.parseRemotes(output)
403}
404
405// AddRemote adds a new remote
406func (g *Git) AddRemote(ctx context.Context, name, url string) error {
407 cmd := exec.CommandContext(ctx, "git", "remote", "add", name, url)
408 cmd.Dir = g.repoPath
409 return g.runCommand(cmd, "remote")
410}
411
412// RemoveRemote removes a remote
413func (g *Git) RemoveRemote(ctx context.Context, name string) error {
414 cmd := exec.CommandContext(ctx, "git", "remote", "remove", name)
415 cmd.Dir = g.repoPath
416 return g.runCommand(cmd, "remote")
417}
418
419// Fetch fetches from a remote
420func (g *Git) Fetch(ctx context.Context, remote string, options FetchOptions) error {
421 args := []string{"fetch"}
422
423 if options.All {
424 args = append(args, "--all")
425 } else if remote != "" {
426 args = append(args, remote)
427 }
428
429 if options.Tags {
430 args = append(args, "--tags")
431 }
432
433 if options.Depth > 0 {
434 args = append(args, fmt.Sprintf("--depth=%d", options.Depth))
435 }
436
437 if options.Prune {
438 args = append(args, "--prune")
439 }
440
441 cmd := exec.CommandContext(ctx, "git", args...)
442 cmd.Dir = g.repoPath
443 return g.runCommand(cmd, "fetch")
444}
445
446// Pull pulls from a remote
447func (g *Git) Pull(ctx context.Context, remote, branch string) error {
448 args := []string{"pull"}
449 if remote != "" {
450 args = append(args, remote)
451 if branch != "" {
452 args = append(args, branch)
453 }
454 }
455
456 cmd := exec.CommandContext(ctx, "git", args...)
457 cmd.Dir = g.repoPath
458 return g.runCommand(cmd, "pull")
459}
460
461// Push pushes to a remote
462func (g *Git) Push(ctx context.Context, remote, branch string, options PushOptions) error {
463 args := []string{"push"}
464
465 if options.Force {
466 args = append(args, "--force")
467 }
468
469 if options.Tags {
470 args = append(args, "--tags")
471 }
472
473 if options.SetUpstream {
474 args = append(args, "--set-upstream")
475 }
476
477 if remote != "" {
478 args = append(args, remote)
479 if branch != "" {
480 args = append(args, branch)
481 }
482 }
483
484 cmd := exec.CommandContext(ctx, "git", args...)
485 cmd.Dir = g.repoPath
486 return g.runCommand(cmd, "push")
487}
488
489// Merge merges a branch into current branch
490func (g *Git) Merge(ctx context.Context, ref string, options MergeOptions) error {
491 args := []string{"merge"}
492
493 if options.NoFF {
494 args = append(args, "--no-ff")
495 }
496
497 if options.Message != "" {
498 args = append(args, "-m", options.Message)
499 }
500
501 if options.Strategy != "" {
502 args = append(args, "-s", options.Strategy)
503 }
504
505 args = append(args, ref)
506
507 cmd := exec.CommandContext(ctx, "git", args...)
508 cmd.Dir = g.repoPath
509 return g.runCommand(cmd, "merge")
510}
511
512// MergeBase finds the common ancestor of two commits
513func (g *Git) MergeBase(ctx context.Context, ref1, ref2 string) (string, error) {
514 cmd := exec.CommandContext(ctx, "git", "merge-base", ref1, ref2)
515 cmd.Dir = g.repoPath
516 output, err := g.runCommandWithOutput(cmd, "merge-base")
517 if err != nil {
518 return "", err
519 }
520
521 return strings.TrimSpace(output), nil
522}
523
524// GetConfig gets a Git configuration value
525func (g *Git) GetConfig(ctx context.Context, key string) (string, error) {
526 cmd := exec.CommandContext(ctx, "git", "config", "--get", key)
527 cmd.Dir = g.repoPath
528 output, err := g.runCommandWithOutput(cmd, "config")
529 if err != nil {
530 return "", err
531 }
532
533 return strings.TrimSpace(output), nil
534}
535
536// SetConfig sets a Git configuration value
537func (g *Git) SetConfig(ctx context.Context, key, value string) error {
538 cmd := exec.CommandContext(ctx, "git", "config", key, value)
539 cmd.Dir = g.repoPath
540 return g.runCommand(cmd, "config")
541}
542
543// GetUserConfig gets user configuration
544func (g *Git) GetUserConfig(ctx context.Context) (*UserConfig, error) {
545 name, err := g.GetConfig(ctx, "user.name")
546 if err != nil {
547 return nil, err
548 }
549
550 email, err := g.GetConfig(ctx, "user.email")
551 if err != nil {
552 return nil, err
553 }
554
555 return &UserConfig{
556 Name: name,
557 Email: email,
558 }, nil
559}
560
561// SetUserConfig sets user configuration
562func (g *Git) SetUserConfig(ctx context.Context, config UserConfig) error {
563 if err := g.SetConfig(ctx, "user.name", config.Name); err != nil {
564 return err
565 }
566
567 return g.SetConfig(ctx, "user.email", config.Email)
568}
569
570// Helper methods
571
572func (g *Git) runCommand(cmd *exec.Cmd, command string) error {
573 output, err := cmd.CombinedOutput()
574 if err != nil {
575 return &GitError{
576 Command: command,
577 Output: string(output),
578 Err: err,
579 }
580 }
581 return nil
582}
583
584func (g *Git) runCommandWithOutput(cmd *exec.Cmd, command string) (string, error) {
585 output, err := cmd.CombinedOutput()
586 if err != nil {
587 return "", &GitError{
588 Command: command,
589 Output: string(output),
590 Err: err,
591 }
592 }
593 return string(output), nil
594}
595
596func (g *Git) isValidRepo(path string) bool {
597 gitDir := filepath.Join(path, ".git")
598 info, err := os.Stat(gitDir)
599 return err == nil && info.IsDir()
600}
601
602func (g *Git) parseStatus(output string) (*Status, error) {
603 lines := strings.Split(strings.TrimSpace(output), "\n")
604 status := &Status{
605 Staged: []FileStatus{},
606 Unstaged: []FileStatus{},
607 Untracked: []string{},
608 Conflicts: []string{},
609 }
610
611 for _, line := range lines {
612 if strings.HasPrefix(line, "## ") {
613 // Parse branch info
614 parts := strings.Fields(line[3:])
615 if len(parts) > 0 {
iomodoccbc0092025-07-25 20:29:47 +0400616 // Extract local branch name from tracking information
617 // Format can be: "main" or "main...origin/master"
618 branchInfo := parts[0]
619 if strings.Contains(branchInfo, "...") {
620 // Split on "..." and take the local branch name
621 branchParts := strings.Split(branchInfo, "...")
622 status.Branch = branchParts[0]
623 } else {
624 status.Branch = branchInfo
625 }
iomodo5a7e4e72025-07-25 13:21:41 +0400626 }
627 continue
628 }
629
630 if len(line) < 3 {
631 continue
632 }
633
634 // Parse file status
635 staged := line[0:1]
636 unstaged := line[1:2]
637 path := strings.TrimSpace(line[3:])
638
639 if staged != " " {
640 status.Staged = append(status.Staged, FileStatus{
641 Path: path,
642 Status: g.parseStatusCode(staged),
643 Staged: true,
644 })
645 }
646
647 if unstaged != " " {
648 status.Unstaged = append(status.Unstaged, FileStatus{
649 Path: path,
650 Status: g.parseStatusCode(unstaged),
651 Staged: false,
652 })
653 }
654
655 if staged == " " && unstaged == "?" {
656 status.Untracked = append(status.Untracked, path)
657 }
658 }
659
660 status.IsClean = len(status.Staged) == 0 && len(status.Unstaged) == 0 && len(status.Untracked) == 0
661 return status, nil
662}
663
664func (g *Git) parseStatusCode(code string) string {
665 switch code {
666 case "M":
667 return "modified"
668 case "A":
669 return "added"
670 case "D":
671 return "deleted"
672 case "R":
673 return "renamed"
674 case "C":
675 return "copied"
676 case "U":
677 return "unmerged"
678 default:
679 return "unknown"
680 }
681}
682
683func (g *Git) parseLog(output string) ([]Commit, error) {
684 lines := strings.Split(strings.TrimSpace(output), "\n")
685 var commits []Commit
686
687 // Each commit takes 9 lines in the format:
688 // Hash, AuthorName, AuthorEmail, AuthorTime, Subject, CommitterName, CommitterEmail, CommitterTime, Parents
689 for i := 0; i < len(lines); i += 9 {
690 if i+8 >= len(lines) {
691 break
692 }
693
694 hash := lines[i]
695 authorName := lines[i+1]
696 authorEmail := lines[i+2]
697 authorTimeStr := lines[i+3]
698 subject := lines[i+4]
699 committerName := lines[i+5]
700 committerEmail := lines[i+6]
701 committerTimeStr := lines[i+7]
702 parentsStr := lines[i+8]
703
704 // Parse timestamps
705 authorTime, _ := strconv.ParseInt(authorTimeStr, 10, 64)
706 committerTime, _ := strconv.ParseInt(committerTimeStr, 10, 64)
707
708 // Parse parents
709 var parents []string
710 if parentsStr != "" {
711 parents = strings.Fields(parentsStr)
712 }
713
714 commit := Commit{
715 Hash: hash,
716 Author: Author{
717 Name: authorName,
718 Email: authorEmail,
719 Time: time.Unix(authorTime, 0),
720 },
721 Committer: Author{
722 Name: committerName,
723 Email: committerEmail,
724 Time: time.Unix(committerTime, 0),
725 },
726 Message: subject,
727 Parents: parents,
728 Timestamp: time.Unix(authorTime, 0),
729 }
730
731 commits = append(commits, commit)
732 }
733
734 return commits, nil
735}
736
737func (g *Git) parseBranches(output string) ([]Branch, error) {
738 lines := strings.Split(strings.TrimSpace(output), "\n")
739 var branches []Branch
740
741 for _, line := range lines {
742 if strings.TrimSpace(line) == "" {
743 continue
744 }
745
746 parts := strings.Split(line, "\t")
747 if len(parts) < 3 {
748 continue
749 }
750
751 branch := Branch{
752 Name: parts[0],
753 Commit: parts[1],
754 Message: parts[2],
755 IsRemote: strings.HasPrefix(parts[0], "remotes/"),
756 }
757
758 // Check if this is the current branch
759 if !branch.IsRemote {
760 branch.IsCurrent = strings.HasPrefix(line, "*")
761 }
762
763 branches = append(branches, branch)
764 }
765
766 return branches, nil
767}
768
769func (g *Git) parseRemotes(output string) ([]Remote, error) {
770 lines := strings.Split(strings.TrimSpace(output), "\n")
771 var remotes []Remote
772 seen := make(map[string]bool)
773
774 for _, line := range lines {
775 if strings.TrimSpace(line) == "" {
776 continue
777 }
778
779 parts := strings.Fields(line)
780 if len(parts) < 3 {
781 continue
782 }
783
784 name := parts[0]
785 if seen[name] {
786 continue
787 }
788
789 remotes = append(remotes, Remote{
790 Name: name,
791 URL: parts[1],
792 })
793 seen[name] = true
794 }
795
796 return remotes, nil
797}