Skip to content

Deleting a worktree first shows what is still working or unsaved in it - #170

Open
DrewBradfordXYZ wants to merge 2 commits into
AgentSystemLabs:mainfrom
DrewBradfordXYZ:safer-worktree-delete
Open

DrewBradfordXYZ wants to merge 2 commits into
AgentSystemLabs:mainfrom
DrewBradfordXYZ:safer-worktree-delete

Conversation

@DrewBradfordXYZ

Copy link
Copy Markdown
Contributor

d on 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" and git worktree remove --force did 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 before Enter will do anything.

Contents: 🐛 What went wrong · 🛡️ Look before you delete · 📸 Screenshots · 🔁 Flow · ⚠️ Risk · 🔧 Technical overview · 🧪 Proof · 📝 Notes · 🔭 What's next

🐛 What went wrong

  • A band's count is nebula's sessions, nothing else.
    • An agent whose session lives in one checkout often works in another through absolute paths; a Claude Code background subagent keeps its parent's working directory.
    • That checkout's band reads 0 sessions · nothing running, though a build is running there and its edits are not committed.
  • One d removed it.
    • The confirm said 0 session(s) will be killed; Enter ran git worktree remove --force, which deletes uncommitted work and pulls the directory out from under the running build.
    • The same goes for the worktree folded into a card's delete, and for Shift+D.

🛡️ Look before you delete

  • The DELETE CHECK — the confirm lists, under its question:
    • processes whose working directory is inside the checkout, other than its own sessions' process trees (those are counted already, and go with it) and nebula's own helpers (the check's own git, the TUI that asked);
    • uncommitted changes, submodules with changes or new commits included, and how recently the newest was written (the newest written 2m ago says something is still at it);
    • commits HEAD has that its base lacks (the branch keeps them), or on a detached HEAD the commits no branch holds, which only the reflog would keep. A repo with no base to compare with says so.
  • Enter waits for it.
    • Up to ten seconds; a press before then flashes still checking 'feat' for work in progress — a moment.
    • A check that fails, or runs out of time, says so in the dialog. It never reads as clean.
    • n on a card's three-way confirm (delete the card, keep the checkout) never waits, since it deletes no checkout.
  • Every way in. d on 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.
  • Auto-cleanup of merged worktrees already waited for idle sessions and a clean checkout; it now also skips a checkout a process is working in.
  • Unchanged. The dialog, its keys and its wording are the same, with lines added under the question. A daemon too old to answer is never asked, and its confirm is exactly what it was.

📸 Screenshots

make shot SCENE=delete-check: a worktree with a commit, an uncommitted file and a sleep running in it. Its band reads nothing running; d on it shows what the check found.

The Delete worktree confirm for feature-x: under "Delete worktree 'feature-x' from disk? 0 session(s) will be killed." it lists "1 process is working in it: sleep", "1 uncommitted change, the newest written just now" and "1 commit main lacks (the branch keeps them)"

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 with Checking '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 }
Loading

⚠️ Risk

🎯 Attack surface

  • ClientRequest::CheckWorktree. Reached by any client on the DAEMON's socket (mode 0700, 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 with ps.
  • The DAEMON runs lsof (macOS). Found on PATH like the ps and pgrep the 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.

Level Why
🔒 Security & production Low a same-user, read-only request; the delete itself is unchanged; an older daemon is never sent it (gated on its handshake version)
⚡ Performance Low runs once per confirm, off the request loop (tokio::spawn + spawn_blocking); lsof -d cwd measured ~0.3 s here with ~600 processes; nothing on the draw or PTY path
🧩 Fit with the codebase Low the GetMetrics / TailOutput reply pattern, git.rs helpers like status_porcelain, the ps-table walk in pty, and the existing ConfirmDialog

Rollback: git revert of 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

  • The check. crates/nebula-daemon/src/worktree_check.rs — check() joins the process sweep (/proc on Linux, one lsof -w -a -d cwd -F pcn elsewhere; 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_commits in git.rs). Every part says when it could not look (process_error, git_error); WorktreeCheck::has_findings treats those as findings.
  • Sparing the worktree's own sessions. Daemon::own_session_pids (registry/projects.rs) takes the child pid of each session, and prewarmed spare, homed in the worktree, and pty::subtree_pids adds everything under them. A failed ps spares the leaders alone, so the check errs toward naming too much.
  • Sparing nebula's helpers. nebula_helper_pids spares the DAEMON's direct children that lead no session (its git, ps, lsof runs) 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.
  • Base, without the network. comparison_base: the WORKTREE BASE BRANCH setting when the repo has it (origin's copy first), else origin/HEAD, else the root's branch. Local refs only; a confirm never fetches.
  • The TUI. crates/nebula-tui/src/delete_check.rs — sync runs 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_checking holds Enter/y; lines words the result, one line per finding, or one line per worktree for Shift+D. The daemon's protocol now reaches App (IpcChannels::protocol_version) so the request is only sent to a daemon at CHECK_WORKTREE_PROTOCOL.
  • Why not refuse in the DAEMON. DeleteWorktree could 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 of DeleteWorktree, and every one of its paths goes through a confirm, so the check lives there.
  • Why not git worktree remove without --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

  • Daemon. The two daemon-level tests run their stand-in build (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_heads and status_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 real sleep in a temp checkout) and a_missing_checkout_checks_clean (worktree_check.rs); subtree_pids_take_each_root_and_everything_below_it (pty); check_worktree_reports_outside_processes_and_changes and merged_cleanup_skips_a_checkout_a_process_is_working_in (registry.rs).
  • TUI. 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 in delete_check.rs.
  • End to end. full_crud_attach_and_restart_persistence (e2e_pty.rs) now sends CheckWorktree to a real daemon over its socket and reads WorktreeChecked back before the delete.
  • Gate. cargo fmt --check clean, cargo clippy --workspace --all-targets -- -D warnings clean, cargo test --workspace green: 1,830 tests (macOS 15 / Darwin 24.5, rustc 1.97.1). The Linux /proc branch compiles in the workspace but was not run on Linux.

📝 Notes

  • One commit on a00b760 (bc7df8e). Reviewed once by an independent agent before drafting; its findings (the check's own git counted 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_VERSION and CHECK_WORKTREE_PROTOCOL in nebula-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 (k keeps it) while keeping an unlanded one (D takes 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

DrewBradfordXYZ and others added 2 commits October 10, 2026 10:00
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>
@DrewBradfordXYZ

Copy link
Copy Markdown
Contributor Author

The cargo (ubuntu-latest) failure is upgrade::tests::reads_the_protocol_of_the_first_nebula_on_path, which this PR doesn't touch. Its sibling reads_the_protocol_range_when_the_installed_binary_advertises_one failed the same way on main in runs 37977824397, 37977487761 and 37966184691. Both write a fake nebula script and exec it at once, which on Linux can hit ETXTBSY when another test thread forks in between. A re-run should pass; I can't trigger one from the fork.

(The earlier ubuntu failure, an import unused on Linux, is fixed in 52c41cc.)

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