Skip to content

fix: recreate a prunable worktree instead of no-op'ing on create#107

Merged
ikeikeikeike merged 2 commits into
mainfrom
fix/worktree-add-recovers-prunable
Jul 15, 2026
Merged

fix: recreate a prunable worktree instead of no-op'ing on create#107
ikeikeikeike merged 2 commits into
mainfrom
fix/worktree-add-recovers-prunable

Conversation

@ikeikeikeike

Copy link
Copy Markdown
Member

When a worktree dir is deleted out-of-band (a partial teardown, a manual rm -rf, an interrupted run) while its git admin record survives, git worktree list still reports the path — as "prunable". AddOrAttach's idempotency check matched that path and returned (false, base, nil), so bough create reported the repo "already registered" and never recreated its dir. A claude --worktree <name> over such a worktree came up half-materialised — only the sub-repos whose dirs happened to survive were present.

Confirm the dir exists on disk (os.Stat) before treating a path match as a no-op. A registered-but-missing entry now falls through to the existing prune + add recovery and is re-materialised on its existing branch (attach path).

verify result
go build / golangci-lint ./internal/gitwt/ OK / 0 issues
unit suite (internal/ cmd/ pkg/) 21 ok / 0 FAIL
real bough create before/after shipped binary leaves the deleted repo missing; fixed binary re-attaches it

New test: TestRunner_AddOrAttach_RecreatesPrunableWorktree (real git: create → rm -rf the dir → re-add must recreate on the same branch).

When a worktree dir was deleted out-of-band (a partial teardown, a manual
`rm -rf`, an interrupted run) while its git admin record survived,
`git worktree list` still reports the path — as "prunable". AddOrAttach's
idempotency check matched that path and returned `(false, base, nil)`, so
`bough create` reported the repo "already registered" and never recreated its
dir. A `claude --worktree <name>` over such a worktree came up
half-materialised: only the sub-repos whose dirs happened to survive were
present, the rest silently missing.

Confirm the dir exists on disk (os.Stat) before treating a path match as a
materialised no-op. A registered-but-missing entry now falls through to the
existing prune + add recovery and is re-materialised on its existing branch
(attach path).

Adds TestRunner_AddOrAttach_RecreatesPrunableWorktree (real git: create →
rm -rf the dir → re-add must recreate on the same branch). Verified end-to-end
against a real `bough create`: the shipped v0.13.0 binary leaves the deleted
repo missing; the fixed binary re-attaches it.
…linked temp

/review findings on the prunable-worktree fix:
- gitwt.go: os.Stat gated recovery on statErr==nil, so a non-ENOENT stat
  failure (EACCES on a parent, a stale NFS mount) on a *present* worktree
  misclassified it as prunable and turned a should-be resume no-op into a
  hard error. Gate recovery on errors.Is(statErr, os.ErrNotExist) only.
- gitwt_test.go: TestRunner_AddOrAttach_RecreatesPrunableWorktree did not
  guard the regression on macOS: t.TempDir() under /var -> /private/var meant
  the post-rm literal dst never matched git's resolved worktree-list path, so
  the loop took the no-match fall-through and never exercised the os.Stat
  guard (the guard could be reverted and the test still passed). Resolve the
  temp root with EvalSymlinks so the match -> stat-fails -> break path runs on
  every OS. Proven: reverting the guard now fails the test.
@ikeikeikeike
ikeikeikeike merged commit 81085c5 into main Jul 15, 2026
11 checks passed
Comment thread internal/gitwt/gitwt.go
// momentarily-stale NFS mount) must keep the old no-op rather
// than tear down a worktree that is actually present — the
// intent is "the dir was deleted", not "the dir is unreadable".
if _, statErr := os.Stat(dst); !errors.Is(statErr, os.ErrNotExist) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Retrospective review: this ENOENT-only guard only catches the "whole directory deleted" shape of prunability. git worktree list --porcelain also flags an entry prunable when the dir survives on disk but its own .git file (or git's admin-side gitdir record) was removed independently — verified against real git 2.53.0, which emits prunable gitdir file points to non-existent location for exactly that shape while os.Stat(dst) reports no error at all. So this guard silently no-ops on that case, reintroducing a variant of the half-materialised-worktree bug this PR fixed.

Filed as PR #119: adds a Prunable field to Worktree (parsed from the porcelain prunable line, which List() was discarding), and gates recovery on wt.Prunable || ENOENT instead of ENOENT alone — trusting git's own verdict while keeping the EACCES/NFS protection this commit added for the non-prunable case.

ikeikeikeike added a commit that referenced this pull request Jul 16, 2026
fix: retrospective review fixes for merged PR #107
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant