blob: bf95f7cc9da204acd94966b02e33d69d174d9ebc [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 {
616 status.Branch = parts[0]
617 }
618 continue
619 }
620
621 if len(line) < 3 {
622 continue
623 }
624
625 // Parse file status
626 staged := line[0:1]
627 unstaged := line[1:2]
628 path := strings.TrimSpace(line[3:])
629
630 if staged != " " {
631 status.Staged = append(status.Staged, FileStatus{
632 Path: path,
633 Status: g.parseStatusCode(staged),
634 Staged: true,
635 })
636 }
637
638 if unstaged != " " {
639 status.Unstaged = append(status.Unstaged, FileStatus{
640 Path: path,
641 Status: g.parseStatusCode(unstaged),
642 Staged: false,
643 })
644 }
645
646 if staged == " " && unstaged == "?" {
647 status.Untracked = append(status.Untracked, path)
648 }
649 }
650
651 status.IsClean = len(status.Staged) == 0 && len(status.Unstaged) == 0 && len(status.Untracked) == 0
652 return status, nil
653}
654
655func (g *Git) parseStatusCode(code string) string {
656 switch code {
657 case "M":
658 return "modified"
659 case "A":
660 return "added"
661 case "D":
662 return "deleted"
663 case "R":
664 return "renamed"
665 case "C":
666 return "copied"
667 case "U":
668 return "unmerged"
669 default:
670 return "unknown"
671 }
672}
673
674func (g *Git) parseLog(output string) ([]Commit, error) {
675 lines := strings.Split(strings.TrimSpace(output), "\n")
676 var commits []Commit
677
678 // Each commit takes 9 lines in the format:
679 // Hash, AuthorName, AuthorEmail, AuthorTime, Subject, CommitterName, CommitterEmail, CommitterTime, Parents
680 for i := 0; i < len(lines); i += 9 {
681 if i+8 >= len(lines) {
682 break
683 }
684
685 hash := lines[i]
686 authorName := lines[i+1]
687 authorEmail := lines[i+2]
688 authorTimeStr := lines[i+3]
689 subject := lines[i+4]
690 committerName := lines[i+5]
691 committerEmail := lines[i+6]
692 committerTimeStr := lines[i+7]
693 parentsStr := lines[i+8]
694
695 // Parse timestamps
696 authorTime, _ := strconv.ParseInt(authorTimeStr, 10, 64)
697 committerTime, _ := strconv.ParseInt(committerTimeStr, 10, 64)
698
699 // Parse parents
700 var parents []string
701 if parentsStr != "" {
702 parents = strings.Fields(parentsStr)
703 }
704
705 commit := Commit{
706 Hash: hash,
707 Author: Author{
708 Name: authorName,
709 Email: authorEmail,
710 Time: time.Unix(authorTime, 0),
711 },
712 Committer: Author{
713 Name: committerName,
714 Email: committerEmail,
715 Time: time.Unix(committerTime, 0),
716 },
717 Message: subject,
718 Parents: parents,
719 Timestamp: time.Unix(authorTime, 0),
720 }
721
722 commits = append(commits, commit)
723 }
724
725 return commits, nil
726}
727
728func (g *Git) parseBranches(output string) ([]Branch, error) {
729 lines := strings.Split(strings.TrimSpace(output), "\n")
730 var branches []Branch
731
732 for _, line := range lines {
733 if strings.TrimSpace(line) == "" {
734 continue
735 }
736
737 parts := strings.Split(line, "\t")
738 if len(parts) < 3 {
739 continue
740 }
741
742 branch := Branch{
743 Name: parts[0],
744 Commit: parts[1],
745 Message: parts[2],
746 IsRemote: strings.HasPrefix(parts[0], "remotes/"),
747 }
748
749 // Check if this is the current branch
750 if !branch.IsRemote {
751 branch.IsCurrent = strings.HasPrefix(line, "*")
752 }
753
754 branches = append(branches, branch)
755 }
756
757 return branches, nil
758}
759
760func (g *Git) parseRemotes(output string) ([]Remote, error) {
761 lines := strings.Split(strings.TrimSpace(output), "\n")
762 var remotes []Remote
763 seen := make(map[string]bool)
764
765 for _, line := range lines {
766 if strings.TrimSpace(line) == "" {
767 continue
768 }
769
770 parts := strings.Fields(line)
771 if len(parts) < 3 {
772 continue
773 }
774
775 name := parts[0]
776 if seen[name] {
777 continue
778 }
779
780 remotes = append(remotes, Remote{
781 Name: name,
782 URL: parts[1],
783 })
784 seen[name] = true
785 }
786
787 return remotes, nil
788}