detached 020512d

Merge pull request #75 from bnema/fix/create-worktree-gitignore

fix(tools): recognize unanchored worktree ignore
commit
020512dd55d7bc6ce18cc97ed7312e7c01480b28
author
bnema · · 2026-08-08 05:25 UTC
committer
GitHub · · 2026-08-08 05:25 UTC
merge of
6c26357 + a9bb256 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.11.4
2 files · +79 -7
M packages/agent/tools/create_worktree.go +9 -7
@@ -19,11 +19,12 @@ import (
1919 )
2020
2121 const (
22- worktreesIgnoreEntry = "/.worktrees/"
23- defaultWorktreeRoot = ".worktrees"
24- worktreeConfigKey = "zut.worktrees.path"
25- maxCreateWorktreeGitOutput = 64 * 1024
26- maxCreateWorktreeIgnoreBytes = 1 << 20
22+ worktreesIgnoreEntry = "/.worktrees/"
23+ worktreesUnanchoredIgnoreEntry = ".worktrees/"
24+ defaultWorktreeRoot = ".worktrees"
25+ worktreeConfigKey = "zut.worktrees.path"
26+ maxCreateWorktreeGitOutput = 64 * 1024
27+ maxCreateWorktreeIgnoreBytes = 1 << 20
2728 )
2829
2930 var createWorktreeOperationMu sync.Mutex
@@ -358,7 +359,7 @@ func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (cre
358359 if err != nil {
359360 return createWorktreePlan{}, fmt.Errorf("create_worktree: read .gitignore: %w", err)
360361 }
361- ignoreAction = "already contains " + worktreesIgnoreEntry
362+ ignoreAction = "already ignores " + defaultWorktreeRoot + "/"
362363 if ignore.changed {
363364 ignoreAction = "add " + worktreesIgnoreEntry
364365 }
@@ -818,7 +819,8 @@ func newWorktreesIgnoreUpdate(path string) (worktreesIgnoreUpdate, error) {
818819 return worktreesIgnoreUpdate{}, err
819820 }
820821 for _, line := range strings.Split(string(contents), "\n") {
821- if strings.TrimSuffix(line, "\r") == worktreesIgnoreEntry {
822+ switch strings.TrimSuffix(line, "\r") {
823+ case worktreesIgnoreEntry, worktreesUnanchoredIgnoreEntry:
822824 return worktreesIgnoreUpdate{path: path, before: contents, existed: existed, mode: mode}, nil
823825 }
824826 }
@@ -19,11 +19,12 @@ import (
19 )19 )
20 20
21 const (21 const (
22- worktreesIgnoreEntry = "/.worktrees/"22+ worktreesIgnoreEntry = "/.worktrees/"
23- defaultWorktreeRoot = ".worktrees"23+ worktreesUnanchoredIgnoreEntry = ".worktrees/"
24- worktreeConfigKey = "zut.worktrees.path"24+ defaultWorktreeRoot = ".worktrees"
25- maxCreateWorktreeGitOutput = 64 * 102425+ worktreeConfigKey = "zut.worktrees.path"
26- maxCreateWorktreeIgnoreBytes = 1 << 2026+ maxCreateWorktreeGitOutput = 64 * 1024
27+ maxCreateWorktreeIgnoreBytes = 1 << 20
27 )28 )
28 29
29 var createWorktreeOperationMu sync.Mutex30 var createWorktreeOperationMu sync.Mutex
@@ -358,7 +359,7 @@ func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (cre
358 if err != nil {359 if err != nil {
359 return createWorktreePlan{}, fmt.Errorf("create_worktree: read .gitignore: %w", err)360 return createWorktreePlan{}, fmt.Errorf("create_worktree: read .gitignore: %w", err)
360 }361 }
361- ignoreAction = "already contains " + worktreesIgnoreEntry362+ ignoreAction = "already ignores " + defaultWorktreeRoot + "/"
362 if ignore.changed {363 if ignore.changed {
363 ignoreAction = "add " + worktreesIgnoreEntry364 ignoreAction = "add " + worktreesIgnoreEntry
364 }365 }
@@ -818,7 +819,8 @@ func newWorktreesIgnoreUpdate(path string) (worktreesIgnoreUpdate, error) {
818 return worktreesIgnoreUpdate{}, err819 return worktreesIgnoreUpdate{}, err
819 }820 }
820 for _, line := range strings.Split(string(contents), "\n") {821 for _, line := range strings.Split(string(contents), "\n") {
821- if strings.TrimSuffix(line, "\r") == worktreesIgnoreEntry {822+ switch strings.TrimSuffix(line, "\r") {
823+ case worktreesIgnoreEntry, worktreesUnanchoredIgnoreEntry:
822 return worktreesIgnoreUpdate{path: path, before: contents, existed: existed, mode: mode}, nil824 return worktreesIgnoreUpdate{path: path, before: contents, existed: existed, mode: mode}, nil
823 }825 }
824 }826 }
M packages/agent/tools/create_worktree_test.go +70 -0
@@ -431,6 +431,76 @@ func TestExecuteCreateWorktreePlanRollsBackBootstrapConfigOnIgnoreFailure(t *tes
431431 }
432432 }
433433
434+func TestCreateWorktreeAcceptsViableGitignoreEntries(t *testing.T) {
435+ if _, err := exec.LookPath("git"); err != nil {
436+ t.Skip("git is required")
437+ }
438+ for _, test := range []struct {
439+ name string
440+ entry string
441+ }{
442+ {name: "root-anchored", entry: worktreesIgnoreEntry},
443+ {name: "unanchored", entry: worktreesUnanchoredIgnoreEntry},
444+ } {
445+ t.Run(test.name, func(t *testing.T) {
446+ repo := initWorktreeTestRepo(t)
447+ ignorePath := filepath.Join(repo, ".gitignore")
448+ before := []byte(test.entry + "\n")
449+ if err := os.WriteFile(ignorePath, before, 0o644); err != nil {
450+ t.Fatal(err)
451+ }
452+ tool := newCreateWorktreeTestTool(repo)
453+ args := mustJSON(t, map[string]string{
454+ "branch": "feature/" + test.name + "-ignore",
455+ "bootstrap_root": defaultWorktreeRoot,
456+ })
457+
458+ result, err := tool.Execute(context.Background(), args, nil)
459+ if err != nil {
460+ t.Fatal(err)
461+ }
462+ text := result.Content[0].(provider.TextBlock).Text
463+ if !strings.Contains(text, ".gitignore: already ignores .worktrees/") {
464+ t.Fatalf("result did not recognize existing ignore entry:\n%s", text)
465+ }
466+ ignore, err := os.ReadFile(ignorePath)
467+ if err != nil {
468+ t.Fatal(err)
469+ }
470+ if string(ignore) != string(before) {
471+ t.Fatalf(".gitignore changed from %q to %q", before, ignore)
472+ }
473+ })
474+ }
475+}
476+
477+func TestNewWorktreesIgnoreUpdateRejectsLookalikeEntries(t *testing.T) {
478+ for _, entry := range []string{
479+ "!.worktrees/\n",
480+ "nested/.worktrees/\n",
481+ "#.worktrees/\n",
482+ ".worktrees-other/\n",
483+ } {
484+ t.Run(strings.TrimSpace(entry), func(t *testing.T) {
485+ ignorePath := filepath.Join(t.TempDir(), ".gitignore")
486+ if err := os.WriteFile(ignorePath, []byte(entry), 0o644); err != nil {
487+ t.Fatal(err)
488+ }
489+
490+ update, err := newWorktreesIgnoreUpdate(ignorePath)
491+ if err != nil {
492+ t.Fatal(err)
493+ }
494+ if !update.changed {
495+ t.Fatalf("entry %q was treated as ignoring the root .worktrees directory", strings.TrimSpace(entry))
496+ }
497+ if got, want := string(update.after), entry+worktreesIgnoreEntry+"\n"; got != want {
498+ t.Fatalf("updated .gitignore = %q, want %q", got, want)
499+ }
500+ })
501+ }
502+}
503+
434504 func TestCreateWorktreePreservesGitignoreLineEndings(t *testing.T) {
435505 if _, err := exec.LookPath("git"); err != nil {
436506 t.Skip("git is required")
@@ -431,6 +431,76 @@ func TestExecuteCreateWorktreePlanRollsBackBootstrapConfigOnIgnoreFailure(t *tes
431 }431 }
432 }432 }
433 433
434+func TestCreateWorktreeAcceptsViableGitignoreEntries(t *testing.T) {
435+ if _, err := exec.LookPath("git"); err != nil {
436+ t.Skip("git is required")
437+ }
438+ for _, test := range []struct {
439+ name string
440+ entry string
441+ }{
442+ {name: "root-anchored", entry: worktreesIgnoreEntry},
443+ {name: "unanchored", entry: worktreesUnanchoredIgnoreEntry},
444+ } {
445+ t.Run(test.name, func(t *testing.T) {
446+ repo := initWorktreeTestRepo(t)
447+ ignorePath := filepath.Join(repo, ".gitignore")
448+ before := []byte(test.entry + "\n")
449+ if err := os.WriteFile(ignorePath, before, 0o644); err != nil {
450+ t.Fatal(err)
451+ }
452+ tool := newCreateWorktreeTestTool(repo)
453+ args := mustJSON(t, map[string]string{
454+ "branch": "feature/" + test.name + "-ignore",
455+ "bootstrap_root": defaultWorktreeRoot,
456+ })
457+
458+ result, err := tool.Execute(context.Background(), args, nil)
459+ if err != nil {
460+ t.Fatal(err)
461+ }
462+ text := result.Content[0].(provider.TextBlock).Text
463+ if !strings.Contains(text, ".gitignore: already ignores .worktrees/") {
464+ t.Fatalf("result did not recognize existing ignore entry:\n%s", text)
465+ }
466+ ignore, err := os.ReadFile(ignorePath)
467+ if err != nil {
468+ t.Fatal(err)
469+ }
470+ if string(ignore) != string(before) {
471+ t.Fatalf(".gitignore changed from %q to %q", before, ignore)
472+ }
473+ })
474+ }
475+}
476+
477+func TestNewWorktreesIgnoreUpdateRejectsLookalikeEntries(t *testing.T) {
478+ for _, entry := range []string{
479+ "!.worktrees/\n",
480+ "nested/.worktrees/\n",
481+ "#.worktrees/\n",
482+ ".worktrees-other/\n",
483+ } {
484+ t.Run(strings.TrimSpace(entry), func(t *testing.T) {
485+ ignorePath := filepath.Join(t.TempDir(), ".gitignore")
486+ if err := os.WriteFile(ignorePath, []byte(entry), 0o644); err != nil {
487+ t.Fatal(err)
488+ }
489+
490+ update, err := newWorktreesIgnoreUpdate(ignorePath)
491+ if err != nil {
492+ t.Fatal(err)
493+ }
494+ if !update.changed {
495+ t.Fatalf("entry %q was treated as ignoring the root .worktrees directory", strings.TrimSpace(entry))
496+ }
497+ if got, want := string(update.after), entry+worktreesIgnoreEntry+"\n"; got != want {
498+ t.Fatalf("updated .gitignore = %q, want %q", got, want)
499+ }
500+ })
501+ }
502+}
503+
434 func TestCreateWorktreePreservesGitignoreLineEndings(t *testing.T) {504 func TestCreateWorktreePreservesGitignoreLineEndings(t *testing.T) {
435 if _, err := exec.LookPath("git"); err != nil {505 if _, err := exec.LookPath("git"); err != nil {
436 t.Skip("git is required")506 t.Skip("git is required")

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