Repository navigation
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
Open
A worktree's band says whether deleting it loses anything, and its delete can take the branch too#218DrewBradfordXYZ wants to merge 6 commits into
DrewBradfordXYZ wants to merge 6 commits into
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Five commits stacked on #217 (
worktree-lock-reason), which is onmain(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:nothing runningwhether the checkout is a merged leftover or the only copy of a day's work, so every band needs adbefore 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.git worktree removekeeps 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.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:
GIT_OPTIONAL_LOCKS=0, so it never holds the index lock under an agent'sgit add.Its verdict replaces
nothing running, in git's own vocabulary:✓ clean · merged✓ clean · detached↑4 unmerged⚠ 3 modified · 2 untracked⚠ 6 unpushed in submodules⚠ detached · 2 unreachable● in use by just, cargo · clean · detached, or after a warning⚠ 3 modified · in use by cargogit worktree lock, from #217)🔒 locked: nightly build · clean · detached(accent color; the reason is cut at 40 characters, and the confirm shows it whole)⋯ git submodule update running,⋯ git rebase in progress(dim, re-read next tick)? 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 saysnothing runningas 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 bynames 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 · detachedbeforein use bywas added, exactly like an abandoned one.2. The confirm decides the branch
Which branch goes, and when:
Ddeletes it and says how many commits go with it.k/D.Enter,kandDwait for the check, asEnteralready 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 inDeleteWorktree.delete_branch. The daemon then runsgit 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-derefmeans a symbolic-ref branch goes itself, never its target. The daemon also keeps the branch, with a warning, when: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 --forcedeletes 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_aheadbecomesunlanded_commits:git cherry <base> HEADdrops each commit whose patch the base already has.+lines remain, a squash test runs:git merge-tree --write-tree <base> HEADgiving 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.
mergedon the band therefore covers more thangit branch --merged, anddocs/sessions.mdsays 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..git/modules, which a worktree delete leaves alone, never counts.git submodule status. On a worktree with 24 submodules, the confirm's check went from ~1.1 s to ~0.2 s.git submodule updatefailed every band check withambiguous argument 'HEAD'.12 unpushedon every checkout of one repo.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.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_PROTOCOLis unchanged.WorktreeCheck.landing,CheckWorktree.bandandDeleteWorktree.delete_branchare trailing fields, left off the frame at their defaults.landingfor 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.WorktreeChecknow has two trailing optionals (lockedfrom 53, thenlanding).skip_serializing_ifcan't express "cut only a run of Nones at the end", so a hand-writtenSerializedoes 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 --checkandcargo clippy --workspace --all-targets -- -D warningsare clean, andcargo test --workspacepasses 1,986 tests on macOS.unlanded_commitsfor branches landed by merge, squash and cherry-pick, and for one unlanded commit; protected branches..envlisted buttarget/not.submodule updateand a stopped rebase named.kandDon a band's delete and on a card's; the setting off; an older daemon getting today's confirm.in use by, tags, folded-submodule and lock refinements came from.Open questions
git cherry, plus a squash test only when commits remain). Should it go behind a setting?mergedcounts squash and cherry-pick merges, whichgit branch --mergeddoesn't. Keep the word and document it (as now), or saylanded?