-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathgit.go
More file actions
208 lines (180 loc) · 8.59 KB
/
git.go
File metadata and controls
208 lines (180 loc) · 8.59 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
// Package git wraps git CLI operations behind a testable interface.
package git
import (
"bytes"
"context"
"fmt"
"log/slog"
"os/exec"
"path/filepath"
"strings"
)
// Runner abstracts git operations for testability.
type Runner interface {
BareClone(ctx context.Context, url, dest string) error
Fetch(ctx context.Context, repoPath string) error
AddWorktree(ctx context.Context, bareRepo, worktreePath, branch string) error
AddWorktreeNewBranch(ctx context.Context, bareRepo, worktreePath, newBranch, startPoint string) error
RemoveWorktree(ctx context.Context, bareRepo, worktreePath string) error
BranchExists(ctx context.Context, bareRepo, branch string) (bool, error)
DefaultBranch(ctx context.Context, bareRepo string) (string, error)
EnsureRemoteRef(ctx context.Context, bareRepo, branch string) error
ResetBranch(ctx context.Context, worktreePath, ref string) error
IsClean(ctx context.Context, worktreePath string) (bool, error)
CurrentBranch(ctx context.Context, worktreePath string) (string, error)
CheckoutBranch(ctx context.Context, worktreePath, branch string) error
CheckoutNewBranch(ctx context.Context, worktreePath, newBranch, startPoint string) error
Rebase(ctx context.Context, worktreePath, onto string) error
RebaseAbort(ctx context.Context, worktreePath string) error
}
// RealRunner shells out to the git binary.
type RealRunner struct {
Log *slog.Logger
}
func (r *RealRunner) log() *slog.Logger {
if r.Log != nil {
return r.Log
}
return slog.Default()
}
func (r *RealRunner) run(ctx context.Context, args ...string) error {
r.log().Debug("executing git command", "args", args)
cmd := exec.CommandContext(ctx, "git", args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
errMsg := strings.TrimSpace(stderr.String())
if errMsg != "" {
return fmt.Errorf("git %s: %w: %s", args[0], err, errMsg)
}
return fmt.Errorf("git %s: %w", args[0], err)
}
return nil
}
func (r *RealRunner) output(ctx context.Context, args ...string) (string, error) {
r.log().Debug("executing git command", "args", args)
cmd := exec.CommandContext(ctx, "git", args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
errMsg := strings.TrimSpace(stderr.String())
if errMsg != "" {
return "", fmt.Errorf("git %s: %w: %s", args[0], err, errMsg)
}
return "", fmt.Errorf("git %s: %w", args[0], err)
}
return strings.TrimSpace(stdout.String()), nil
}
// BareClone creates a bare clone of a repository.
func (r *RealRunner) BareClone(ctx context.Context, url, dest string) error {
r.log().Debug("bare cloning repository", "url", url, "dest", dest)
cloneURL := url
if !strings.Contains(url, "://") && !strings.HasPrefix(url, "git@") && !filepath.IsAbs(url) && !strings.HasPrefix(url, ".") {
cloneURL = "https://" + url
}
return r.run(ctx, "clone", "--bare", cloneURL, dest)
}
// Fetch fetches all refs in a bare repository and ensures the default branch
// has a remote tracking ref so status checks can reference origin/main.
func (r *RealRunner) Fetch(ctx context.Context, repoPath string) error {
r.log().Debug("fetching repository", "path", repoPath)
if err := r.run(ctx, "-C", repoPath, "fetch", "--all", "--prune"); err != nil {
return err
}
// Create a remote tracking ref for the default branch so status checks
// can compare against origin/main (or origin/master). Bare clones store
// refs in refs/heads/ and don't create refs/remotes/origin/* by default.
// We only track the default branch to avoid case-conflict errors on
// macOS for repos with branches that differ only by casing.
defaultBranch, err := r.output(ctx, "-C", repoPath, "symbolic-ref", "--short", "HEAD")
if err == nil && defaultBranch != "" {
_ = r.run(ctx, "-C", repoPath, "fetch", "origin",
"+refs/heads/"+defaultBranch+":refs/remotes/origin/"+defaultBranch)
_ = r.run(ctx, "-C", repoPath, "symbolic-ref",
"refs/remotes/origin/HEAD", "refs/remotes/origin/"+defaultBranch)
}
return nil
}
// AddWorktree creates a worktree from a bare repo at the given path and branch.
// Uses --force to allow the same branch to be checked out in multiple worktrees
// across different workspaces.
func (r *RealRunner) AddWorktree(ctx context.Context, bareRepo, worktreePath, branch string) error {
r.log().Debug("adding worktree", "bare_repo", bareRepo, "worktree", worktreePath, "branch", branch)
return r.run(ctx, "-C", bareRepo, "worktree", "add", "--force", worktreePath, branch)
}
// AddWorktreeNewBranch creates a worktree with a new branch starting from startPoint.
func (r *RealRunner) AddWorktreeNewBranch(ctx context.Context, bareRepo, worktreePath, newBranch, startPoint string) error {
r.log().Debug("adding worktree with new branch", "bare_repo", bareRepo, "worktree", worktreePath, "branch", newBranch, "start_point", startPoint)
return r.run(ctx, "-C", bareRepo, "worktree", "add", "-b", newBranch, worktreePath, startPoint)
}
// RemoveWorktree removes a worktree from a bare repo.
func (r *RealRunner) RemoveWorktree(ctx context.Context, bareRepo, worktreePath string) error {
r.log().Debug("removing worktree", "bare_repo", bareRepo, "worktree", worktreePath)
return r.run(ctx, "-C", bareRepo, "worktree", "remove", "--force", worktreePath)
}
// BranchExists checks if a branch exists in the bare repo (local or remote ref).
func (r *RealRunner) BranchExists(ctx context.Context, bareRepo, branch string) (bool, error) {
// Check for exact ref match: heads/<branch> or remotes/origin/<branch>
out, err := r.output(ctx, "-C", bareRepo, "branch", "-a", "--list", branch, "origin/"+branch)
if err != nil {
return false, err
}
return out != "", nil
}
// DefaultBranch returns the default branch name (e.g. "main" or "master") for a bare repo.
func (r *RealRunner) DefaultBranch(ctx context.Context, bareRepo string) (string, error) {
// In a bare clone, HEAD points to the default branch
out, err := r.output(ctx, "-C", bareRepo, "symbolic-ref", "--short", "HEAD")
if err != nil {
return "", fmt.Errorf("determining default branch: %w", err)
}
return out, nil
}
// EnsureRemoteRef creates refs/remotes/origin/{branch} in a bare repo so
// origin/{branch} resolves from worktrees.
func (r *RealRunner) EnsureRemoteRef(ctx context.Context, bareRepo, branch string) error {
r.log().Debug("ensuring remote ref", "bare_repo", bareRepo, "branch", branch)
return r.run(ctx, "-C", bareRepo, "fetch", "origin",
"+refs/heads/"+branch+":refs/remotes/origin/"+branch)
}
// ResetBranch resets the current branch in a worktree to the given ref.
// This is used to fast-forward existing worktrees to the latest remote state.
func (r *RealRunner) ResetBranch(ctx context.Context, worktreePath, ref string) error {
r.log().Debug("resetting branch", "path", worktreePath, "ref", ref)
return r.run(ctx, "-C", worktreePath, "reset", "--hard", ref)
}
// IsClean returns true if the worktree has no uncommitted changes.
func (r *RealRunner) IsClean(ctx context.Context, worktreePath string) (bool, error) {
r.log().Debug("checking worktree cleanliness", "path", worktreePath)
out, err := r.output(ctx, "-C", worktreePath, "status", "--porcelain")
if err != nil {
return false, err
}
return out == "", nil
}
// CurrentBranch returns the currently checked-out branch in a worktree.
func (r *RealRunner) CurrentBranch(ctx context.Context, worktreePath string) (string, error) {
r.log().Debug("getting current branch", "path", worktreePath)
return r.output(ctx, "-C", worktreePath, "rev-parse", "--abbrev-ref", "HEAD")
}
// CheckoutBranch switches to an existing branch in a worktree.
func (r *RealRunner) CheckoutBranch(ctx context.Context, worktreePath, branch string) error {
r.log().Debug("checking out branch", "path", worktreePath, "branch", branch)
return r.run(ctx, "-C", worktreePath, "checkout", branch)
}
// CheckoutNewBranch creates and switches to a new branch from a start point.
func (r *RealRunner) CheckoutNewBranch(ctx context.Context, worktreePath, newBranch, startPoint string) error {
r.log().Debug("checking out new branch", "path", worktreePath, "branch", newBranch, "start_point", startPoint)
return r.run(ctx, "-C", worktreePath, "checkout", "-b", newBranch, startPoint)
}
// Rebase rebases the current branch onto the given ref.
func (r *RealRunner) Rebase(ctx context.Context, worktreePath, onto string) error {
r.log().Debug("rebasing", "path", worktreePath, "onto", onto)
return r.run(ctx, "-C", worktreePath, "rebase", onto)
}
// RebaseAbort aborts a rebase in progress.
func (r *RealRunner) RebaseAbort(ctx context.Context, worktreePath string) error {
r.log().Debug("aborting rebase", "path", worktreePath)
return r.run(ctx, "-C", worktreePath, "rebase", "--abort")
}