Skip to content

Commit 2347413

Browse files
committed
Prevent automatic maintenance from racing rebases
1 parent f7c06fc commit 2347413

4 files changed

Lines changed: 34 additions & 2 deletions

File tree

‎AGENTS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,5 +144,6 @@ if errors.As(err, &exitErr) { ... }
144144
- `git.SetOps()` replaces the **package-level** ops variable. Forgetting `defer restore()` in a test will break every subsequent test in the package.
145145
- Interrupt detection: Ctrl+C is caught as `terminal.InterruptErr`, wrapped into an `errInterrupt` sentinel, and printed with a friendly message before a silent exit.
146146
- Rerere: on first rebase conflict, the user is prompted to enable `git rerere`. If declined, a flag file prevents future prompts. `tryAutoResolveRebase()` loops up to 1000 times auto-continuing when rerere resolves conflicts.
147+
- Rebase commands disable automatic maintenance through command-local configuration so detached `rerere gc` cannot race conflict handling. Repository settings and unrelated Git commands are unchanged.
147148
- Date-preserving rebase starts use the merge backend so Git persists the date setting across conflicts. Continuations use native saved settings, not start-only date flags.
148149
- The `.gitignore` ignores `/gh-stack` and `/gh-stack.exe` (the built binary).

‎internal/git/git.go‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,8 +151,9 @@ func (d *defaultOps) runRebaseCommand(args []string, opts RebaseOpts) error {
151151

152152
func rebaseArgs(opts RebaseOpts) []string {
153153
// The cascade owns its ref range and must never stash another worktree.
154+
// Detached maintenance can race the next commit's rerere lock.
154155
// Use configuration overrides rather than flags unavailable in Git 2.36.
155-
args := []string{"-c", "rebase.updateRefs=false", "-c", "rebase.autoStash=false", "rebase"}
156+
args := []string{"-c", "rebase.updateRefs=false", "-c", "rebase.autoStash=false", "-c", "maintenance.auto=false", "rebase"}
156157
if opts.CommitterDateIsAuthorDate {
157158
// The apply backend loses this option after a conflict. The merge
158159
// backend persists it for continuation and rerere auto-continuation.

‎internal/git/gitops_test.go‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package git
22

33
import (
4+
"encoding/json"
45
"fmt"
56
"os"
67
"os/exec"
78
"path/filepath"
89
"runtime"
10+
"slices"
911
"strings"
1012
"testing"
1113

@@ -1429,6 +1431,9 @@ func TestIntegration_WorktreeRerereAutoContinuesMultipleCommits(t *testing.T) {
14291431
original := gitExec(t, path, "rev-parse", "HEAD")
14301432
mainHead := gitExec(t, dir, "rev-parse", "HEAD")
14311433
require.NoError(t, linked.EnableRerere())
1434+
gitExec(t, dir, "config", "maintenance.auto", "true")
1435+
tracePath := filepath.Join(t.TempDir(), "rebase-trace")
1436+
t.Setenv("GIT_TRACE2_EVENT", tracePath)
14321437
t.Setenv("GIT_EDITOR", "false")
14331438
restore := forbidGlobalWorktreeQueries(t)
14341439
defer restore()
@@ -1465,6 +1470,31 @@ func TestIntegration_WorktreeRerereAutoContinuesMultipleCommits(t *testing.T) {
14651470
require.NoError(t, err)
14661471
assert.Contains(t, string(data), "resolved")
14671472
}
1473+
trace, err := os.ReadFile(tracePath)
1474+
require.NoError(t, err)
1475+
var rebaseSessions []string
1476+
for _, line := range strings.Split(strings.TrimSpace(string(trace)), "\n") {
1477+
var event struct {
1478+
Event string `json:"event"`
1479+
SID string `json:"sid"`
1480+
Argv []string `json:"argv"`
1481+
}
1482+
require.NoError(t, json.Unmarshal([]byte(line), &event))
1483+
if event.Event == "start" && slices.Contains(event.Argv, "rebase") {
1484+
rebaseSessions = append(rebaseSessions, event.SID)
1485+
}
1486+
if event.Event != "child_start" || len(event.Argv) < 3 ||
1487+
event.Argv[1] != "maintenance" || event.Argv[2] != "run" || !slices.Contains(event.Argv, "--auto") {
1488+
continue
1489+
}
1490+
for _, sid := range rebaseSessions {
1491+
assert.False(t, event.SID == sid || strings.HasPrefix(event.SID, sid+"/"),
1492+
"automatic maintenance can race the next commit's rerere lock: %v", event.Argv)
1493+
}
1494+
}
1495+
assert.NotEmpty(t, rebaseSessions)
1496+
assert.Equal(t, "true", gitExec(t, dir, "config", "--local", "--get", "maintenance.auto"),
1497+
"the rebase must not change the repository's maintenance configuration")
14681498
}
14691499

14701500
func TestIntegration_WorktreeRebaseContinueStopsWhenNoProgress(t *testing.T) {

‎internal/git/worktree_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ func TestStateQueryWrappersDelegateErrors(t *testing.T) {
209209
func TestRebaseArgs(t *testing.T) {
210210
for _, date := range []bool{false, true} {
211211
args := rebaseArgs(RebaseOpts{CommitterDateIsAuthorDate: date})
212-
want := []string{"-c", "rebase.updateRefs=false", "-c", "rebase.autoStash=false", "rebase"}
212+
want := []string{"-c", "rebase.updateRefs=false", "-c", "rebase.autoStash=false", "-c", "maintenance.auto=false", "rebase"}
213213
if date {
214214
want = append(want, "--merge", "--committer-date-is-author-date")
215215
}

0 commit comments

Comments
 (0)