Repository navigation
Deleting a worktree first shows what is still working or unsaved in it - #170
Open
DrewBradfordXYZ wants to merge 2 commits into
Open
DrewBradfordXYZ wants to merge 2 commits into
DrewBradfordXYZ wants to merge 2 commits into
Conversation
A worktree's band counts nebula's own sessions, and nothing else. An agent whose session lives in one checkout often works in another through absolute paths (a background subagent keeps its parent's working directory), so a band that reads "0 sessions · nothing running" can have a build running in it and an hour of edits nobody committed — and `d` on it removed the checkout with `git worktree remove --force` behind a confirm that said "0 session(s) will be killed". Every confirm that would take a worktree off disk (`d` on its band, the worktree folded into a card's delete, `Shift+D`) now runs the DELETE CHECK and lists what it found under the question: - processes whose working directory is inside the checkout, other than its own sessions' process trees (already counted, and going with it) and nebula's own helpers (the DAEMON's non-session children, such as the check's own git, and the asking client's tree) — read off /proc on Linux, one `lsof -d cwd` on macOS, before the git half runs; - what `git status` reports (submodules with changes or new commits included), and how recently the newest of those paths was written; - commits HEAD has that its base lacks (the WORKTREE BASE BRANCH setting, else origin's default branch, else the root's branch; local refs only), or on a detached HEAD the commits no branch or remote holds; no base to compare with is reported, never read as "nothing missing". Enter waits for the check, up to ten seconds, so the delete is never confirmed ahead of it; a check that fails or runs out of time says so instead of reading as clean, and `n` (the card only) never waits. The request is new (CheckWorktree, protocol 51) and only sent to a daemon that speaks it; an older one gets the confirm exactly as before. The dialog opens once, already filled in. While its check is out it is not drawn — the footer says `checking 'feat' for work in progress…` — so a reply opens it with the findings in place, instead of a "Checking…" body that then redraws as a different dialog. Only a check still out after five seconds opens it early, with "Checking…". While held, only Esc (or `n` where it cancels) and a waiting Enter get through. The opt-in merged-worktree cleanup, which already waits for idle sessions and a clean checkout, also skips a checkout a process is working in. Tests: git (status -z parsing, base/ahead/detached on a real repo), the process sweep (lsof parsing, component-wise match, a live `sleep`), pty subtree_pids, check_worktree and the cleanup refusal through the daemon, five TUI tests (Enter waits then lists findings, a failed check, reopen asks again, an older daemon, the card delete's `n`, a fast check opening the dialog once, a slow one revealed at five seconds, and keys inert while held), and the CheckWorktree round trip in e2e_pty. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
worktree_check.rs imported anyhow::bail at the top, but its one use is in the lsof sweep, compiled only where there is no /proc. On Linux the import was unused and clippy's -D warnings failed the ubuntu-latest job. Qualify it at the call instead. Checked: cargo zigbuild clippy --workspace --all-targets --target x86_64-unknown-linux-gnu -- -D warnings is clean, as is clippy on macOS. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Contributor
Author
|
The (The earlier ubuntu failure, an import unused on Linux, is fixed in 52c41cc.) |
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.
don a worktree's band could take a checkout off disk while an agent was building in it: the confirm counted nebula's own sessions only, so it said "0 session(s) will be killed" andgit worktree remove --forcedid the rest. Every confirm that deletes a worktree now asks the DAEMON what the checkout still holds — processes working in it, uncommitted changes, commits its base lacks — and lists it under the question beforeEnterwill do anything.Contents: 🐛 What went wrong · 🛡️ Look before you delete · 📸 Screenshots · 🔁 Flow ·⚠️ Risk · 🔧 Technical overview · 🧪 Proof · 📝 Notes · 🔭 What's next
🐛 What went wrong
0 sessions · nothing running, though a build is running there and its edits are not committed.dremoved it.0 session(s) will be killed;Enterrangit worktree remove --force, which deletes uncommitted work and pulls the directory out from under the running build.Shift+D.🛡️ Look before you delete
git, the TUI that asked);the newest written 2m agosays something is still at it);Enterwaits for it.still checking 'feat' for work in progress — a moment.non a card's three-way confirm (delete the card, keep the checkout) never waits, since it deletes no checkout.don a band, Delete worktree in its right-click menu, the worktree a card's delete offers to take,Shift+D, and the Delete emptied worktree setting's two-way confirm.📸 Screenshots
make shot SCENE=delete-check: a worktree with a commit, an uncommitted file and asleeprunning in it. Its band readsnothing running;don it shows what the check found.While the check runs, the confirm is held back and the footer says
checking 'feature-x' for work in progress…; it opens once, already filled in. A check still out after 5 s opens it withChecking 'feature-x' for work in progress…in place of the findings. A clean checkout gets one dim line:Checked: nothing is running in it, uncommitted, or missing from its base.🔁 Flow
sequenceDiagram participant U as User participant T as TUI participant D as DAEMON U->>T: d on a worktree band T->>T: Confirm held, footer "checking 'feat'…" T->>D: CheckWorktree { id } (protocol ≥ 51 only) par process sweep D->>D: lsof -d cwd (macOS) / /proc/*/cwd (Linux), minus the worktree's own session trees and git D->>D: status -z, mtimes, rev-list base..HEAD end D-->>T: WorktreeChecked { check } T->>T: Confirm opens with the findings under the question U->>T: Enter (held until here, at most 10 s) T->>D: DeleteWorktree { force: true }🎯 Attack surface
ClientRequest::CheckWorktree. Reached by any client on the DAEMON's socket (mode0700, same user). Held by: it only reads —git status,rev-list,symbolic-ref, file mtimes, and a process sweep — and names a worktree by id, never a path the client supplies. Not held: a client learns the names and pids of the user's own processes working in a checkout, which that user can already list withps.lsof(macOS). Found onPATHlike thepsandpgrepthe daemon already runs; fixed arguments, no input from the request. On Linux there is no exec: it reads/proc.Verdict: 🟢 Low risk — a read-only request and a dialog that only ever waits longer; nothing deletes that did not before.
tokio::spawn+spawn_blocking);lsof -d cwdmeasured ~0.3 s here with ~600 processes; nothing on the draw or PTY pathGetMetrics/TailOutputreply pattern,git.rshelpers likestatus_porcelain, theps-table walk inpty, and the existingConfirmDialogRollback:
git revertof the merge. It does not undo the PROTOCOL VERSION bump to 51: a client built after the revert would still have to be protocol-compatible with a 51 daemon (MIN stays 49, so it is).🔧 Technical overview
crates/nebula-daemon/src/worktree_check.rs—check()joins the process sweep (/procon Linux, onelsof -w -a -d cwd -F pcnelsewhere; matched component-wise against both the stored and the resolved checkout path) with the git half (git::changed_paths,comparison_base,commits_ahead,head_detached,unreferenced_commitsingit.rs). Every part says when it could not look (process_error,git_error);WorktreeCheck::has_findingstreats those as findings.Daemon::own_session_pids(registry/projects.rs) takes the child pid of each session, and prewarmed spare, homed in the worktree, andpty::subtree_pidsadds everything under them. A failedpsspares the leaders alone, so the check errs toward naming too much.nebula_helper_pidsspares the DAEMON's direct children that lead no session (itsgit,ps,lsofruns) and the asking client's tree (its pid from the socket's peer credentials). Not the daemon's whole subtree: every session's tools run under it, including an agent in another checkout building in this one — the case the check is for. The sweep also runs before the git half, never beside it.comparison_base: the WORKTREE BASE BRANCH setting when the repo has it (origin's copy first), elseorigin/HEAD, else the root's branch. Local refs only; a confirm never fetches.crates/nebula-tui/src/delete_check.rs—syncruns once per event-loop turn, so whichever key or menu opened a worktree-deleting confirm, it is checked (no call site needs to know).still_checkingholdsEnter/y;lineswords the result, one line per finding, or one line per worktree forShift+D. The daemon's protocol now reachesApp(IpcChannels::protocol_version) so the request is only sent to a daemon atCHECK_WORKTREE_PROTOCOL.DeleteWorktreecould refuse when the check finds something, but saying "I know" would need a new field on an existing variant, which an older daemon cannot decode (compact MessagePack). The TUI is the only sender ofDeleteWorktree, and every one of its paths goes through a confirm, so the check lives there.git worktree removewithout--force. It refuses only dirty checkouts; it knows nothing of processes, and the TUI's forced delete is what lets a worktree with submodules go at all.🧪 Proof
sleep) under a shell, in its own process group, as an agent's build would be — never as a direct child of the daemon.delete_check_git_reads_changes_commits_and_detached_headsandstatus_z_keeps_odd_names_and_drops_rename_sources(git.rs);lsof_fields_parse_into_pid_name_and_cwd,processes_under_match_whole_components_and_skip_spared_pids,the_live_sweep_finds_a_process_working_in_the_checkout(a realsleepin a temp checkout) anda_missing_checkout_checks_clean(worktree_check.rs);subtree_pids_take_each_root_and_everything_below_it(pty);check_worktree_reports_outside_processes_and_changesandmerged_cleanup_skips_a_checkout_a_process_is_working_in(registry.rs).a_worktree_delete_waits_for_its_check_and_lists_what_it_found,a_failed_worktree_check_says_so_and_lets_the_confirm_through,reopening_a_worktree_delete_checks_again(a late answer or error for a closed confirm is dropped silently),an_older_daemon_gets_no_check_and_the_old_confirm,a_card_delete_that_offers_the_worktree_checks_it_but_n_never_waits, plus the wording tests indelete_check.rs.full_crud_attach_and_restart_persistence(e2e_pty.rs) now sendsCheckWorktreeto a real daemon over its socket and readsWorktreeCheckedback before the delete.cargo fmt --checkclean,cargo clippy --workspace --all-targets -- -D warningsclean,cargo test --workspacegreen: 1,830 tests (macOS 15 / Darwin 24.5, rustc 1.97.1). The Linux/procbranch compiles in the workspace but was not run on Linux.📝 Notes
a00b760(bc7df8e). Reviewed once by an independent agent before drafting; its findings (the check's owngitcounted as a process, a missing base read as clean, a closed confirm's error flashing) are fixed in this commit. Protocol 51; if another PR takes 51 first, this one moves to the next number (PROTOCOL_VERSIONandCHECK_WORKTREE_PROTOCOLinnebula-core/src/protocol.rs).🔭 What's next
Two follow-ups build on this check, each a branch on my fork, to be sent once this one lands:
worktree-safe-to-delete: the check's git half runs in the background for idle bands, so each band says before any key whether a delete loses anything (✓ clean · merged,↑4 unmerged,⚠ 3 modified · 2 untracked,⚠ 6 unpushed in submodules). "Landed" counts by content (git cherry, plus a squash check), so squash- and cherry-picked branches stop reading as ahead forever, and the confirm can delete a landed branch with its checkout (kkeeps it) while keeping an unlanded one (Dtakes it).subagent-worktree-activity: shows another session's subagents on the band of the worktree they work in, and the same confirm names them.🤖 Generated with Claude Code