Skip to content

Commit 94dbc83

Browse files
committed
Add worktree-scoped Git execution
1 parent 2bd699a commit 94dbc83

6 files changed

Lines changed: 1786 additions & 135 deletions

File tree

‎internal/git/git.go‎

Lines changed: 147 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@ import (
55
"errors"
66
"fmt"
77
"os"
8-
"os/exec"
8+
"path/filepath"
99
"strings"
1010
"time"
1111

1212
cligit "github.com/cli/cli/v2/git"
1313
)
1414

15-
// client is a shared git client used by all package-level functions.
15+
// client is used by unscoped operations. Scoped operations keep their own copy.
1616
var client = &cligit.Client{}
1717

1818
// ErrMultipleRemotes is returned by ResolveRemote when multiple remotes
@@ -33,22 +33,58 @@ type CommitInfo struct {
3333
Time time.Time
3434
}
3535

36-
// run executes an arbitrary git command via the client and returns trimmed stdout.
37-
func run(args ...string) (string, error) {
38-
cmd, err := client.Command(context.Background(), args...)
36+
func (d *defaultOps) command(args ...string) (*cligit.Command, error) {
37+
c, err := d.gitClient()
3938
if err != nil {
40-
return "", err
39+
return nil, err
4140
}
42-
out, err := cmd.Output()
41+
cmd, err := c.Command(context.Background(), args...)
42+
if err != nil {
43+
return nil, err
44+
}
45+
d.configureCommand(cmd)
46+
return cmd, nil
47+
}
48+
49+
func (d *defaultOps) configureCommand(cmd *cligit.Command) {
50+
if !d.scoped {
51+
return
52+
}
53+
env := cmd.Environ()
54+
cmd.Env = make([]string, 0, len(env))
55+
for _, entry := range env {
56+
key, _, _ := strings.Cut(entry, "=")
57+
switch strings.ToUpper(key) {
58+
case "GIT_DIR", "GIT_COMMON_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE",
59+
"GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES",
60+
"GIT_PREFIX", "GIT_GRAFT_FILE", "GIT_SHALLOW_FILE",
61+
"GIT_IMPLICIT_WORK_TREE", "GIT_NAMESPACE", "GIT_CONFIG":
62+
// An explicit target must not inherit another checkout's index,
63+
// object database, or working directory from the caller.
64+
continue
65+
}
66+
cmd.Env = append(cmd.Env, entry)
67+
}
68+
}
69+
70+
// runRaw preserves path whitespace, NUL delimiters, and output on nonzero exits.
71+
func (d *defaultOps) runRaw(args ...string) (string, error) {
72+
cmd, err := d.command(args...)
4373
if err != nil {
4474
return "", err
4575
}
46-
return strings.TrimSpace(string(out)), nil
76+
out, err := cmd.Output()
77+
return string(out), err
78+
}
79+
80+
func (d *defaultOps) run(args ...string) (string, error) {
81+
out, err := d.runRaw(args...)
82+
return strings.TrimSpace(out), err
4783
}
4884

4985
// runSilent executes a git command via the client and only returns an error.
50-
func runSilent(args ...string) error {
51-
cmd, err := client.Command(context.Background(), args...)
86+
func (d *defaultOps) runSilent(args ...string) error {
87+
cmd, err := d.command(args...)
5288
if err != nil {
5389
return err
5490
}
@@ -57,8 +93,11 @@ func runSilent(args ...string) error {
5793

5894
// runInteractive runs a git command with stdin/stdout/stderr connected to
5995
// the terminal, allowing interactive programs like editors to work.
60-
func runInteractive(args ...string) error {
61-
cmd := exec.Command("git", args...)
96+
func (d *defaultOps) runInteractive(args ...string) error {
97+
cmd, err := d.command(args...)
98+
if err != nil {
99+
return err
100+
}
62101
cmd.Stdin = os.Stdin
63102
cmd.Stdout = os.Stdout
64103
cmd.Stderr = os.Stderr
@@ -84,69 +123,143 @@ func IsRebaseStartError(err error) bool {
84123
return errors.As(err, &startErr)
85124
}
86125

87-
func runRebaseCommand(args []string, opts RebaseOpts) error {
88-
if IsRebaseInProgress() {
126+
func (d *defaultOps) runRebaseCommand(args []string, opts RebaseOpts) error {
127+
inProgress, err := d.rebaseInProgress()
128+
if err != nil {
129+
return &RebaseStartError{Err: err}
130+
}
131+
if inProgress {
89132
return &RebaseStartError{Err: errors.New("a rebase is already in progress")}
90133
}
91-
err := runSilent(args...)
134+
err = d.runSilent(args...)
92135
if err == nil {
93136
return nil
94137
}
95-
err = tryAutoResolveRebase(err, opts)
96-
if err != nil && !IsRebaseInProgress() {
138+
err = d.tryAutoResolveRebase(err, opts)
139+
if err == nil {
140+
return nil
141+
}
142+
inProgress, stateErr := d.rebaseInProgress()
143+
if stateErr != nil {
144+
return errors.Join(err, stateErr)
145+
}
146+
if !inProgress {
97147
return &RebaseStartError{Err: err}
98148
}
99149
return err
100150
}
101151

102-
// rebaseContinueOnce runs a single git rebase --continue without auto-resolve.
103-
func rebaseContinueOnce(opts RebaseOpts) error {
104-
args := []string{"rebase"}
152+
func rebaseArgs(opts RebaseOpts) []string {
153+
// The cascade owns its ref range and must never stash another worktree.
154+
// Use configuration overrides rather than flags unavailable in Git 2.36.
155+
args := []string{"-c", "rebase.updateRefs=false", "-c", "rebase.autoStash=false", "rebase"}
105156
if opts.CommitterDateIsAuthorDate {
106-
args = append(args, "--committer-date-is-author-date")
157+
// The apply backend loses this option after a conflict. The merge
158+
// backend persists it for continuation and rerere auto-continuation.
159+
args = append(args, "--merge", "--committer-date-is-author-date")
107160
}
108-
args = append(args, "--continue")
109-
cmd := exec.Command("git", args...)
110-
cmd.Env = append(os.Environ(), "GIT_EDITOR=true")
161+
return args
162+
}
163+
164+
// rebaseContinueOnce runs a single git rebase --continue without auto-resolve.
165+
func (d *defaultOps) rebaseContinueOnce(_ RebaseOpts) error {
166+
// The merge backend persists date options at rebase start. Repeating
167+
// start-only options with --continue does not change that saved state.
168+
cmd, err := d.command(append(rebaseArgs(RebaseOpts{}), "--continue")...)
169+
if err != nil {
170+
return err
171+
}
172+
cmd.Env = append(cmd.Environ(), "GIT_EDITOR=true")
111173
return cmd.Run()
112174
}
113175

114176
// tryAutoResolveRebase checks whether rerere has resolved all conflicts
115177
// from a failed rebase. If so, it auto-continues the rebase (potentially
116178
// multiple times for multi-commit rebases). Returns originalErr if any
117179
// conflicts remain that need manual resolution.
118-
func tryAutoResolveRebase(originalErr error, opts RebaseOpts) error {
180+
func (d *defaultOps) tryAutoResolveRebase(originalErr error, opts RebaseOpts) error {
181+
previousStep := ""
119182
for i := 0; i < 1000; i++ {
120-
if !IsRebaseInProgress() {
121-
if i == 0 {
122-
return originalErr
123-
}
124-
return nil
125-
}
126-
conflicts, err := ConflictedFiles()
183+
inProgress, err := d.rebaseInProgress()
127184
if err != nil {
185+
return errors.Join(originalErr, err)
186+
}
187+
if !inProgress {
128188
return originalErr
129189
}
190+
conflicts, err := d.ConflictedFiles()
191+
if err != nil {
192+
return errors.Join(originalErr, err)
193+
}
130194
if len(conflicts) > 0 {
131195
return originalErr
132196
}
197+
step, err := d.rebaseStep()
198+
if err != nil {
199+
return errors.Join(originalErr, err)
200+
}
201+
if step == previousStep {
202+
return originalErr
203+
}
204+
previousStep = step
133205
// Rerere resolved all conflicts — auto-continue.
134-
if rebaseContinueOnce(opts) == nil {
206+
if err := d.rebaseContinueOnce(opts); err == nil {
135207
return nil
208+
} else {
209+
originalErr = err
136210
}
137211
// Continue hit another conflicting commit; loop to check
138212
// if rerere resolved that one too.
139213
}
140214
return originalErr
141215
}
142216

217+
func (d *defaultOps) rebaseStep() (string, error) {
218+
gitDir, err := d.GitDir()
219+
if err != nil {
220+
return "", err
221+
}
222+
for _, file := range []string{"rebase-merge/msgnum", "rebase-apply/next"} {
223+
data, err := os.ReadFile(filepath.Join(gitDir, filepath.FromSlash(file)))
224+
if errors.Is(err, os.ErrNotExist) {
225+
continue
226+
}
227+
if err != nil {
228+
return "", err
229+
}
230+
return file + ":" + string(data), nil
231+
}
232+
return "", fmt.Errorf("cannot find rebase progress in %q", gitDir)
233+
}
234+
143235
// --- Public functions delegate through the ops interface ---
144236

145-
// GitDir returns the path to the .git directory.
237+
// GitDir returns the absolute path to the current worktree's Git directory.
146238
func GitDir() (string, error) {
147239
return ops.GitDir()
148240
}
149241

242+
// CommonDir returns the absolute Git directory shared by all linked worktrees.
243+
func CommonDir() (string, error) {
244+
return ops.CommonDir()
245+
}
246+
247+
// Worktrees lists all registered worktrees, including the main worktree.
248+
func Worktrees() ([]Worktree, error) {
249+
return ops.Worktrees()
250+
}
251+
252+
// ForWorktree returns operations scoped to a worktree in the same repository.
253+
// Invalid contexts return errors from the resulting operations.
254+
func ForWorktree(path string) Ops {
255+
return ops.ForWorktree(path)
256+
}
257+
258+
// CheckVersion requires Git 2.36 or newer.
259+
func CheckVersion() error {
260+
return ops.CheckVersion()
261+
}
262+
150263
// RootDir returns the repository's root directory.
151264
func RootDir() (string, error) {
152265
return ops.RootDir()

0 commit comments

Comments
 (0)