detached 6f3e28f

fix(tools): make worktree bootstrap retries idempotent

commit
6f3e28f83c725d9139ce8f28a1f93a291c2e6c73
author
brice · · 2026-08-07 08:42 UTC
committer
brice · · 2026-08-07 08:44 UTC
parent
84a0b9d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +96 -6
M packages/agent/tools/create_worktree.go +18 -4
@@ -255,9 +255,6 @@ func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (cre
255255 return createWorktreePlan{}, fmt.Errorf("create_worktree: read local worktree configuration: %w", err)
256256 }
257257 bootstrapRoot := strings.TrimSpace(args.BootstrapRoot)
258- if configured && bootstrapRoot != "" {
259- return createWorktreePlan{}, errors.New("create_worktree: worktree root is already configured; change the local Git configuration explicitly before bootstrapping again")
260- }
261258
262259 rootSource := "existing .worktrees directory"
263260 configValue := ""
@@ -269,6 +266,23 @@ func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (cre
269266 if err != nil {
270267 return createWorktreePlan{}, err
271268 }
269+ if bootstrapRoot != "" {
270+ requestedRoot, requestedDefault, _, err := resolveBootstrapWorktreeRoot(repoRoot, bootstrapRoot)
271+ if err != nil {
272+ return createWorktreePlan{}, err
273+ }
274+ configuredCanonical, err := canonicalOrParent(worktreeRoot)
275+ if err != nil {
276+ return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve configured worktree root: %w", err)
277+ }
278+ requestedCanonical, err := canonicalOrParent(requestedRoot)
279+ if err != nil {
280+ return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve bootstrap_root: %w", err)
281+ }
282+ if defaultRoot != requestedDefault || configuredCanonical != requestedCanonical {
283+ return createWorktreePlan{}, errors.New("create_worktree: worktree root is already configured; change the local Git configuration explicitly before bootstrapping again")
284+ }
285+ }
272286 rootSource = "local Git config"
273287 } else {
274288 defaultPath := filepath.Join(repoRoot, defaultWorktreeRoot)
@@ -280,7 +294,7 @@ func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (cre
280294 return bootstrapRequiredPlan(branch, repoRoot), nil
281295 }
282296 if exists {
283- if bootstrapRoot != "" {
297+ if bootstrapRoot != "" && bootstrapRoot != defaultWorktreeRoot {
284298 return createWorktreePlan{}, errors.New("create_worktree: worktree root is already established; omit bootstrap_root")
285299 }
286300 worktreeRoot = defaultPath
@@ -255,9 +255,6 @@ func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (cre
255 return createWorktreePlan{}, fmt.Errorf("create_worktree: read local worktree configuration: %w", err)255 return createWorktreePlan{}, fmt.Errorf("create_worktree: read local worktree configuration: %w", err)
256 }256 }
257 bootstrapRoot := strings.TrimSpace(args.BootstrapRoot)257 bootstrapRoot := strings.TrimSpace(args.BootstrapRoot)
258- if configured && bootstrapRoot != "" {
259- return createWorktreePlan{}, errors.New("create_worktree: worktree root is already configured; change the local Git configuration explicitly before bootstrapping again")
260- }
261 258
262 rootSource := "existing .worktrees directory"259 rootSource := "existing .worktrees directory"
263 configValue := ""260 configValue := ""
@@ -269,6 +266,23 @@ func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (cre
269 if err != nil {266 if err != nil {
270 return createWorktreePlan{}, err267 return createWorktreePlan{}, err
271 }268 }
269+ if bootstrapRoot != "" {
270+ requestedRoot, requestedDefault, _, err := resolveBootstrapWorktreeRoot(repoRoot, bootstrapRoot)
271+ if err != nil {
272+ return createWorktreePlan{}, err
273+ }
274+ configuredCanonical, err := canonicalOrParent(worktreeRoot)
275+ if err != nil {
276+ return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve configured worktree root: %w", err)
277+ }
278+ requestedCanonical, err := canonicalOrParent(requestedRoot)
279+ if err != nil {
280+ return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve bootstrap_root: %w", err)
281+ }
282+ if defaultRoot != requestedDefault || configuredCanonical != requestedCanonical {
283+ return createWorktreePlan{}, errors.New("create_worktree: worktree root is already configured; change the local Git configuration explicitly before bootstrapping again")
284+ }
285+ }
272 rootSource = "local Git config"286 rootSource = "local Git config"
273 } else {287 } else {
274 defaultPath := filepath.Join(repoRoot, defaultWorktreeRoot)288 defaultPath := filepath.Join(repoRoot, defaultWorktreeRoot)
@@ -280,7 +294,7 @@ func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (cre
280 return bootstrapRequiredPlan(branch, repoRoot), nil294 return bootstrapRequiredPlan(branch, repoRoot), nil
281 }295 }
282 if exists {296 if exists {
283- if bootstrapRoot != "" {297+ if bootstrapRoot != "" && bootstrapRoot != defaultWorktreeRoot {
284 return createWorktreePlan{}, errors.New("create_worktree: worktree root is already established; omit bootstrap_root")298 return createWorktreePlan{}, errors.New("create_worktree: worktree root is already established; omit bootstrap_root")
285 }299 }
286 worktreeRoot = defaultPath300 worktreeRoot = defaultPath
M packages/agent/tools/create_worktree_test.go +78 -2
@@ -60,6 +60,19 @@ func TestCreateWorktreeBootstrapsDefaultAtRepositoryRoot(t *testing.T) {
6060 if got := gitTestOutput(t, repo, "config", "--local", "--get", worktreeConfigKey); got != defaultWorktreeRoot {
6161 t.Fatalf("saved worktree root = %q, want %q", got, defaultWorktreeRoot)
6262 }
63+ followupArgs := mustJSON(t, map[string]string{
64+ "branch": "feature/default-followup",
65+ "bootstrap_root": defaultWorktreeRoot,
66+ })
67+ if _, err := tool.Preview(context.Background(), followupArgs); err != nil {
68+ t.Fatal(err)
69+ }
70+ if _, err := tool.Execute(context.Background(), followupArgs, nil); err != nil {
71+ t.Fatal(err)
72+ }
73+ if _, err := os.Stat(filepath.Join(repo, defaultWorktreeRoot, "feature", "default-followup")); err != nil {
74+ t.Fatalf("configured default root was not reused: %v", err)
75+ }
6376 }
6477
6578 func TestCreateWorktreeRequiresBootstrapForUnconfiguredRepository(t *testing.T) {
@@ -93,7 +106,39 @@ func TestCreateWorktreeRequiresBootstrapForUnconfiguredRepository(t *testing.T)
93106 }
94107 }
95108
96-func TestCreateWorktreeRejectsBootstrapRootWhenDefaultRootExists(t *testing.T) {
109+func TestCreateWorktreeAcceptsDefaultBootstrapRootWhenDefaultRootExists(t *testing.T) {
110+ if _, err := exec.LookPath("git"); err != nil {
111+ t.Skip("git is required")
112+ }
113+ repo := initWorktreeTestRepo(t)
114+ if err := os.Mkdir(filepath.Join(repo, defaultWorktreeRoot), 0o755); err != nil {
115+ t.Fatal(err)
116+ }
117+ tool := newCreateWorktreeTestTool(repo)
118+ args := mustJSON(t, map[string]string{
119+ "branch": "feature/existing-default-root",
120+ "bootstrap_root": defaultWorktreeRoot,
121+ })
122+
123+ if _, err := tool.Preview(context.Background(), args); err != nil {
124+ t.Fatal(err)
125+ }
126+ result, err := tool.Execute(context.Background(), args, nil)
127+ if err != nil {
128+ t.Fatal(err)
129+ }
130+ if _, err := os.Stat(filepath.Join(repo, defaultWorktreeRoot, "feature", "existing-default-root")); err != nil {
131+ t.Fatalf("worktree was not created: %v", err)
132+ }
133+ if source, _ := result.Details.(map[string]any)["root_source"].(string); source != "existing .worktrees directory" {
134+ t.Fatalf("root source = %q", source)
135+ }
136+ if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
137+ t.Fatalf("existing default root saved config: %#v", got)
138+ }
139+}
140+
141+func TestCreateWorktreeRejectsConflictingBootstrapRootWhenDefaultRootExists(t *testing.T) {
97142 if _, err := exec.LookPath("git"); err != nil {
98143 t.Skip("git is required")
99144 }
@@ -118,6 +163,30 @@ func TestCreateWorktreeRejectsBootstrapRootWhenDefaultRootExists(t *testing.T) {
118163 }
119164 }
120165
166+func TestCreateWorktreeRejectsConflictingBootstrapRootWhenRootIsConfigured(t *testing.T) {
167+ if _, err := exec.LookPath("git"); err != nil {
168+ t.Skip("git is required")
169+ }
170+ repo := initWorktreeTestRepo(t)
171+ gitTestOutput(t, repo, "config", "--local", worktreeConfigKey, defaultWorktreeRoot)
172+ tool := newCreateWorktreeTestTool(repo)
173+ args := mustJSON(t, map[string]string{
174+ "branch": "feature/conflicting-configured-bootstrap",
175+ "bootstrap_root": t.TempDir(),
176+ })
177+
178+ if _, err := tool.Preview(context.Background(), args); err == nil || !strings.Contains(err.Error(), "already configured") {
179+ t.Fatalf("preview error = %v, want configured-root error", err)
180+ }
181+ _, err := tool.Execute(context.Background(), args, nil)
182+ if err == nil || !strings.Contains(err.Error(), "already configured") {
183+ t.Fatalf("error = %v, want configured-root error", err)
184+ }
185+ if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/conflicting-configured-bootstrap"); got.exitCode != 1 {
186+ t.Fatalf("conflicting bootstrap created branch: %#v", got)
187+ }
188+}
189+
121190 func TestCreateWorktreeBootstrapsExternalRootWithoutChangingGitignore(t *testing.T) {
122191 if _, err := exec.LookPath("git"); err != nil {
123192 t.Skip("git is required")
@@ -153,7 +222,14 @@ func TestCreateWorktreeBootstrapsExternalRootWithoutChangingGitignore(t *testing
153222 if state, _ := result.Details.(map[string]any)["state"].(string); state != "created" {
154223 t.Fatalf("state = %q, want created", state)
155224 }
156- followup, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/external-followup"}`), nil)
225+ followupArgs := mustJSON(t, map[string]string{
226+ "branch": "feature/external-followup",
227+ "bootstrap_root": externalRoot,
228+ })
229+ if _, err := tool.Preview(context.Background(), followupArgs); err != nil {
230+ t.Fatal(err)
231+ }
232+ followup, err := tool.Execute(context.Background(), followupArgs, nil)
157233 if err != nil {
158234 t.Fatal(err)
159235 }
@@ -60,6 +60,19 @@ func TestCreateWorktreeBootstrapsDefaultAtRepositoryRoot(t *testing.T) {
60 if got := gitTestOutput(t, repo, "config", "--local", "--get", worktreeConfigKey); got != defaultWorktreeRoot {60 if got := gitTestOutput(t, repo, "config", "--local", "--get", worktreeConfigKey); got != defaultWorktreeRoot {
61 t.Fatalf("saved worktree root = %q, want %q", got, defaultWorktreeRoot)61 t.Fatalf("saved worktree root = %q, want %q", got, defaultWorktreeRoot)
62 }62 }
63+ followupArgs := mustJSON(t, map[string]string{
64+ "branch": "feature/default-followup",
65+ "bootstrap_root": defaultWorktreeRoot,
66+ })
67+ if _, err := tool.Preview(context.Background(), followupArgs); err != nil {
68+ t.Fatal(err)
69+ }
70+ if _, err := tool.Execute(context.Background(), followupArgs, nil); err != nil {
71+ t.Fatal(err)
72+ }
73+ if _, err := os.Stat(filepath.Join(repo, defaultWorktreeRoot, "feature", "default-followup")); err != nil {
74+ t.Fatalf("configured default root was not reused: %v", err)
75+ }
63 }76 }
64 77
65 func TestCreateWorktreeRequiresBootstrapForUnconfiguredRepository(t *testing.T) {78 func TestCreateWorktreeRequiresBootstrapForUnconfiguredRepository(t *testing.T) {
@@ -93,7 +106,39 @@ func TestCreateWorktreeRequiresBootstrapForUnconfiguredRepository(t *testing.T)
93 }106 }
94 }107 }
95 108
96-func TestCreateWorktreeRejectsBootstrapRootWhenDefaultRootExists(t *testing.T) {109+func TestCreateWorktreeAcceptsDefaultBootstrapRootWhenDefaultRootExists(t *testing.T) {
110+ if _, err := exec.LookPath("git"); err != nil {
111+ t.Skip("git is required")
112+ }
113+ repo := initWorktreeTestRepo(t)
114+ if err := os.Mkdir(filepath.Join(repo, defaultWorktreeRoot), 0o755); err != nil {
115+ t.Fatal(err)
116+ }
117+ tool := newCreateWorktreeTestTool(repo)
118+ args := mustJSON(t, map[string]string{
119+ "branch": "feature/existing-default-root",
120+ "bootstrap_root": defaultWorktreeRoot,
121+ })
122+
123+ if _, err := tool.Preview(context.Background(), args); err != nil {
124+ t.Fatal(err)
125+ }
126+ result, err := tool.Execute(context.Background(), args, nil)
127+ if err != nil {
128+ t.Fatal(err)
129+ }
130+ if _, err := os.Stat(filepath.Join(repo, defaultWorktreeRoot, "feature", "existing-default-root")); err != nil {
131+ t.Fatalf("worktree was not created: %v", err)
132+ }
133+ if source, _ := result.Details.(map[string]any)["root_source"].(string); source != "existing .worktrees directory" {
134+ t.Fatalf("root source = %q", source)
135+ }
136+ if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
137+ t.Fatalf("existing default root saved config: %#v", got)
138+ }
139+}
140+
141+func TestCreateWorktreeRejectsConflictingBootstrapRootWhenDefaultRootExists(t *testing.T) {
97 if _, err := exec.LookPath("git"); err != nil {142 if _, err := exec.LookPath("git"); err != nil {
98 t.Skip("git is required")143 t.Skip("git is required")
99 }144 }
@@ -118,6 +163,30 @@ func TestCreateWorktreeRejectsBootstrapRootWhenDefaultRootExists(t *testing.T) {
118 }163 }
119 }164 }
120 165
166+func TestCreateWorktreeRejectsConflictingBootstrapRootWhenRootIsConfigured(t *testing.T) {
167+ if _, err := exec.LookPath("git"); err != nil {
168+ t.Skip("git is required")
169+ }
170+ repo := initWorktreeTestRepo(t)
171+ gitTestOutput(t, repo, "config", "--local", worktreeConfigKey, defaultWorktreeRoot)
172+ tool := newCreateWorktreeTestTool(repo)
173+ args := mustJSON(t, map[string]string{
174+ "branch": "feature/conflicting-configured-bootstrap",
175+ "bootstrap_root": t.TempDir(),
176+ })
177+
178+ if _, err := tool.Preview(context.Background(), args); err == nil || !strings.Contains(err.Error(), "already configured") {
179+ t.Fatalf("preview error = %v, want configured-root error", err)
180+ }
181+ _, err := tool.Execute(context.Background(), args, nil)
182+ if err == nil || !strings.Contains(err.Error(), "already configured") {
183+ t.Fatalf("error = %v, want configured-root error", err)
184+ }
185+ if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/conflicting-configured-bootstrap"); got.exitCode != 1 {
186+ t.Fatalf("conflicting bootstrap created branch: %#v", got)
187+ }
188+}
189+
121 func TestCreateWorktreeBootstrapsExternalRootWithoutChangingGitignore(t *testing.T) {190 func TestCreateWorktreeBootstrapsExternalRootWithoutChangingGitignore(t *testing.T) {
122 if _, err := exec.LookPath("git"); err != nil {191 if _, err := exec.LookPath("git"); err != nil {
123 t.Skip("git is required")192 t.Skip("git is required")
@@ -153,7 +222,14 @@ func TestCreateWorktreeBootstrapsExternalRootWithoutChangingGitignore(t *testing
153 if state, _ := result.Details.(map[string]any)["state"].(string); state != "created" {222 if state, _ := result.Details.(map[string]any)["state"].(string); state != "created" {
154 t.Fatalf("state = %q, want created", state)223 t.Fatalf("state = %q, want created", state)
155 }224 }
156- followup, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/external-followup"}`), nil)225+ followupArgs := mustJSON(t, map[string]string{
226+ "branch": "feature/external-followup",
227+ "bootstrap_root": externalRoot,
228+ })
229+ if _, err := tool.Preview(context.Background(), followupArgs); err != nil {
230+ t.Fatal(err)
231+ }
232+ followup, err := tool.Execute(context.Background(), followupArgs, nil)
157 if err != nil {233 if err != nil {
158 t.Fatal(err)234 t.Fatal(err)
159 }235 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close