Skip to content

Drop a worktree's row when its directory is deleted without git - #164

Open
DrewBradfordXYZ wants to merge 2 commits into
AgentSystemLabs:mainfrom
DrewBradfordXYZ:prunable-worktrees-v2
Open

DrewBradfordXYZ wants to merge 2 commits into
AgentSystemLabs:mainfrom
DrewBradfordXYZ:prunable-worktrees-v2

Conversation

@DrewBradfordXYZ

Copy link
Copy Markdown
Contributor

Two commits. The first is a plain bug fix. The second extends it to locked
worktrees, which is a policy choice; drop it if you'd rather follow git's
lock rule, and the first stands on its own.

The bug

Delete a linked worktree's directory directly (rm -rf, Finder, a cleanup
script) instead of with git worktree remove, and its row stays in the tree
for good, with no session in it. docs/how-it-works.md already promises that
"a worktree someone removed" disappears within a couple of seconds; for this
kind of removal it never did.

Commit 1: the fix

  1. git worktree list still lists the checkout, marked prunable, until
    someone runs git worktree prune:

    worktree /path/repo-wt/feat
    HEAD 0c44b25…
    branch refs/heads/feat
    prunable gitdir file points to non-existent location
    

    parse_worktree_list ignored that line, so the reconcile saw the checkout
    as present (and nebula add would adopt it, through the same parse). It
    now leaves prunable stanzas out. The main checkout is never prunable.

  2. The sync never ran. The 2 s loop only reconciles when
    worktree_probe_stamp changes, and deleting a checkout moves none of the
    mtimes it reads. The stamp now also carries the registry entries whose
    gitdir target no longer exists (git's own prunable test). That list is
    sorted and only changes when the filesystem does, so there's no re-sync
    every tick.

nebula still never runs git worktree prune, and a row that holds a session
or terminal is kept, as before. That row is #127's case: its session now
refuses to start and says why. This PR only covers the empty row #127 leaves
behind. One existing test,
remove_worktree_survives_manual_rm_rf, checked the prune through
list_worktrees, which now hides a prunable checkout either way; it reads
git's raw listing instead. The docs change is one clause in WORKTREE SYNC.

Commit 2: locked worktrees (your call)

Git never marks a locked worktree prunable, however gone it is; the lock
exists so a checkout on removable media keeps its registration. But Claude
Code locks the worktrees it creates and a killed session never unlocks (the
comment in remove_worktree notes this), so an agent's rm -rf'd worktree,
probably the most common case, keeps its row after commit 1.

Commit 2 makes list_worktrees also leave out a linked checkout whose .git
file is missing. Git's registry is untouched: nebula never unlocks or prunes.
The trade-off: an empty row for a checkout on an unmounted volume is now
dropped, and comes back as a new row (new id, default sort order) when the
volume returns. Before, it stayed.

How it was tested

  • New tests, all passing; cargo test -p nebula-daemon is 335 passed on
    main (a00b760), 338 after commit 1 and 339 after commit 2.

    • Commit 1: parse_worktree_list_leaves_out_checkouts_deleted_without_git
      and reconcile_drops_a_row_whose_checkout_was_deleted_without_git fail
      before this PR. probe_changes_when_a_checkout_is_deleted_without_git reads
      the new stamp field, so it doesn't compile without it. (The fail-before runs were made against
      v0.42.0; the rebase onto main applied without conflicts.)
    • Commit 2: list_worktrees_leaves_out_a_locked_checkout_that_was_deleted,
      and the reconcile test also runs with the worktree locked. Both fail with
      commit 1 alone.
    • The reconcile test needs git 2.31+, when prunable appeared in porcelain
      output.
  • Live, with cargo build -p nebula binaries (not an installed nebula), each
    run as an isolated daemon (NEBULA_RUNTIME_DIR / NEBULA_DATA_DIR /
    NEBULA_CONFIG_FILE), reading the worktrees table: add a project with one
    linked worktree, rm -rf it, wait 6 s, then touch .git/HEAD and wait 4 s.

    before rm 6 s after rm after a git touch
    v0.42.0 (before) feat, main feat, main feat, main
    both commits feat, main main main
    both commits, worktree locked feat, main main main

    These live runs were made on the branch's v0.42.0 base; on main the same
    two commits apply unchanged.

  • cargo fmt --check and cargo clippy --all-targets -- -D warnings are
    clean.

🤖 Generated with Claude Code

DrewBradfordXYZ and others added 2 commits October 9, 2026 23:24
Deleting a linked checkout's directory directly (`rm -rf`, Finder, a disk
sweep) instead of with `git worktree remove` left its row in the tree for
good. Two things kept it:

- `git worktree list` keeps listing such a checkout, marked `prunable`,
  until someone runs `git worktree prune`. The parser ignored that line,
  so the reconcile saw the checkout as present and kept the row (adding a
  project adopted it too, through the same parse). `parse_worktree_list`
  now leaves prunable stanzas out. The main checkout is never prunable.
- The sync loop only runs the reconcile when its probe stamp changes, and
  deleting a checkout moves none of the mtimes it read. The stamp now also
  carries the registry entries whose `gitdir` target is gone (git's own
  prunable test), so the deletion is noticed on the next tick.

nebula still never prunes in the user's repo, and a row with a session or
terminal in it is still kept.

`remove_worktree_survives_manual_rm_rf` asserted the prune through
`list_worktrees`, which now leaves a prunable checkout out either way; it
reads git's raw listing instead.

Docs: how-it-works's WORKTREE SYNC names the deleted-checkout case.

Tests: parse_worktree_list_leaves_out_checkouts_deleted_without_git,
reconcile_drops_a_row_whose_checkout_was_deleted_without_git,
probe_changes_when_a_checkout_is_deleted_without_git.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Git never marks a locked worktree `prunable`, however gone it is: the lock
is how a checkout on removable media keeps its registration while the media
is away. Claude Code locks the worktrees it creates and a killed session
never unlocks (as `remove_worktree` already notes), so an agent's checkout
deleted with `rm -rf` still kept its row after the previous commit.

`list_worktrees` now also leaves out a linked checkout whose `.git` file is
missing. Git's registry is left exactly as it was: nebula never unlocks or
prunes, and the checkout is listed again the moment its directory returns.

This is a policy choice rather than a pure fix. The same rule means an empty
row for a checkout on a volume that is not mounted is dropped, and comes
back as a new row (new id, default sort order) when the volume does; before,
it stayed. A row with a session or terminal in it is still kept.

Tests: list_worktrees_leaves_out_a_locked_checkout_that_was_deleted,
reconcile_drops_a_row_whose_checkout_was_deleted_without_git now runs
locked as well as plain.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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