Skip to content

A worktree's band says whether deleting it loses anything, and its delete can take the branch too - #218

Open
DrewBradfordXYZ wants to merge 6 commits into
AgentSystemLabs:mainfrom
DrewBradfordXYZ:worktree-safe-to-delete
Open

DrewBradfordXYZ wants to merge 6 commits into
AgentSystemLabs:mainfrom
DrewBradfordXYZ:worktree-safe-to-delete

Conversation

@DrewBradfordXYZ

Copy link
Copy Markdown
Contributor

Five commits stacked on #217 (worktree-lock-reason), which is on main (d56195b). This is the follow-up to #170/#183 you said could go against main. Read the commits one at a time: the first is the feature, and the other four are refinements that came out of using it.

Why

The DELETE CHECK (#183) answers "what will this destroy?", but only after d, and one worktree at a time. Three gaps remain:

  1. No answer at a glance. An idle band reads nothing running whether the checkout is a merged leftover or the only copy of a day's work, so every band needs a d before you can decide. On one repo: 8 idle bands. One was empty, six held unlanded work, and one was a tool's own scratch checkout.
  2. The branch is left behind. git worktree remove keeps the branch. A merged one becomes clutter. An unmerged one becomes work nobody finds again, since nebula lists worktrees, not branches. The same repo had 177 local branches after a week of a worktree per task: 170 fully landed, and 2 holding forgotten work.
  3. "Commits its base lacks" counts by ancestry (rev-list base..HEAD). A branch landed by a squash merge, a rebase-and-merge or a cherry-pick (GitHub's squash merge, merge queues) reads as N commits ahead forever. So the check cries wolf on exactly the leftovers people most want to clear.

What

1. The band says it before you press anything (band_safety.rs)

The DELETE CHECK runs in the background for each empty band:

  • one band per git tick, least recently read first;
  • at most one check in flight, and no band re-read within 30 s;
  • GIT_OPTIONAL_LOCKS=0, so it never holds the index lock under an agent's git add.

Its verdict replaces nothing running, in git's own vocabulary:

State Band text
nothing destroyed, branch landed ✓ clean · merged
nothing destroyed, detached ✓ clean · detached
branch holds unlanded commits (a delete keeps them) ↑4 unmerged
uncommitted edits / new files ⚠ 3 modified · 2 untracked
submodule commits no remote has ⚠ 6 unpushed in submodules
detached, commits on no ref ⚠ detached · 2 unreachable
a process outside nebula's sessions working there ● in use by just, cargo · clean · detached, or after a warning ⚠ 3 modified · in use by cargo
locked (git worktree lock, from #217) 🔒 locked: nightly build · clean · detached (accent color; the reason is cut at 40 characters, and the confirm shows it whole)
a git at work, or stopped part-way ⋯ git submodule update running, ⋯ git rebase in progress (dim, re-read next tick)
git could not be read ? can't read submodule lib/a · retrying (retried on the next few ticks, then at the usual pace; the confirm shows git's own words)

⚠ and the warning color mean a delete destroys it. Before its first read, a band says nothing running as it does today. In the nested layout, an empty worktree's row (#204) shows the verdict after its branch name.

The band check keeps the confirm's process sweep, sparing the worktree's own sessions and nebula's helpers, so in use by names only work nebula doesn't show. It skips the gitignored-files read, which only the confirm does. A tool's scratch checkout mid-run read ✓ clean · detached before in use by was added, exactly like an abandoned one.

2. The confirm decides the branch

  ╭ Delete worktree ─────────────────────────────────────────────────╮
  │Delete worktree 'golden-falcon-waltzes' from disk?                │
  │Checked: nothing is running in it, uncommitted, or unlanded.      │
  │Its branch has landed on origin/main and is deleted with it.      │
  │                                                                  │
  │[Enter/y] delete both   [k] keep branch   [Esc/n] cancel          │
  ╰──────────────────────────────────────────────────────────────────╯

  ╭ Delete worktree ─────────────────────────────────────────────────╮
  │Delete worktree 'worktree-agent-a2e151d6' from disk?              │
  │4 commits are on this branch and not on origin/main.              │
  │                                                                  │
  │[Enter/y] keep branch   [D] delete branch too   [Esc/n] cancel    │
  ╰──────────────────────────────────────────────────────────────────╯
  • Which branch goes, and when:

    • A landed branch goes with the checkout by default. A new Delete landed branch setting (Sessions tab, on by default) turns that off.
    • An unlanded branch stays by default; D deletes it and says how many commits go with it.
    • A card's delete that takes its emptied worktree offers the same k / D.
    • Enter, k and D wait for the check, as Enter already does. The dialog still opens at once, as on main.
  • The delete takes exactly what was judged. The check reports the branch and its tip (BranchTip), and the confirm hands both back in DeleteWorktree.delete_branch. The daemon then runs git update-ref --no-deref -d refs/heads/<name> <oid>, an atomic compare-and-delete. A commit made while the confirm was open keeps the branch, and --no-deref means a symbolic-ref branch goes itself, never its target. The daemon also keeps the branch, with a warning, when:

    • the checkout is no longer on that branch (the worktree sync rewrites the row's branch within seconds, so the row is never trusted for this);
    • another checkout has it out;
    • it is the base, origin's default branch or the root checkout's branch.

    A detached HEAD deletes no branch, and a git paused part-way (a stopped rebase) keeps the branch whatever the check found.

  • Ignored files the delete takes. The confirm (not the band) names the gitignored files that git worktree remove --force deletes and that aren't build output: ⚠ 2 ignored files are deleted too: .env, .claude/settings.local.json. target/, node_modules/, dist/ and the like are left out of the list.

3. "Landed" counts by content

commits_ahead becomes unlanded_commits:

  • git cherry <base> HEAD drops each commit whose patch the base already has.
  • Only when + lines remain, a squash test runs: git merge-tree --write-tree <base> HEAD giving the base's own tree means merging would change nothing.

Any trouble reads as unlanded, never landed. That includes a squash-merged branch whose lines the base has changed since: the merge conflicts, so the branch is kept, which is the safe way round. merged on the band therefore covers more than git branch --merged, and docs/sessions.md says so.

4. Submodule commits on no remote

A linked worktree's submodules live in its own git dir (.git/worktrees/<name>/modules/…), which the delete takes. So the superproject can pin a submodule commit that exists nowhere else. The check counts the commits reachable from each such store's HEAD, local branches, tags or stash that no remote-tracking ref holds and no ref of the root checkout's submodule reaches.

  • Which stores count: nested submodules, and every store under the worktree's git dir, including one deinitialised there. The main repository's .git/modules, which a worktree delete leaves alone, never counts.
  • Cost: stores are read eight at a time, found from the index's gitlinks rather than git submodule status. On a worktree with 24 submodules, the confirm's check went from ~1.1 s to ~0.2 s.
  • Mid-clone stores: a store still being cloned (HEAD on a branch with no commit yet) holds nothing and counts 0. Before this, git submodule update failed every band check with ambiguous argument 'HEAD'.
  • Tags: git records no "fetched from" for a tag, so a release tag's history read as 12 unpushed on every checkout of one repo.
    • The band no longer counts tags; it doesn't go to the network every rotation.
    • The confirm counts a tag unless one of the store's remotes has it under the same name at the same object (git ls-remote --tags, timeboxed). It asks only when some tag holds a commit that no branch, stash or remote-tracking ref holds, so most confirms make no network call. A remote that doesn't answer keeps nothing: the confirm warns rather than guess.
  • Folded submodules: a submodule turned into a plain folder of its superproject (git filter-repo --to-subdirectory-filter, git subtree add) keeps each commit's tree but not its id. Its old store in a worktree read as commits on no remote, though all of them were published as the folder: one worktree cut before a run of folds showed ⚠ 29 unpushed in submodules. A commit now also counts as held when some commit a remote-tracking ref of the superproject reaches has the same tree at that folder. A local branch is still no keeper. The walk runs only when the superproject has one of the trees at all.

Protocol

Protocol 54 (BRANCH_LANDING_PROTOCOL), additive. MIN_COMPATIBLE_PROTOCOL is unchanged.

  • WorktreeCheck.landing, CheckWorktree.band and DeleteWorktree.delete_branch are trailing fields, left off the frame at their defaults.
  • The daemon strips landing for a client older than 54, and the TUI never sends the new fields to an older daemon. Against an older daemon, the confirm and the bands read as they do today.
  • WorktreeCheck now has two trailing optionals (locked from 53, then landing). skip_serializing_if can't express "cut only a run of Nones at the end", so a hand-written Serialize does it: a check with a landing and no lock keeps every field in its slot.

54 assumes #174 takes 52 and #217 takes 53. If #174 or #178 lands later, the numbers move to the next free ones (two constants plus PROTOCOL_VERSION).

How verified

  • cargo fmt --all --check and cargo clippy --workspace --all-targets -- -D warnings are clean, and cargo test --workspace passes 1,986 tests on macOS.
  • Daemon tests on real temporary repos:
    • unlanded_commits for branches landed by merge, squash and cherry-pick, and for one unlanded commit; protected branches.
    • Submodule commits on no remote: nested, and in a deinitialised submodule's store; tags counted only when no remote has them; a folded submodule's store counting nothing once its folder is pushed; a store mid-clone counting 0, and an unreadable one named.
    • The compare-and-delete refused after the tip moved; a symbolic-ref branch deleted without its target; an ignored .env listed but target/ not.
    • A running submodule update and a stopped rebase named.
    • A delete taking only the branch and tip the confirm judged.
    • A band check reading the branch and naming a process working in the checkout.
  • TUI tests: every band verdict's wording, including in use, locked and git-at-work; the confirm per branch state; k and D on a band's delete and on a card's; the setting off; an older daemon getting today's confirm.
  • Protocol: the new fields are invisible to an older peer when unset, and a lock and a landing together round-trip.
  • Used daily since 2026-10-10 in a build of nebula with these patches, on repos with two dozen submodules and a merge-queue tool's locked scratch worktree. This is where the in use by, tags, folded-submodule and lock refinements came from.

Open questions

  • The band line is on for everyone. It costs one background git read per idle band per rotation (a git cherry, plus a squash test only when commits remain). Should it go behind a setting?
  • merged counts squash and cherry-pick merges, which git branch --merged doesn't. Keep the word and document it (as now), or say landed?

DrewBradfordXYZ and others added 6 commits November 12, 2026 23:12
…s it be

`git worktree lock --reason …` is git's way to say "keep this checkout":
git's own `worktree remove` and `prune` refuse a locked one. nebula never
showed a lock, and its delete unlocked any lock it met and went ahead, so
a checkout a person, an agent or a tool had locked looked like any other
clean one and went with one `d`. Auto-clean merged worktrees could take
one with no one asked at all.

- The DELETE CHECK reads the lock (`git worktree list --porcelain`) and the
  confirm leads with it: `⚠ locked: <reason> — deleting unlocks it`, or
  `no reason given`. A bulk delete's line says `locked: <reason>`. Nothing
  is assumed about who locked it or whether the lock is stale: the person
  reads the reason and decides.
- Auto-clean merged worktrees skips a locked checkout, with
  `auto-cleanup skipped <branch>: checkout is locked (<reason>)`.
- Protocol 53, additive. `WorktreeCheck::locked` is its last field and is
  left off the wire when None, so an unlocked check is byte for byte what
  it was; the daemon records each client's `Hello` version and clears the
  lock for a client older than 53.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…lete can take the branch too

An idle band read "nothing running" whether its checkout was a merged
leftover or the only copy of a day's work, so every one needed a `d`
before it could be cleared; `git worktree remove` left every branch
behind, where nobody sees it again; and "commits its base lacks" counted
by ancestry, so a branch landed by squash merge, rebase or cherry-pick
read as unlanded forever.

- The band says it first. The DELETE CHECK's git half (no process sweep:
  that stays with the confirm) runs in the background for each empty
  band, one per git tick, least recently read first, at most one in
  flight, none re-read within 30 s (`band_safety.rs`). Its verdict takes
  the place of "nothing running": `✓ clean · merged`, `✓ clean ·
  detached`, `↑4 unmerged`, and in the warning color `⚠ 3 modified · 2
  untracked`, `⚠ 6 unpushed in submodules`, `⚠ detached · 2 unreachable`.
- A band reads git state, not just git's output. A checkout with a git
  at work in it (the check's process sweep finds a `git submodule
  update`, a clone, a fetch, a rebase…) or one stopped part-way
  (rebase-merge/, rebase-apply/, MERGE_HEAD, CHERRY_PICK_HEAD,
  REVERT_HEAD) reads a dim `⋯ git submodule update running` or `⋯ git
  rebase in progress` (a stopped one never hides a ⚠: `⚠ 2 modified ·
  git rebase in progress`), is read again next tick, and its confirm
  keeps the branch. A submodule store mid-clone (HEAD on a branch with no
  commit yet: `git submodule update` was failing every band check with
  "ambiguous argument 'HEAD'") holds nothing to lose and counts 0.
  Only a failure git cannot explain reads `? can't read submodule <path>
  · retrying` or `? can't read git state · retrying` (the confirm shows
  git's own words), retried on the next three ticks, then at the usual
  pace.
- Landed counts by content. `commits_ahead` becomes `unlanded_commits`:
  `git cherry <base> HEAD`, and when it reports `+` lines, a squash test
  (`git merge-tree --write-tree` giving the base's own tree). Any trouble
  reads as unlanded, never landed — including a squash-merged branch whose
  lines the base has changed again since (the merge conflicts): kept.
- Submodule commits on no remote. A commit reachable from a worktree's
  submodule HEAD, local branches, tags or stash that no remote-tracking
  ref holds and no ref of the root checkout's submodule reaches (a
  commit fetched there by id and left dangling still counts) lives only in the
  worktree's own store, which the delete takes; the check counts them.
  Nested submodules count too, and so does every store under the
  worktree's own git dir (`.git/worktrees/<name>/modules/…`), a
  submodule deinitialised there or absent at this commit included; the
  main repository's `.git/modules`, which a worktree delete leaves, never
  does. Background reads run with GIT_OPTIONAL_LOCKS=0.
- Ignored files. The confirm's check (not the band's) also lists the
  gitignored files and folders `git worktree remove --force` takes that
  are not build output (`git ls-files --others --ignored --directory`,
  less target/, node_modules/, dist/ and the like): `⚠ 2 ignored files
  are deleted too: .env, .claude/settings.local.json`.
- The confirm decides the branch. A landed branch goes with the checkout
  (`[Enter/y] delete both   [k] keep branch`), under the new Delete
  landed branch setting (Sessions, on by default); an unlanded one stays
  (`[Enter/y] keep branch   [D] delete branch too`); a card's delete
  that takes its emptied worktree offers the same `k` / `D`. The check
  reports the branch and its tip; the confirm hands both back
  (`DeleteWorktree.delete_branch: Option<BranchTip>`) and the DAEMON
  deletes with `git update-ref --no-deref -d refs/heads/<name> <oid>`, an atomic
  compare-and-delete, so a commit made while the confirm was open keeps
  the branch. It also keeps it, with a warning, when the checkout is no
  longer on that branch (the worktree sync rewrites the row's branch
  within seconds, so the row is never trusted for this), when another
  checkout has it out, or when it is the base, origin's default or the
  root's branch; a detached HEAD deletes nothing.
- The check is quick. Its git questions run side by side and the
  submodules eight at a time, found from the index's gitlinks rather
  than `git submodule status`: on a worktree with 24 submodules the
  confirm's check went from ~1.1 s to ~0.2 s. While it is out, Enter,
  `k` and `D` wait for the answer; Esc cancels.

Protocol 54 (BRANCH_LANDING_PROTOCOL): `WorktreeCheck.landing`,
`CheckWorktree.git_only` and `DeleteWorktree.delete_branch` are trailing
fields left out of the frame at their defaults, so an older peer decodes
what it always did; the daemon strips `landing` for a client older than
54, and the TUI never sends the new fields to an older daemon, whose
confirm and bands read as before. `WorktreeCheck` now has two trailing
optional fields (`locked`, from 53, then `landing`), so it is written by a
hand `Serialize` that cuts only a None run at the end: a check with a
landing and no lock keeps every field in its slot.

Tests: unlanded_commits on real repos landed by merge, squash and
cherry-pick, one unlanded commit, protected branches, and a
submodule commit on no remote, on a local branch behind a detached
HEAD, stashed, nested, or in a deinitialised submodule's store, a compare-and-delete refused after a commit, a symbolic-ref
branch deleted without its target, and an ignored .env listed but not target/
(git.rs); a store mid-clone counting 0 and an unreadable one named
(git.rs); a running submodule update and a stopped rebase named, and
the git command-line parser (worktree_check.rs); a git-only check, and a delete taking a landed or discarded
branch but not one switched away from after a worktree sync, committed
to after the check, out in another checkout, or detached (registry.rs); 51-shape frames
(protocol.rs); band verdict wording, confirm wording, the defaults per
branch state, `k` and `D` on a band's and on a card's delete, the
setting off, and a 51 daemon getting the old confirm, and
keys inert while the check is out, the band's git-at-work and failure
wording, fast retry and back-off, and a mid-rebase confirm keeping
the branch (TUI); the e2e delete removing a landed branch's ref.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A band's `0 sessions` counts nebula's own sessions only, so a checkout a
build, a shell or a tool's scratch run was working in read `✓ clean ·
detached` like an abandoned one: the merge queue's bisect probe in one
repo looked exactly like a leftover worth deleting. The band check
already ran the process sweep, to spot a git at work, and threw the
rest away.

- The band check keeps it. The daemon spares the worktree's own
  sessions (a prewarmed spare) and nebula's helpers exactly as for the
  confirm, so the sweep names only work nebula does not show. The band
  leads with `● in use by just, cargo` (two names, then `+N`), before the
  git verdict with its ✓ dropped (`● in use by zsh · clean · detached`),
  and after a warning (`⚠ 3 modified · in use by cargo`). A git at work
  still names itself (`⋯ git submodule update running`).
- `CheckWorktree.git_only` becomes `band`: it no longer skips the sweep,
  only the gitignored files a confirm lists. Frames are positional, so
  the wire is unchanged; no protocol bump.

Tests: the verdict's wording in use, alone, after a warning, beside a
live git, and silent without a landing (band_safety.rs); a band check
naming a process working in the checkout (registry.rs).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A worktree's band warned "12 unpushed in submodules" on every checkout
of a repo whose submodule had a tag fetched from its remote: git records
no "fetched from" for a tag, and the check counted every commit a tag
held that no remote-tracking branch did, so a release tag's own history
read as work the delete would lose.

- The band no longer counts tags. It runs every rotation and does not go
  to the network; a commit only a tag holds is rare, and the confirm
  still asks.
- The confirm counts a tag unless a remote of the store has it under the
  same name at the same object (`git ls-remote --tags`, timeboxed like a
  fetch). It asks only when some tag holds a commit no branch, stash or
  remote-tracking ref does, so most confirms make no network call. A
  remote that does not answer keeps nothing: the confirm warns rather
  than guess.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The band's verdict now leads with the lock someone put on the checkout
(`git worktree lock --reason …`), whoever put it there: `🔒 locked:
nightly build · clean · detached`, in the accent color, the reason cut at
40 characters (the delete confirm shows it whole). A warning keeps its
place and color ahead of it: `⚠ 3 modified · 🔒 locked: nightly build`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A submodule turned into a plain folder of its superproject (git
filter-repo --to-subdirectory-filter, git subtree add) keeps each
commit's tree but not its id. Its old store, left in a linked worktree's
git dir, then read as commits on no remote, so the band and the delete
confirm warned about work that was published, as the folder.

A store's commits on no remote now also count as held when a commit a
remote-tracking ref of the superproject reaches has the same tree at
that folder. A local branch is still no keeper. The walk runs only when
the superproject has one of the trees at all.

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