From e1acca985d2529c09840fcb997b118b24bd77b36 Mon Sep 17 00:00:00 2001 From: Drew Bradford <16455249+DrewBradfordXYZ@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:56:56 -0400 Subject: [PATCH 1/6] A locked worktree says why before it is deleted, and auto-clean leaves it be MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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: — deleting unlocks it`, or `no reason given`. A bulk delete's line says `locked: `. 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 : checkout is locked ()`. - 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) --- crates/nebula-core/src/protocol.rs | 69 ++++++++- crates/nebula-daemon/src/git.rs | 135 +++++++++++++++++- crates/nebula-daemon/src/registry.rs | 46 ++++++ crates/nebula-daemon/src/registry/projects.rs | 10 ++ crates/nebula-daemon/src/server.rs | 13 +- crates/nebula-daemon/src/worktree_check.rs | 7 +- crates/nebula-tui/src/delete_check.rs | 54 +++++++ docs/configuration.md | 2 +- docs/keys.md | 2 +- docs/sessions.md | 8 +- 10 files changed, 334 insertions(+), 12 deletions(-) diff --git a/crates/nebula-core/src/protocol.rs b/crates/nebula-core/src/protocol.rs index 8348fea6..7caf5b2f 100644 --- a/crates/nebula-core/src/protocol.rs +++ b/crates/nebula-core/src/protocol.rs @@ -10,7 +10,7 @@ use std::path::PathBuf; /// /// Bump on every protocol change. Additive changes keep /// [`MIN_COMPATIBLE_PROTOCOL`] where it is; breaking changes bump both. -pub const PROTOCOL_VERSION: u32 = 51; +pub const PROTOCOL_VERSION: u32 = 53; /// Oldest IPC protocol this build can safely talk to. /// @@ -25,6 +25,11 @@ pub const MIN_COMPATIBLE_PROTOCOL: u32 = 49; /// without the check. pub const CHECK_WORKTREE_PROTOCOL: u32 = 51; +/// First protocol whose clients read `WorktreeCheck::locked`. The daemon +/// leaves it off the check it sends an older client, whose reply is then +/// byte for byte what it always was. +pub const WORKTREE_LOCK_PROTOCOL: u32 = 53; + pub fn protocol_ranges_overlap( local_min: u32, local_current: u32, @@ -647,6 +652,15 @@ pub struct WorktreeCheck { pub detached: bool, /// Why git could not be asked, when it could not. pub git_error: Option, + /// The checkout is locked (`git worktree lock`): the reason it was + /// locked with, empty when none was given. Whoever locked it — a + /// person, an agent, a CI tool — asked git to keep it, and git's own + /// `worktree remove` refuses it; a delete unlocks it first, so the + /// confirm says so. Kept last and left off the wire when None: an + /// older client decodes the shorter check it always did, and the + /// daemon clears it for one (`WORKTREE_LOCK_PROTOCOL`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub locked: Option, } /// One process `WorktreeCheck` found working inside a checkout. @@ -666,6 +680,7 @@ impl WorktreeCheck { || self.changes > 0 || self.ahead > 0 || self.git_error.is_some() + || self.locked.is_some() } } @@ -862,4 +877,56 @@ mod tests { assert!(protocol_compatible_with(PROTOCOL_VERSION + 1)); assert!(!protocol_compatible_with(MIN_COMPATIBLE_PROTOCOL - 1)); } + + /// A check with no lock is the bytes a client older than + /// `WORKTREE_LOCK_PROTOCOL` always read; one with a lock reaches a new + /// client whole. + #[test] + fn a_checks_lock_costs_an_older_client_nothing() { + /// `WorktreeCheck` as it was before `locked`. + #[derive(Serialize, Deserialize)] + struct Before { + processes: Vec, + process_error: Option, + changes: u32, + newest_change_ms: Option, + ahead: u32, + base: Option, + detached: bool, + git_error: Option, + } + let unlocked = WorktreeCheck { + changes: 2, + base: Some("origin/main".into()), + ..WorktreeCheck::default() + }; + let before = Before { + processes: Vec::new(), + process_error: None, + changes: 2, + newest_change_ms: None, + ahead: 0, + base: Some("origin/main".into()), + detached: false, + git_error: None, + }; + assert_eq!( + rmp_serde::to_vec(&unlocked).unwrap(), + rmp_serde::to_vec(&before).unwrap() + ); + let older: WorktreeCheck = + rmp_serde::from_slice(&rmp_serde::to_vec(&before).unwrap()).unwrap(); + assert_eq!( + older, unlocked, + "a newer client reads an older daemon's check" + ); + + let locked = WorktreeCheck { + locked: Some("kept".into()), + ..unlocked + }; + let back: WorktreeCheck = + rmp_serde::from_slice(&rmp_serde::to_vec(&locked).unwrap()).unwrap(); + assert_eq!(back, locked); + } } diff --git a/crates/nebula-daemon/src/git.rs b/crates/nebula-daemon/src/git.rs index b380a60f..b172a3c1 100644 --- a/crates/nebula-daemon/src/git.rs +++ b/crates/nebula-daemon/src/git.rs @@ -170,6 +170,78 @@ fn parse_worktree_list(out: &str) -> Vec { entries } +/// The lock git keeps on `worktree`, a checkout of `repo`: the reason it +/// was locked with (empty when none was given), or None when it is not +/// locked. Anyone can lock a checkout — `git worktree lock --reason …` by +/// hand, an agent marking the one it works in, a tool keeping one it +/// reuses — and git's own `worktree remove` and `prune` then leave it be. +pub async fn worktree_lock(repo: &Path, worktree: &Path) -> Result> { + let out = git(repo, &["worktree", "list", "--porcelain"]).await?; + let real = std::fs::canonicalize(worktree).ok(); + Ok(lock_in_list(&out, |listed| { + listed == worktree || real.is_some() && std::fs::canonicalize(listed).ok() == real + })) +} + +/// The pure half of [`worktree_lock`]: in `git worktree list --porcelain` +/// output, the `locked` line of the stanza whose path `is_target` picks. +/// Git writes `locked` alone when no reason was given, and C-quotes a +/// reason it cannot print as is (a newline, a quote). +fn lock_in_list(out: &str, is_target: impl Fn(&Path) -> bool) -> Option { + let mut in_target = false; + for line in out.lines() { + if let Some(listed) = line.strip_prefix("worktree ") { + in_target = is_target(Path::new(listed)); + } else if in_target && line == "locked" { + return Some(String::new()); + } else if let Some(reason) = line.strip_prefix("locked ").filter(|_| in_target) { + return Some(unquote_c_style(reason)); + } + } + None +} + +/// Undo git's C-style quoting (`"a \"b\"\n\303\251"`); text git left +/// unquoted comes back as it is. +fn unquote_c_style(text: &str) -> String { + let Some(inner) = text.strip_prefix('"').and_then(|t| t.strip_suffix('"')) else { + return text.to_string(); + }; + let mut bytes = Vec::with_capacity(inner.len()); + let mut chars = inner.bytes().peekable(); + while let Some(b) = chars.next() { + if b != b'\\' { + bytes.push(b); + continue; + } + match chars.next() { + Some(b'n') => bytes.push(b'\n'), + Some(b't') => bytes.push(b'\t'), + Some(b'r') => bytes.push(b'\r'), + Some(b'a') => bytes.push(0x07), + Some(b'b') => bytes.push(0x08), + Some(b'f') => bytes.push(0x0c), + Some(b'v') => bytes.push(0x0b), + Some(d @ b'0'..=b'7') => { + let mut value = u32::from(d - b'0'); + for _ in 0..2 { + match chars.peek() { + Some(&o @ b'0'..=b'7') => { + value = value * 8 + u32::from(o - b'0'); + chars.next(); + } + _ => break, + } + } + bytes.push(u8::try_from(value).unwrap_or(b'?')); + } + Some(other) => bytes.push(other), + None => bytes.push(b'\\'), + } + } + String::from_utf8_lossy(&bytes).into_owned() +} + /// The branch a paused rebase in `checkout` is replaying, if there is one — /// read from the same state file `git status` uses to say "rebasing branch /// X" while `git worktree list` calls the checkout detached. `None` for a @@ -736,10 +808,13 @@ pub async fn remove_worktree(repo: &Path, worktree_path: &Path, force: bool) -> let _ = git(repo, &["worktree", "prune"]).await; Ok(()) } - // Locked by a session that ran `git worktree lock` (Claude Code locks - // its worktree and a killed session never unlocks). The caller has - // already killed this worktree's sessions, so the lock is stale — - // unlock and retry rather than surfacing git's refusal. + // Locked (`git worktree lock`): often by an agent that locks the + // checkout it works in and never unlocks when killed, but anyone + // can lock one to keep it. A delete the person asked for was + // confirmed with the lock and its reason in front of them (the + // DELETE CHECK names it, to a client that reads it); auto-cleanup + // never takes a locked checkout. So unlock and retry rather than + // surfacing git's refusal. Err(e) if e.to_string().contains("locked working tree") => { git(repo, &["worktree", "unlock", &path_str]).await?; git(repo, &args).await?; @@ -1450,6 +1525,58 @@ mod tests { remove_worktree(&repo, &wt, false).await.unwrap(); } + #[test] + fn lock_in_list_reads_the_target_stanzas_lock_and_reason() { + let out = "worktree /r\nHEAD 1\nbranch refs/heads/main\n\n\ + worktree /r/a\nHEAD 2\nbranch refs/heads/a\nlocked\n\n\ + worktree /r/b\nHEAD 3\ndetached\nlocked merge queue: reused by every push\n\n\ + worktree /r/c\nHEAD 4\nbranch refs/heads/c\nlocked \"two\\nlines \\\"q\\\" caf\\303\\251\"\n\n\ + worktree /r/d\nHEAD 5\nbranch refs/heads/d\n"; + let lock = |p: &str| lock_in_list(out, |listed| listed == Path::new(p)); + assert_eq!(lock("/r"), None, "the main checkout is not locked"); + assert_eq!(lock("/r/a").as_deref(), Some(""), "locked, no reason"); + assert_eq!( + lock("/r/b").as_deref(), + Some("merge queue: reused by every push") + ); + assert_eq!(lock("/r/c").as_deref(), Some("two\nlines \"q\" café")); + assert_eq!(lock("/r/d"), None, "another stanza's lock is not its"); + assert_eq!(lock("/r/x"), None, "not listed"); + } + + #[tokio::test] + async fn worktree_lock_reads_what_git_worktree_lock_wrote() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repo"); + std::fs::create_dir(&repo).unwrap(); + init_repo(&repo).await; + let wt = add_worktree(&repo, "feature", None).await.unwrap(); + let wt_str = wt.to_string_lossy().into_owned(); + assert_eq!(worktree_lock(&repo, &wt).await.unwrap(), None); + git(&repo, &["worktree", "lock", &wt_str]).await.unwrap(); + assert_eq!( + worktree_lock(&repo, &wt).await.unwrap().as_deref(), + Some("") + ); + git(&repo, &["worktree", "unlock", &wt_str]).await.unwrap(); + git( + &repo, + &[ + "worktree", + "lock", + "--reason", + "kept for the nightly build", + &wt_str, + ], + ) + .await + .unwrap(); + assert_eq!( + worktree_lock(&repo, &wt).await.unwrap().as_deref(), + Some("kept for the nightly build") + ); + } + #[tokio::test] async fn remove_worktree_unlocks_session_locked_checkout() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/nebula-daemon/src/registry.rs b/crates/nebula-daemon/src/registry.rs index c8a4fb10..4c8a2759 100644 --- a/crates/nebula-daemon/src/registry.rs +++ b/crates/nebula-daemon/src/registry.rs @@ -4586,6 +4586,52 @@ mod tests { assert!(!wt.exists()); } + /// A checkout someone locked is theirs to keep: the cleanup leaves it + /// and says why, whoever locked it. + #[tokio::test] + async fn merged_cleanup_skips_a_locked_checkout() { + let tmp = tempfile::tempdir().unwrap(); + let root = std::fs::canonicalize(tmp.path()).unwrap(); + let repo = init_repo(&root); + let wt = root.join("repo-worktrees").join("feat"); + let wt_str = wt.to_string_lossy().into_owned(); + git_in(&repo, &["worktree", "add", &wt_str, "-b", "feat"]); + git_in( + &repo, + &[ + "worktree", + "lock", + "--reason", + "reused by the release job", + &wt_str, + ], + ); + let daemon = test_daemon(); + project_at(&daemon, &repo); + seed_worktree(&daemon, "p", "feat", &wt_str, false); + let id = WorktreeId("feat".into()); + let head = git::head_sha(&wt).await.unwrap(); + let url = "https://github.com/o/r/pull/1"; + + let err = daemon + .cleanup_merged_worktree(&id, "feat", 1, url, &head) + .await + .expect_err("a locked checkout is kept"); + assert!( + err.to_string() + .contains("checkout is locked (reused by the release job)"), + "{err:#}" + ); + assert!(wt.exists()); + + git_in(&repo, &["worktree", "unlock", &wt_str]); + daemon + .cleanup_merged_worktree(&id, "feat", 1, url, &head) + .await + .unwrap(); + assert!(!wt.exists()); + } + fn git_in(repo: &Path, args: &[&str]) { let out = std::process::Command::new("git") .arg("-C") diff --git a/crates/nebula-daemon/src/registry/projects.rs b/crates/nebula-daemon/src/registry/projects.rs index d69380c6..95591d5a 100644 --- a/crates/nebula-daemon/src/registry/projects.rs +++ b/crates/nebula-daemon/src/registry/projects.rs @@ -625,6 +625,16 @@ impl Daemon { if !status.trim().is_empty() { bail!("auto-cleanup skipped {branch}: checkout has uncommitted or untracked changes"); } + // Someone asked git to keep it; only a person, at the confirm, may + // overrule that. + if let Some(reason) = git::worktree_lock(&project.repo_path, &worktree.path).await? { + let why = if reason.is_empty() { + String::new() + } else { + format!(" ({reason})") + }; + bail!("auto-cleanup skipped {branch}: checkout is locked{why}"); + } let (_, _, agents, terminals) = self.store_blocking(|store| store.load_tree()).await?; if let Some(agent) = agents.iter().find(|agent| { diff --git a/crates/nebula-daemon/src/server.rs b/crates/nebula-daemon/src/server.rs index 9ccfb177..d7a70088 100644 --- a/crates/nebula-daemon/src/server.rs +++ b/crates/nebula-daemon/src/server.rs @@ -87,6 +87,9 @@ struct ClientConnection { attached: HashMap>, subscription: Option>, handshaken: bool, + /// The protocol the client said it speaks in its `Hello`: a reply + /// carrying something newer than that is cut down to what it reads. + client_protocol: u32, /// The client process, when the socket's credentials name it. peer_pid: Option, } @@ -99,6 +102,7 @@ impl ClientConnection { attached: HashMap::new(), subscription: None, handshaken: false, + client_protocol: 0, peer_pid: None, } } @@ -649,6 +653,7 @@ impl ClientConnection { async fn handle_hello(&mut self, protocol_version: u32) -> bool { self.handshaken = nebula_core::protocol_compatible_with(protocol_version); + self.client_protocol = protocol_version; let reply = if self.handshaken { ServerEvent::HelloOk { protocol_version: PROTOCOL_VERSION, @@ -911,9 +916,15 @@ impl ClientConnection { let daemon = self.daemon.clone(); let out_tx = self.out_tx.clone(); let client = self.peer_pid; + let reads_lock = self.client_protocol >= nebula_core::WORKTREE_LOCK_PROTOCOL; tokio::spawn(async move { let ev = match daemon.check_worktree(&id, client).await { - Ok(check) => ServerEvent::WorktreeChecked { req_id, id, check }, + Ok(mut check) => { + if !reads_lock { + check.locked = None; + } + ServerEvent::WorktreeChecked { req_id, id, check } + } Err(e) => ServerEvent::Error { req_id: Some(req_id), message: format!("{e:#}"), diff --git a/crates/nebula-daemon/src/worktree_check.rs b/crates/nebula-daemon/src/worktree_check.rs index ece3fd6a..78df8885 100644 --- a/crates/nebula-daemon/src/worktree_check.rs +++ b/crates/nebula-daemon/src/worktree_check.rs @@ -9,7 +9,8 @@ //! each cheap and each best effort, and each saying so when it could not //! look: which processes have their working directory inside the checkout, //! what `git status` reports there and how recently it was written, and -//! which commits HEAD has that its base lacks. +//! which commits HEAD has that its base lacks. And one thing a person or +//! tool may have said outright: that the checkout is locked, and why. use crate::git; use anyhow::{Context, Result}; @@ -81,6 +82,10 @@ async fn git_half(repo: &Path, worktree: &Path, configured_base: Option) &mut check, ); } + match git::worktree_lock(repo, worktree).await { + Ok(lock) => check.locked = lock, + Err(e) => note(e, &mut check), + } check } diff --git a/crates/nebula-tui/src/delete_check.rs b/crates/nebula-tui/src/delete_check.rs index e8c466b2..b7666ad0 100644 --- a/crates/nebula-tui/src/delete_check.rs +++ b/crates/nebula-tui/src/delete_check.rs @@ -326,6 +326,17 @@ fn bulk_lines(states: &[(String, &CheckState)], now_ms: i64) -> Vec { /// A finished check's findings, one sentence each. fn findings(check: &WorktreeCheck, now_ms: i64) -> Vec { let mut out = Vec::new(); + // First: the one finding someone stated on purpose. + match check.locked.as_deref() { + Some("") => { + out.push("locked (git worktree lock, no reason given) — deleting unlocks it".into()) + } + Some(reason) => out.push(format!( + "locked: {} — deleting unlocks it", + one_line(reason) + )), + None => {} + } if !check.processes.is_empty() { let n = check.processes.len(); out.push(format!( @@ -367,6 +378,11 @@ fn findings(check: &WorktreeCheck, now_ms: i64) -> Vec { /// A finished check's findings as a bulk delete's line lists them. fn short_findings(check: &WorktreeCheck, now_ms: i64) -> Vec { let mut out = Vec::new(); + match check.locked.as_deref() { + Some("") => out.push("locked".into()), + Some(reason) => out.push(format!("locked: {}", one_line(reason))), + None => {} + } if !check.processes.is_empty() { out.push(format!("running {}", process_names(check))); } @@ -390,6 +406,11 @@ fn short_findings(check: &WorktreeCheck, now_ms: i64) -> Vec { out } +/// A lock reason as one line of a dialog: git keeps a reason's newlines. +fn one_line(reason: &str) -> String { + reason.split_whitespace().collect::>().join(" ") +} + fn process_names(check: &WorktreeCheck) -> String { let mut names: Vec = check .processes @@ -461,6 +482,39 @@ mod tests { ); } + #[test] + fn a_locked_checkout_leads_with_its_lock_and_reason() { + let check = WorktreeCheck { + locked: Some("merge queue:\nreused by every push".into()), + changes: 1, + ..WorktreeCheck::default() + }; + let text: Vec = single_lines("probe", &done(check.clone()), 0) + .into_iter() + .inspect(|l| assert!(l.warn)) + .map(|l| l.text) + .collect(); + assert_eq!( + text, + [ + "⚠ locked: merge queue: reused by every push — deleting unlocks it", + "⚠ 1 uncommitted change", + ] + ); + assert_eq!( + short_findings(&check, 0)[0], + "locked: merge queue: reused by every push" + ); + + let bare = WorktreeCheck { + locked: Some(String::new()), + ..WorktreeCheck::default() + }; + assert!(bare.has_findings(), "a lock alone is a finding"); + let text = &single_lines("probe", &done(bare), 0)[0].text; + assert!(text.contains("no reason given"), "{text}"); + } + #[test] fn a_detached_head_warns_that_only_the_reflog_keeps_its_commits() { let check = WorktreeCheck { diff --git a/docs/configuration.md b/docs/configuration.md index 72e470f6..f4932f0d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -70,7 +70,7 @@ behaviors that change how the tree is worked; every switch there is off by defau | `feedback_sound` | string | `"Sosumi"` | Sessions | The FEEDBACK SOUND rung when a turn stops at NEEDS FEEDBACK — a permission prompt or a question — with the same values and fallbacks as `done_sound`, and a different default so red and green sound different from the next room. It never rings for the session whose pane you are locked into typing at while the terminal window has focus: that prompt is already under your hands. The one switch for the DESKTOP NOTIFICATION too: while the terminal window is in the background (from the focus reports nebula asks the terminal for — tmux needs `focus-events on`), each session that goes red is also named in a desktop notification (`osascript` on macOS, `notify-send` on Linux; never over `nebula ssh`, where the desktop is the wrong machine's; a notifier that is missing or fails is a debug line, not an error). `off` silences the sound and the notification together. | | `preset_text` | string | `"prefix"` | Sessions | PRESET TEXT: which side of the task a new AGENT PRESET's text goes — `prefix` (one box, sent before the task), `postfix` (one box, sent after it) or `prefix & postfix` (both, the form every preset had through 0.32). The PRESET EDITOR opens a new preset with the box(es) named here, and its **Text** row changes one preset — a side the row leaves out saves blank; editing a stored preset also shows any side that already holds text, so nothing saved is ever hidden. Prefix alone by default: the framing most people reach for, and one box to fill. A hand-edited `both` reads as `prefix & postfix`; anything else as the default. See [Sessions](sessions.md#agent-presets). | | `delete_empty_worktree` | bool | `false` | Sessions | DELETE EMPTIED WORKTREE: what the delete of a linked WORKTREE's last live card asks — the last session's `d`, the last terminal's close, or a `D` that takes them all. Off, the default, that card's own CONFIRM DIALOG carries the question too, before anything is deleted (`Delete agent 'x'? Its session and history go away.` then `Nothing else is left in worktree 'feature': delete it from disk too?`), with three answers: `Enter` or `y` deletes the card and then the worktree, the same forced delete the band's own `d` runs; `n` deletes the card and keeps the empty checkout; `Esc` cancels and keeps the card alive. On, the question is not asked: the dialog is the card's ordinary two-way confirm, its message saying the worktree goes with it, and `Enter` deletes both — unless archived sessions are still filed under the checkout, whose history the delete would take: those always get the three-way question, which says how many go with it. The ROOT WORKTREE is never offered either way, and archiving a session (`a`) never counts as emptying: an archived card is still filed under its checkout. With `show_all_worktrees` on, off asks nothing — the emptied worktree keeps its band — and on still deletes the worktree with its last card. See [Sessions](sessions.md#the-grid). | -| `auto_cleanup_merged` | bool | `false` | Sessions | AUTO-CLEAN MERGED WORKTREES: off by default. On, when a linked worktree's branch PR is safely detected as merged, nebula asks the DAEMON to archive idle sessions in that checkout and remove the worktree from disk with `git worktree remove` (not forced). Safety gates: never the ROOT WORKTREE, never the repository default branch, never a branch that is the base of an open PR, only a MERGED PR whose head branch matches and whose head SHA is still the checkout's HEAD, never a checkout with uncommitted or untracked files, never a checkout with a running terminal or a session mid-turn. A skipped cleanup flashes why; a cleanup flashes `cleaned up : PR # merged`. | +| `auto_cleanup_merged` | bool | `false` | Sessions | AUTO-CLEAN MERGED WORKTREES: off by default. On, when a linked worktree's branch PR is safely detected as merged, nebula asks the DAEMON to archive idle sessions in that checkout and remove the worktree from disk with `git worktree remove` (not forced). Safety gates: never the ROOT WORKTREE, never the repository default branch, never a branch that is the base of an open PR, only a MERGED PR whose head branch matches and whose head SHA is still the checkout's HEAD, never a checkout with uncommitted or untracked files, never a locked checkout (`git worktree lock`), never a checkout with a running terminal or a session mid-turn. A skipped cleanup flashes why; a cleanup flashes `cleaned up : PR # merged`. | | `show_all_worktrees` | bool | `true` | Sessions | SHOW ALL WORKTREES: every checkout of the project gets a BAND on the grid, one with nothing running in it too. It is on by default. Off, the grid is only what is running: a checkout with no session or terminal has no band. On, such a checkout is an EMPTY BAND — its rule over one line, `nothing running · p: new session · t: terminal · d: delete worktree` — which `j`/`k` walk onto, where `p`/`n`/`t` start work, and where `d` (or **Delete worktree** in its right-click menu) deletes the worktree behind its own confirm (`Delete worktree 'feature' from disk?`); the ROOT WORKTREE's band has no `d`. On also means deleting a worktree's last card never asks about the worktree, so the emptied band stays until its own `d` — unless `delete_empty_worktree` is on, which still deletes the worktree with its last card. The ARCHIVED VIEW never shows an empty band. See [Sessions](sessions.md#the-grid). | | `appearance` | string | `"auto"` | Appearance | APPEARANCE: the dark or the light palette. `auto` asks the terminal for its background color at startup (OSC 11, with `COLORFGBG` as the fallback for a terminal that doesn't answer) and draws the light palette on a light background, the dark one otherwise; `dark` and `light` force one. In the light palette every THEME keeps its hues at a weight that reads on white, the FOCUSED PANEL TINT and the card washes mix toward white instead of black, and `black_background` paints white. Anything off the list reads as `auto`. | | `theme` | string | `"default"` | Appearance | The THEME: `default`, `ocean`, `forest`, `rose`, `amber`, `lavender`, `coral`, `slate`, `sand`, `mono`. An unknown name falls back to `default`. | diff --git a/docs/keys.md b/docs/keys.md index cea64c90..214fe9b0 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -43,7 +43,7 @@ The grid isn't the only view. With a session selected: | New session picker (any row) | `?` | open Settings → Agents parked on the highlighted harness's section, where its Enabled switch and default model and effort live (`s` does the same from a pull request row's right-click menu; in the pickers it is a filter letter) | | The grid (a cloud card) | `Enter`, right-click | `Enter` — or a click on the link in the CLOUD SESSION PANEL — opens the session's page in the browser; the right-click menu offers **Open in browser** and **Send to cloud session**, which queues a message on it. No **Attach** or **Restart**: the agent runs in the cloud, not in a terminal here | | The grid (a card) | `Space` | **follow-up prompt.** A small modal opens over the grid and what you type there goes to the agent already running in the selected session as its next turn: `Enter` sends it, `Shift+Enter` / `Option+Enter` / `Ctrl+J` break a line, `Esc` closes without sending. The right-click menu's **Follow-up prompt** row does the same. Only a live local agent has one: an archived session, a Claude Cloud card (whose right-click menu queues a message instead) and a terminal have nothing to follow up on. A session whose CLI is not up is booted first and the box kept as it is, so nothing is typed into a process that is still starting — press `Enter` again once it is. Rebindable as `follow_up` | -| The grid | `r`, `a`, `u`, `d`, `A` | rename, archive, unarchive, delete, toggle archived. `Delete` and `Backspace` are bound alongside `d` and do the same thing, here and everywhere else `d` deletes something — and `a` and the row menu's Archive ask first, in the CONFIRM DIALOG `d` goes behind (`Enter` or `y` archives, `Esc` or `n` keeps the session), so typing aimed at an agent that lands on the grid archives nothing; the dialog says what makes saying yes cheap: `u` brings the session back. **Confirm on archive** (Settings → Sessions, on by default) off archives at once. In the nested layout, `d` and `a` on a thread's root that has children take the whole thread: `d` deletes every prompt and terminal nested under it, `a` archives every prompt and closes its terminals — each behind a confirm that counts and lists them, `a`'s asking even with the setting off; the root checkout's thread included, and a project left with nothing is the welcome. `d` on the last live card of a linked worktree asks about the checkout in the same confirm, before anything is deleted — `Nothing else is left in worktree 'feature': delete it from disk too?` — with three answers: `y` deletes the card and then the checkout as `d` on its band would, `n` deletes the card and keeps the empty checkout, `Esc` keeps the card alive; **Delete emptied worktree** (Settings → Sessions, off by default) skips that question and `Enter` deletes both, except when archived sessions are still filed under the checkout. With **Show all worktrees** on (Settings → Sessions, off by default) the question is never asked: the card goes alone, its checkout stays as an EMPTY BAND, and `d` on that band — a checkout with nothing running — asks to delete the worktree itself (`Delete worktree 'feature' from disk?`), as **Delete worktree** in the band's right-click menu does. Every confirm that deletes a worktree lists what the DELETE CHECK found in it — processes working there from outside its sessions, uncommitted changes and how recently they were written, commits its base lacks — and `Enter` waits for that check (up to ten seconds) before it deletes anything. `Shift+R` refreshes the pull requests (above). **A held `a` opens one dialog** and archives nothing by itself: its repeats land on the dialog, where `a` is nothing — with the confirm off, it archives one card, as `u` unarchives one. **A held `u` unarchives one card**, however long it is held: on a terminal with the kitty keyboard protocol (Ghostty, kitty, WezTerm, foot) nebula watches the key until it comes up, swallowing the repeats the terminal sends meanwhile, and the next card needs the key pressed again; a terminal without it (Terminal.app, `nebula browser`, tmux) reports a held key as one press after another, and there a long hold still walks the row | +| The grid | `r`, `a`, `u`, `d`, `A` | rename, archive, unarchive, delete, toggle archived. `Delete` and `Backspace` are bound alongside `d` and do the same thing, here and everywhere else `d` deletes something — and `a` and the row menu's Archive ask first, in the CONFIRM DIALOG `d` goes behind (`Enter` or `y` archives, `Esc` or `n` keeps the session), so typing aimed at an agent that lands on the grid archives nothing; the dialog says what makes saying yes cheap: `u` brings the session back. **Confirm on archive** (Settings → Sessions, on by default) off archives at once. In the nested layout, `d` and `a` on a thread's root that has children take the whole thread: `d` deletes every prompt and terminal nested under it, `a` archives every prompt and closes its terminals — each behind a confirm that counts and lists them, `a`'s asking even with the setting off; the root checkout's thread included, and a project left with nothing is the welcome. `d` on the last live card of a linked worktree asks about the checkout in the same confirm, before anything is deleted — `Nothing else is left in worktree 'feature': delete it from disk too?` — with three answers: `y` deletes the card and then the checkout as `d` on its band would, `n` deletes the card and keeps the empty checkout, `Esc` keeps the card alive; **Delete emptied worktree** (Settings → Sessions, off by default) skips that question and `Enter` deletes both, except when archived sessions are still filed under the checkout. With **Show all worktrees** on (Settings → Sessions, off by default) the question is never asked: the card goes alone, its checkout stays as an EMPTY BAND, and `d` on that band — a checkout with nothing running — asks to delete the worktree itself (`Delete worktree 'feature' from disk?`), as **Delete worktree** in the band's right-click menu does. Every confirm that deletes a worktree lists what the DELETE CHECK found in it — a `git worktree lock` and its reason, processes working there from outside its sessions, uncommitted changes and how recently they were written, commits its base lacks — and `Enter` waits for that check (up to ten seconds) before it deletes anything. `Shift+R` refreshes the pull requests (above). **A held `a` opens one dialog** and archives nothing by itself: its repeats land on the dialog, where `a` is nothing — with the confirm off, it archives one card, as `u` unarchives one. **A held `u` unarchives one card**, however long it is held: on a terminal with the kitty keyboard protocol (Ghostty, kitty, WezTerm, foot) nebula watches the key until it comes up, swallowing the repeats the terminal sends meanwhile, and the next card needs the key pressed again; a terminal without it (Terminal.app, `nebula browser`, tmux) reports a held key as one press after another, and there a long hold still walks the row | | The grid (a card) | `e` | agent presets: saved launch definitions (harness, model, effort, text on the side of the task the form's **Text** row names — a prefix, a postfix or both; a new preset starts on `prefix` alone unless **Preset text** in Settings → Sessions says otherwise — and whether to ask for a task). The launch lands in the checkout under the cursor — or, with `Tab` (or a click on the list's bottom `[ ] new worktree` row), in a fresh worktree of the project cut first, the row turning into a green NEW WORKTREE chip naming the branch; `Tab` again flips back. The same toggle is on the list wherever it opens: in the ISSUES MODAL it flips between the root checkout and a fresh `issue-15-` worktree, and a PR SESSION's row just names the pull request's own checkout, there being nothing to flip. `Enter` asks for an optional task and starts the agent with prefix + task + postfix as its first prompt — sent empty, the prefix and postfix go alone — or, for a preset whose **Task** row is `skip`, launches at once without asking. Type to find a preset by name — letters narrow the rows to the fuzzy matches, the cursor on the best, the query in the title (`Agent presets ⌕ rev`); `↑`/`↓` (or `Ctrl+n`/`Ctrl+p`) move, `Backspace` widens, and `Esc` clears the letters before it closes. Because letters type, the verbs are chords: `Ctrl+a` / `Ctrl+e` / `Ctrl+d` create, edit, delete (`Delete` deletes too). In the PULL REQUESTS MODAL, `Shift+Tab` on a pull request is the same picker, launching the preset as a PR SESSION on it | | The grid | `p` | quick prompt: a wrapped, multi-row task box (`Shift+Enter`, `Option+Enter` or `Ctrl+J` inserts a line and `↑`/`↓` walk the rows as they wrap, as in Claude Code's own prompt — the keys for getting around a long prompt are under *Any multi-row box* below; `Esc` closes the box, `Enter` launches) that starts a new agent in the checkout under the cursor — the worktree whose band the cursor is on — with what you typed as its first prompt — no picker. Which CLI it launches is the `Agent` row under **Quick prompt** in Settings → Agents, run with that harness's own default model and effort; the session titles itself from the prompt. For one launch only, `Tab` picks a different harness (`→` drills into its model and effort, same as the new-session picker) and `Shift+Tab` picks one of your saved agent presets — adopting its harness, model, effort and prefix/postfix wrapping; sent empty with a preset on, the box launches on the prefix and postfix alone, and a `skip`-task preset picked over an empty box launches right away. Either picker hands the box back with your text intact, on `Esc` too. The launch lands the cursor on the new session's card as it goes up in its band, the grid scrolling to it (shown in the pane, the keys still on the grid) — turn off `Follow new` under **Quick prompt** in Settings → Agents to stay out of its way instead, the cursor and the pane staying on the card you were on and the footer naming the branch it went to, or turn on `Focus` there to drop straight into its terminal. The box launches in the checkout under the cursor by default — the worktree whose band the cursor is on, open or collapsed, the new session starting beside the ones already running there (more work in one session's own conversation is its follow-up, `Space`) — and on the project's root branch once `Esc` has let the aim go; turn on `New worktree` under **Quick prompt** in Settings → Agents to have every new box cut a fresh worktree first — a random branch off the fetched `origin/HEAD`, or the **Worktree base branch** setting — its band going up beside the rest, the cursor moving onto it unless `Follow new` is off. `Ctrl+N` inside the box flips between the two for this one launch only — the next box starts from the setting again: the row under the title says which — a quiet `worktree: main` with `[ ] new worktree` unticked, or a filled `NEW WORKTREE` chip naming the branch to be cut with `[✓] new worktree` ticked and the whole frame turned green — and the text and caret stay put across the flip. `Enter` on the box empty starts the session all the same — the harness, model and effort the title names, with no first prompt: you type it in the CLI (a Claude Cloud box, which needs its task, closes instead). Closing the box does not throw the prompt away: `Esc`, a click outside it and `^q` park what you typed, and the next `p` opens on it — aimed the same way, the box comes back whole, the `Tab` harness or `Shift+Tab` preset you had picked included; aimed somewhere else, your text comes back into that box's own launch. One draft is kept, in memory only, and the box that takes it back is the last place it can be lost from: clear it there and close it and it is gone | | The grid | `Shift+D` | delete every session listed for the checkout under the cursor (the confirm lists the casualties) | diff --git a/docs/sessions.md b/docs/sessions.md index 3c1a6cdb..0b7af652 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -320,7 +320,9 @@ is one key from there: `p` opens the QUICK PROMPT, focused, so the first thing y worktree with its last card. "Nothing running" counts nebula's own sessions only: an agent whose session lives in another checkout can be working in this one through absolute paths. So every confirm that would take a worktree off disk first runs the **DELETE CHECK** and lists - what it finds under the question — processes whose working directory is inside the checkout + what it finds under the question — first, when the checkout is locked (`git worktree lock`, + by hand, by an agent or by a tool that reuses it), the lock and the reason it was locked with, + since deleting unlocks it; then processes whose working directory is inside the checkout (other than its own sessions, which the confirm already counts), uncommitted changes and how recently the newest was written, and commits its base lacks (or, on a detached HEAD, commits no branch holds). `Enter` waits for the check — up to ten seconds — so the delete is never @@ -372,8 +374,8 @@ is one key from there: `p` opens the QUICK PROMPT, focused, so the first thing y Turn on **Auto-clean merged worktrees** in Settings → Sessions (`auto_cleanup_merged`) to have nebula remove linked checkouts after their PR lands. It is off by default and guarded: the ROOT WORKTREE, the repo's default branch, branches used as the base of open PRs, dirty checkouts, - checkouts with untracked files, moved heads, running terminals and sessions mid-turn are skipped - with a short notice instead. + checkouts with untracked files, locked checkouts, moved heads, running terminals and sessions + mid-turn are skipped with a short notice instead. - **Walking it.** `j` and `k` walk the bands — the rule of the band under the cursor takes the accent and its branch goes bold, its remembered card (the session it was last left on, else its first) is raised out of the row — a heavy accent frame (`┏━┓`, a weight no status frame takes) over From c93eff1a60a6d8124615cf3c1fb52b2a8f67a405 Mon Sep 17 00:00:00 2001 From: Drew Bradford <16455249+DrewBradfordXYZ@users.noreply.github.com> Date: Sat, 10 Oct 2026 07:22:49 -0400 Subject: [PATCH 2/6] A worktree's band says whether deleting it loses anything, and its delete can take the branch too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 · 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 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//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`) and the DAEMON deletes with `git update-ref --no-deref -d refs/heads/ `, 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) --- crates/nebula-core/src/protocol.rs | 292 +++++- crates/nebula-daemon/src/git.rs | 968 +++++++++++++++++- crates/nebula-daemon/src/registry.rs | 189 +++- crates/nebula-daemon/src/registry/projects.rs | 76 +- crates/nebula-daemon/src/server.rs | 39 +- crates/nebula-daemon/src/worktree_check.rs | 460 ++++++++- crates/nebula-tui/src/app.rs | 3 + crates/nebula-tui/src/band_safety.rs | 468 +++++++++ crates/nebula-tui/src/config.rs | 48 + crates/nebula-tui/src/delete_check.rs | 493 ++++++++- crates/nebula-tui/src/event_loop/commands.rs | 34 +- crates/nebula-tui/src/event_loop/launcher.rs | 131 +++ crates/nebula-tui/src/event_loop/loop_run.rs | 2 + crates/nebula-tui/src/event_loop/overlays.rs | 38 + .../src/event_loop/server_events.rs | 10 +- crates/nebula-tui/src/event_loop/tests/mod.rs | 270 ++++- crates/nebula-tui/src/lib.rs | 1 + crates/nebula-tui/src/ui/launcher_view.rs | 59 +- .../nebula-tui/src/ui/launcher_view/nested.rs | 27 +- crates/nebula-tui/src/ui/overlay.rs | 39 +- crates/nebula/tests/e2e_pty.rs | 20 +- docs/configuration.md | 1 + docs/keys.md | 2 +- docs/sessions.md | 35 +- 24 files changed, 3588 insertions(+), 117 deletions(-) create mode 100644 crates/nebula-tui/src/band_safety.rs diff --git a/crates/nebula-core/src/protocol.rs b/crates/nebula-core/src/protocol.rs index 7caf5b2f..7a22ead0 100644 --- a/crates/nebula-core/src/protocol.rs +++ b/crates/nebula-core/src/protocol.rs @@ -10,7 +10,7 @@ use std::path::PathBuf; /// /// Bump on every protocol change. Additive changes keep /// [`MIN_COMPATIBLE_PROTOCOL`] where it is; breaking changes bump both. -pub const PROTOCOL_VERSION: u32 = 53; +pub const PROTOCOL_VERSION: u32 = 54; /// Oldest IPC protocol this build can safely talk to. /// @@ -30,6 +30,13 @@ pub const CHECK_WORKTREE_PROTOCOL: u32 = 51; /// byte for byte what it always was. pub const WORKTREE_LOCK_PROTOCOL: u32 = 53; +/// First protocol whose daemon says where a checkout's branch stands +/// (`WorktreeCheck::landing`), answers a `git_only` `CheckWorktree` for a +/// band, and deletes a branch with its worktree (`DeleteWorktree`'s +/// `delete_branch`). An older daemon is sent neither field, and its +/// confirm and bands read as they did. +pub const BRANCH_LANDING_PROTOCOL: u32 = 54; + pub fn protocol_ranges_overlap( local_min: u32, local_current: u32, @@ -133,15 +140,34 @@ pub enum ClientRequest { req_id: u64, id: WorktreeId, force: bool, + /// Delete this branch too, once the checkout is gone — the branch + /// and tip the confirm judged (`WorktreeLanding::branch`), deleted + /// only if its tip is still that commit (an atomic compare-and- + /// delete), so a commit made while the confirm was open keeps it. + /// Never the base, origin's default or the root's branch, one + /// another checkout has out, or a branch the checkout is no longer + /// on; each keeps the branch with a warning. None keeps the + /// branch, as every older client's delete did. Omitted from the + /// frame when None, so an older daemon decodes what it always got; + /// sent only to one at [`BRANCH_LANDING_PROTOCOL`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + delete_branch: Option, }, /// What deleting this worktree would interrupt or lose — the DELETE /// CHECK a confirm shows before the checkout leaves the disk. Answered /// by `ServerEvent::WorktreeChecked` with the same req_id (not an Ack), - /// or an `Error`. Reads only: nothing is touched. Gated on - /// [`CHECK_WORKTREE_PROTOCOL`]. + /// or an `Error`. Reads only: no ref, checkout or file moves (the + /// squash-merge test may write a merged tree's objects to the store). + /// Gated on [`CHECK_WORKTREE_PROTOCOL`]. CheckWorktree { req_id: u64, id: WorktreeId, + /// The git half alone, no process sweep: what an idle band's line + /// shows (the sweep stays with the confirm). Omitted from the + /// frame when false; sent true only to a daemon at + /// [`BRANCH_LANDING_PROTOCOL`]. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + git_only: bool, }, /// Opt-in cleanup for a linked worktree whose PR was detected as merged. /// The daemon re-checks the safety invariants before touching disk: @@ -626,7 +652,7 @@ pub struct MetricsSnapshot { /// it just now (`ClientRequest::CheckWorktree`). Every part is best effort /// and says so when it could not look: a check that failed is not a clean /// bill of health. -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] pub struct WorktreeCheck { /// Processes whose working directory is inside the checkout — a build, /// an editor, a shell, the tool calls of an agent whose own session @@ -642,7 +668,10 @@ pub struct WorktreeCheck { /// worked on" when it is a moment ago. None with no changes. pub newest_change_ms: Option, /// Commits HEAD has that `base` lacks. The branch keeps them after the - /// checkout goes, unless `detached`. + /// checkout goes, unless `detached`. Counted by content since + /// [`BRANCH_LANDING_PROTOCOL`] (`git cherry`, and a squash merge's + /// whole diff), so a branch landed by squash, rebase or cherry-pick + /// counts none; by ancestry before it. pub ahead: u32, /// What `ahead` was counted against (`origin/main`); None when nothing /// could be found to compare with. @@ -656,11 +685,100 @@ pub struct WorktreeCheck { /// locked with, empty when none was given. Whoever locked it — a /// person, an agent, a CI tool — asked git to keep it, and git's own /// `worktree remove` refuses it; a delete unlocks it first, so the - /// confirm says so. Kept last and left off the wire when None: an - /// older client decodes the shorter check it always did, and the - /// daemon clears it for one (`WORKTREE_LOCK_PROTOCOL`). - #[serde(default, skip_serializing_if = "Option::is_none")] + /// confirm says so. Sent to a client at [`WORKTREE_LOCK_PROTOCOL`] + /// or later; see the `Serialize` impl for how the trailing fields + /// stay readable to older ones. + #[serde(default)] pub locked: Option, + /// Where the branch stands and what else only this checkout holds. + /// Sent to a client at [`BRANCH_LANDING_PROTOCOL`] or later. + #[serde(default)] + pub landing: Option, +} + +/// The frame is the fields in order, as a derive would write them, except +/// that the optional fields later protocols added (`locked`, `landing`) +/// are cut from the end while they are None. A client older than a field +/// stops reading before it, so the daemon clears what such a client cannot +/// decode and the frame comes out the length it knows. Only a None run at +/// the END goes: one in the middle keeps its place, or the fields after it +/// would be read into the wrong slots. +impl Serialize for WorktreeCheck { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeStruct; + let trailing = if self.landing.is_some() { + 2 + } else { + usize::from(self.locked.is_some()) + }; + let mut s = serializer.serialize_struct("WorktreeCheck", 8 + trailing)?; + s.serialize_field("processes", &self.processes)?; + s.serialize_field("process_error", &self.process_error)?; + s.serialize_field("changes", &self.changes)?; + s.serialize_field("newest_change_ms", &self.newest_change_ms)?; + s.serialize_field("ahead", &self.ahead)?; + s.serialize_field("base", &self.base)?; + s.serialize_field("detached", &self.detached)?; + s.serialize_field("git_error", &self.git_error)?; + if trailing >= 1 { + s.serialize_field("locked", &self.locked)?; + } + if trailing >= 2 { + s.serialize_field("landing", &self.landing)?; + } + s.end() + } +} + +/// The part of a [`WorktreeCheck`] a band's line and a confirm's branch +/// choice read ([`BRANCH_LANDING_PROTOCOL`]). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorktreeLanding { + /// The checkout is on a branch every commit of which is on its base + /// by content (`ahead` is 0), and that branch is not the base, the + /// default branch or the root's: deleting it loses nothing. + pub branch_landed: bool, + /// How many of `changes` are untracked files (`??`); the rest are + /// modified, added, deleted or submodules. + pub untracked: u32, + /// Commits checked out in the checkout's submodules that no remote + /// holds and the root checkout's submodules do not have: they live + /// only in this worktree's submodule store, which the delete takes. + pub submodule_unpushed: u32, + /// The branch the checkout is on and its tip, as the check found + /// them; None on a detached HEAD. What a confirm hands back in + /// `DeleteWorktree::delete_branch`, so the delete takes exactly what + /// was judged. + pub branch: Option, + /// Gitignored files and folders in the checkout that are not build + /// output (`.env`, `.claude/settings.local.json`): `git worktree + /// remove --force` deletes them, and `git status` never shows them. + /// The first few by name; read by a confirm's check only, never a + /// band's. + pub ignored_files: Vec, + /// How many there are in all, `ignored_files` being the first few. + pub ignored_total: u32, + /// When `git_error` is a submodule's — git could not read its store, + /// as in the middle of a `git submodule update` — that submodule's + /// path in the checkout (or its name), so a band can say which. None + /// for any other failure. + pub failed_submodule: Option, + /// A git operation under way in the checkout (`submodule update`, + /// `rebase`): its state is in flux, so a band says so rather than + /// reading it, and a confirm keeps the branch whatever it found. + pub git_op: Option, + /// `git_op` is a running git process (it will end on its own), not + /// one paused mid-way in the checkout's state (a stopped rebase). + pub git_op_live: bool, +} + +/// A local branch and the commit its tip was at. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct BranchTip { + /// The short name (`feat`, not `refs/heads/feat`). + pub name: String, + /// The full object id of its tip. + pub oid: String, } /// One process `WorktreeCheck` found working inside a checkout. @@ -681,6 +799,25 @@ impl WorktreeCheck { || self.ahead > 0 || self.git_error.is_some() || self.locked.is_some() + || self.submodule_unpushed() > 0 + || self.ignored_total() > 0 + } + + /// The branch and tip the check judged, when it said. + pub fn branch_tip(&self) -> Option<&BranchTip> { + self.landing.as_ref()?.branch.as_ref() + } + + /// Submodule commits the delete would take with it; 0 from a daemon + /// too old to look. + pub fn submodule_unpushed(&self) -> u32 { + self.landing.as_ref().map_or(0, |l| l.submodule_unpushed) + } + + /// Gitignored files a delete takes too that are not build output; 0 + /// from a band's check, which does not look. + pub fn ignored_total(&self) -> u32 { + self.landing.as_ref().map_or(0, |l| l.ignored_total) } } @@ -873,6 +1010,10 @@ mod tests { #[test] fn protocol_compatibility_accepts_current_and_additive_peers() { + const { + assert!(BRANCH_LANDING_PROTOCOL > CHECK_WORKTREE_PROTOCOL); + assert!(BRANCH_LANDING_PROTOCOL <= PROTOCOL_VERSION); + } assert!(protocol_compatible_with(PROTOCOL_VERSION)); assert!(protocol_compatible_with(PROTOCOL_VERSION + 1)); assert!(!protocol_compatible_with(MIN_COMPATIBLE_PROTOCOL - 1)); @@ -929,4 +1070,137 @@ mod tests { rmp_serde::from_slice(&rmp_serde::to_vec(&locked).unwrap()).unwrap(); assert_eq!(back, locked); } + + /// The 51 shapes of the requests and the check that grew trailing + /// fields at [`BRANCH_LANDING_PROTOCOL`] — what an older peer encodes + /// and decodes. + mod v51 { + use crate::ids::WorktreeId; + use serde::{Deserialize, Serialize}; + + #[derive(Debug, PartialEq, Serialize, Deserialize)] + pub struct DeleteWorktree { + pub req_id: u64, + pub id: WorktreeId, + pub force: bool, + } + + #[derive(Debug, PartialEq, Serialize, Deserialize)] + pub struct CheckWorktree { + pub req_id: u64, + pub id: WorktreeId, + } + + #[derive(Debug, Default, PartialEq, Serialize, Deserialize)] + pub struct Check { + pub processes: Vec, + pub process_error: Option, + pub changes: u32, + pub newest_change_ms: Option, + pub ahead: u32, + pub base: Option, + pub detached: bool, + pub git_error: Option, + } + } + + fn fields_of(v: &T) -> Vec { + rmp_serde::to_vec(v).unwrap() + } + + /// A request as a 51 peer would decode its variant: the variant's + /// name, and its body read as the 51 shape `T`. + fn variant_body(req: &ClientRequest) -> (String, T) { + let map: std::collections::HashMap = + rmp_serde::from_slice(&fields_of(req)).unwrap(); + map.into_iter().next().unwrap() + } + + /// A request or check that leaves its new field at its default is the + /// byte-for-byte frame a 51 peer reads; a 51 peer's frame decodes + /// with the new fields at their defaults. + #[test] + fn branch_landing_fields_are_invisible_to_an_older_peer_when_unset() { + let id = WorktreeId("w".into()); + let new = ClientRequest::DeleteWorktree { + req_id: 1, + id: id.clone(), + force: true, + delete_branch: None, + }; + let (name, old): (String, v51::DeleteWorktree) = variant_body(&new); + assert_eq!( + old, + v51::DeleteWorktree { + req_id: 1, + id: id.clone(), + force: true, + } + ); + let back: ClientRequest = rmp_serde::from_slice(&fields_of( + &[(name, old)] + .into_iter() + .collect::>(), + )) + .unwrap(); + assert!(matches!( + back, + ClientRequest::DeleteWorktree { + delete_branch: None, + .. + } + )); + let new = ClientRequest::CheckWorktree { + req_id: 2, + id: id.clone(), + git_only: false, + }; + let (_, old): (String, v51::CheckWorktree) = variant_body(&new); + assert_eq!(old, v51::CheckWorktree { req_id: 2, id }); + + let check = WorktreeCheck { + changes: 2, + ahead: 1, + base: Some("origin/main".into()), + ..WorktreeCheck::default() + }; + let old: v51::Check = rmp_serde::from_slice(&fields_of(&check)).unwrap(); + assert_eq!((old.changes, old.ahead), (2, 1)); + let back: WorktreeCheck = rmp_serde::from_slice(&fields_of(&old)).unwrap(); + assert_eq!(back, check, "a 51 daemon's check reads with no landing"); + + let landed = WorktreeCheck { + landing: Some(WorktreeLanding { + branch_landed: true, + untracked: 1, + submodule_unpushed: 6, + branch: Some(BranchTip { + name: "feat".into(), + oid: "0123".into(), + }), + ignored_files: vec![".env".into()], + ignored_total: 1, + failed_submodule: None, + git_op: Some("rebase".into()), + git_op_live: false, + }), + ..check + }; + let back: WorktreeCheck = rmp_serde::from_slice(&fields_of(&landed)).unwrap(); + assert_eq!(back, landed); + assert_eq!(landed.locked, None); + // A lock and a landing together, and a 53 client's cut of it. + let both = WorktreeCheck { + locked: Some("kept".into()), + ..landed.clone() + }; + let back: WorktreeCheck = rmp_serde::from_slice(&fields_of(&both)).unwrap(); + assert_eq!(back, both); + let lock_only = WorktreeCheck { + landing: None, + ..both + }; + let back: WorktreeCheck = rmp_serde::from_slice(&fields_of(&lock_only)).unwrap(); + assert_eq!(back, lock_only); + } } diff --git a/crates/nebula-daemon/src/git.rs b/crates/nebula-daemon/src/git.rs index b172a3c1..65e9a602 100644 --- a/crates/nebula-daemon/src/git.rs +++ b/crates/nebula-daemon/src/git.rs @@ -2,7 +2,8 @@ //! libgit2's worktree support lags git's, these are rare user-initiated ops, //! and git's stderr is the best error message we could show. -use anyhow::{anyhow, bail, Result}; +use anyhow::{anyhow, bail, Context, Result}; +use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::time::Duration; use tokio::process::Command; @@ -45,6 +46,51 @@ async fn git(repo: &Path, args: &[&str]) -> Result { Ok(String::from_utf8_lossy(&output.stdout).into_owned()) } +/// [`git`] for a background read that must not take the index lock: a +/// `git status` that refreshes the index writes it back, racing whatever +/// the user or an agent runs in that checkout at the same moment +/// (`GIT_OPTIONAL_LOCKS=0`, as editors and prompts set it). +async fn git_read(repo: &Path, args: &[&str]) -> Result { + let output = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .env("GIT_OPTIONAL_LOCKS", "0") + .output() + .await + .map_err(spawn_err)?; + if !output.status.success() { + bail!("{}", String::from_utf8_lossy(&output.stderr).trim()); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +/// [`git_read`] with `input` on its stdin. +async fn git_read_input(repo: &Path, args: &[&str], input: &str) -> Result { + use tokio::io::AsyncWriteExt; + let mut child = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .env("GIT_OPTIONAL_LOCKS", "0") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(spawn_err)?; + let mut stdin = child.stdin.take().context("git's stdin")?; + let input = input.to_string(); + let writer = tokio::spawn(async move { + let _ = stdin.write_all(input.as_bytes()).await; + }); + let output = child.wait_with_output().await?; + let _ = writer.await; + if !output.status.success() { + bail!("{}", String::from_utf8_lossy(&output.stderr).trim()); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + /// `git init` an existing directory. pub async fn init(path: &Path) -> Result<()> { git(path, &["init"]).await?; @@ -679,12 +725,21 @@ pub async fn status_porcelain(repo: &Path) -> Result { .await } +/// One path `git status` reports in a checkout. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChangedPath { + /// Relative to the checkout. + pub path: PathBuf, + /// A file git does not track (`??`), rather than an edit to one it does. + pub untracked: bool, +} + /// The paths `git status` reports in a checkout — uncommitted edits, /// untracked files, and submodules with changes or new commits of their /// own — relative to it. NUL-separated porcelain, so a name with a space /// or a newline survives; a rename reports its new name only. -pub async fn changed_paths(worktree: &Path) -> Result> { - let out = git( +pub async fn changed_paths(worktree: &Path) -> Result> { + let out = git_read( worktree, &["status", "--porcelain=v1", "-z", "--untracked-files=normal"], ) @@ -693,14 +748,17 @@ pub async fn changed_paths(worktree: &Path) -> Result> { } /// Pure core of [`changed_paths`]. -fn parse_status_z(out: &str) -> Vec { +fn parse_status_z(out: &str) -> Vec { let mut paths = Vec::new(); let mut entries = out.split('\0').filter(|e| !e.is_empty()); while let Some(entry) = entries.next() { let Some((xy, path)) = entry.split_at_checked(3) else { continue; }; - paths.push(PathBuf::from(path)); + paths.push(ChangedPath { + path: PathBuf::from(path), + untracked: xy.starts_with("??"), + }); // A rename or copy is followed by the name it came from. if xy.starts_with(['R', 'C']) { entries.next(); @@ -741,10 +799,452 @@ pub async fn comparison_base(repo: &Path, configured: Option<&str>) -> Option Result { - let range = format!("{base}..HEAD"); - count(git(worktree, &["rev-list", "--count", &range]).await?) +/// How many of `head`'s commits have not landed on `base`, counted by +/// content, not ancestry: a branch landed by a squash merge, a +/// rebase-and-merge or a cherry-pick has commits `base..head` still lists +/// forever, though nothing in them is missing from `base`. `git cherry` +/// drops each commit whose patch `base` already has (patch-id equality); +/// when some remain, one more test catches the squash merge, whose single +/// commit matches none of them: merging `head` into `base` would change +/// nothing (`git merge-tree --write-tree` gives `base`'s own tree). That +/// test runs only when `cherry` found something, and any trouble with it — +/// a conflict, a git too old for `--write-tree` — counts the commits as +/// unlanded, never as landed. `dir` is any checkout of the repo. The +/// merge writes its tree's objects to the store, as any merge would; it +/// moves no ref and touches no checkout. +pub async fn unlanded_commits(dir: &Path, base: &str, head: &str) -> Result { + let out = git(dir, &["cherry", base, head]).await?; + let unlanded = out.lines().filter(|l| l.starts_with('+')).count(); + if unlanded == 0 || merges_to_nothing(dir, base, head).await { + return Ok(0); + } + Ok(u32::try_from(unlanded).unwrap_or(u32::MAX)) +} + +/// Would merging `head` into `base` leave `base`'s tree exactly as it is? +async fn merges_to_nothing(dir: &Path, base: &str, head: &str) -> bool { + let Ok(merged) = git(dir, &["merge-tree", "--write-tree", base, head]).await else { + return false; + }; + let base_tree = format!("{base}^{{tree}}"); + let Ok(tree) = git(dir, &["rev-parse", "--verify", &base_tree]).await else { + return false; + }; + let merged = merged.lines().next().unwrap_or("").trim(); + !merged.is_empty() && merged == tree.trim() +} + +/// The branch a checkout is on; None on a detached HEAD, or when git +/// cannot say. +pub async fn current_branch(worktree: &Path) -> Option { + git(worktree, &["symbolic-ref", "-q", "--short", "HEAD"]) + .await + .ok() + .map(|out| out.trim().to_string()) + .filter(|branch| !branch.is_empty()) +} + +/// Is `branch` one a delete must never take with a worktree: the base its +/// commits are measured against (or the local branch of the same name), +/// origin's default branch, or the branch the ROOT WORKTREE has out? +pub async fn protected_branch(repo: &Path, branch: &str, base: Option<&str>) -> bool { + base.is_some_and(|base| base == branch || base.strip_prefix("origin/") == Some(branch)) + || default_branch_name(repo).await.as_deref() == Some(branch) + || current_branch(repo).await.as_deref() == Some(branch) +} + +/// The branch a checkout is on and the commit at its tip; None on a +/// detached HEAD, or when git cannot say. +pub async fn branch_tip(worktree: &Path) -> Option { + let name = current_branch(worktree).await?; + let full = format!("refs/heads/{name}"); + let oid = git(worktree, &["rev-parse", "--verify", "--quiet", &full]) + .await + .ok()?; + Some(nebula_core::BranchTip { + name, + oid: oid.trim().to_string(), + }) +} + +/// Delete local branch `tip.name` only while its tip is still `tip.oid`: +/// `git update-ref -d` compares and deletes in one step, so a commit that +/// lands on the branch between the check and the delete keeps it. Unlike +/// `git branch -d`, it does not refuse a branch some checkout has out — +/// callers check that first. The branch's config section goes with it, +/// as `git branch -D` would take it. `--no-deref`: a branch that is a +/// symbolic ref (`alias -> main`) goes itself, never the branch it +/// points at. +pub async fn delete_branch_at(repo: &Path, tip: &nebula_core::BranchTip) -> Result<()> { + let full = format!("refs/heads/{}", tip.name); + git(repo, &["update-ref", "--no-deref", "-d", &full, &tip.oid]) + .await + .context("its tip moved since the confirm")?; + let section = format!("branch.{}", tip.name); + let _ = git(repo, &["config", "--remove-section", §ion]).await; + Ok(()) +} + +/// How many commits in `worktree`'s submodule stores nothing else keeps: +/// commits reachable from a store's HEAD, local branches, tags or stash +/// that no remote-tracking ref of it holds and the ROOT WORKTREE's store +/// for the same submodule (`root`'s) lacks too. A linked worktree's +/// submodules live in its own git dir (`.git/worktrees//modules/…`), +/// which `git worktree remove` deletes — so a superproject commit can pin +/// a submodule commit that exists nowhere else. The stores counted are +/// the ones the delete takes: every store under the worktree's own git +/// dir — a submodule deinitialised there, or absent at this commit, keeps +/// its store, commits and all — and the store of every submodule checked +/// out in it (nested ones too), when that lives inside the worktree or its +/// git dir. Never the main repository's `.git/modules`, which a worktree +/// delete leaves alone. The stores are asked [`SUBMODULE_JOBS`] at a time: +/// a repo with two dozen submodules is most of what a confirm waits for. +pub async fn submodule_unpushed(worktree: &Path, root: &Path) -> Result { + let own_git = PathBuf::from( + git_read(worktree, &["rev-parse", "--absolute-git-dir"]) + .await? + .trim(), + ); + let own_git = std::fs::canonicalize(&own_git).unwrap_or(own_git); + let checkout = std::fs::canonicalize(worktree).unwrap_or_else(|_| worktree.to_path_buf()); + // Each store once, by the name a band names it by when it fails: its + // path in the checkout when one is checked out there, else its name + // under `modules/`. + let mut stores: std::collections::BTreeMap = Default::default(); + let modules = own_git.join("modules"); + let mut found = Vec::new(); + collect_stores(&modules, &mut found)?; + for store in found { + let name = store + .strip_prefix(&modules) + .map(module_name) + .unwrap_or_else(|_| store.display().to_string()); + stores.insert(store, name); + } + if worktree.join(".gitmodules").exists() { + let mut dirs: Vec = checked_out_submodules(worktree) + .await? + .into_iter() + .collect(); + while let Some(rel) = dirs.pop() { + let dir = worktree.join(&rel); + let label = rel.display().to_string(); + if let Some(store) = store_of(&dir) { + stores.insert(store, label.clone()); + } + if dir.join(".gitmodules").exists() { + let nested = checked_out_submodules(&dir) + .await + .map_err(|e| SubmoduleError::wrap(&label, e))?; + dirs.extend(nested.into_iter().map(|p| rel.join(p))); + } + } + } + // Only what the delete takes. + stores.retain(|s, _| s.starts_with(&own_git) || s.starts_with(&checkout)); + let mut stores: Vec<(PathBuf, String)> = stores.into_iter().collect(); + let root_git = if root == worktree { + None + } else { + let out = git_read(root, &["rev-parse", "--absolute-git-dir"]).await?; + let dir = PathBuf::from(out.trim()); + Some(std::fs::canonicalize(&dir).unwrap_or(dir)) + }; + let mut jobs = tokio::task::JoinSet::new(); + let mut total: u32 = 0; + loop { + while jobs.len() < SUBMODULE_JOBS { + let Some((store, label)) = stores.pop() else { + break; + }; + let kept = root_git + .as_deref() + .and_then(|root_git| kept_store(&store, &own_git, &checkout, root, root_git)); + let cwd = checkout.clone(); + jobs.spawn(async move { + lost_in_store(cwd, store, kept) + .await + .map_err(|e| SubmoduleError::wrap(&label, e)) + }); + } + let Some(done) = jobs.join_next().await else { + break; + }; + let lost = done.context("submodule check")??; + total = total.saturating_add(lost); + } + Ok(total) +} + +/// A store's name under a git dir's `modules/` (`lib/modules/inner` is +/// `lib/inner`): what a band calls a submodule that is not checked out. +fn module_name(rel: &Path) -> String { + let mut parts = Vec::new(); + let mut skip = false; + for part in rel.components() { + let part = part.as_os_str().to_string_lossy(); + // Every other `modules` level is a store's own `modules/`. + if skip && part == "modules" { + skip = false; + continue; + } + skip = true; + parts.push(part.into_owned()); + } + parts.join("/") +} + +/// [`submodule_unpushed`] could not read one submodule: which one, by its +/// path in the checkout (or its name), for a band to say — a checkout in +/// the middle of `git submodule update` reads this way for a moment. +#[derive(Debug)] +pub struct SubmoduleError { + pub path: String, + message: String, +} + +impl SubmoduleError { + fn wrap(path: &str, e: anyhow::Error) -> anyhow::Error { + anyhow::Error::new(Self { + path: path.to_string(), + message: format!("{e:#}"), + }) + } +} + +impl std::fmt::Display for SubmoduleError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "submodule {}: {}", self.path, self.message) + } +} + +impl std::error::Error for SubmoduleError {} + +/// Stores asked about at once by [`submodule_unpushed`]. +const SUBMODULE_JOBS: usize = 8; + +/// Every submodule store under `modules` (a git dir's `modules/`), nested +/// ones too: a directory with a `HEAD` and an `objects/` is a store, and +/// its own `modules/` holds its submodules'; any other directory is a +/// level of a submodule name with a `/` in it. Symbolic links are not +/// followed: a store is a real directory, and a link could loop. A +/// `modules/` that is not there holds nothing; one that cannot be read is +/// an error, never "nothing to lose". +fn collect_stores(modules: &Path, stores: &mut Vec) -> Result<()> { + let entries = match std::fs::read_dir(modules) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(e).with_context(|| format!("read {}", modules.display())), + }; + for entry in entries { + let entry = entry.with_context(|| format!("read {}", modules.display()))?; + if !entry.file_type()?.is_dir() { + continue; + } + let dir = entry.path(); + if dir.join("HEAD").is_file() && dir.join("objects").is_dir() { + collect_stores(&dir.join("modules"), stores)?; + stores.push(std::fs::canonicalize(&dir).unwrap_or(dir)); + } else { + collect_stores(&dir, stores)?; + } + } + Ok(()) +} + +/// The store of the submodule checked out at `dir`: its `.git` directory, +/// or where its `.git` file (`gitdir: …`) points. +pub(crate) fn store_of(dir: &Path) -> Option { + let dot_git = dir.join(".git"); + let store = if dot_git.is_dir() { + dot_git + } else { + let text = std::fs::read_to_string(&dot_git).ok()?; + dir.join(text.trim().strip_prefix("gitdir:")?.trim()) + }; + std::fs::canonicalize(store).ok() +} + +/// The ROOT WORKTREE's store for the submodule whose store in the worktree +/// is `store`: the same place under the root's git dir, or — for a store +/// inside the worktree's own folder — the store of the root's checkout at +/// the same path. None when the root has none. +fn kept_store( + store: &Path, + own_git: &Path, + checkout: &Path, + root: &Path, + root_git: &Path, +) -> Option { + if let Ok(rel) = store.strip_prefix(own_git) { + let kept = root_git.join(rel); + return kept.join("HEAD").is_file().then_some(kept); + } + let rel = store.strip_prefix(checkout).ok()?; + store_of(&root.join(rel.parent()?)) +} + +/// What one store holds that no remote does and `kept` (the root's store +/// for the same submodule) lacks: its HEAD, its local branches and tags (a +/// commit on a branch, then HEAD detached back to the pin, is still lost +/// with the store) and its stash. `--glob` matches nothing when there is +/// no stash; the bracket keeps git from reading it as `refs/stash/*`. +async fn lost_in_store(cwd: PathBuf, store: PathBuf, kept: Option) -> Result { + let git_dir = format!("--git-dir={}", store.display()); + let refs = [ + "--branches", + "--tags", + "--glob=refs/stas[h]", + "--not", + "--remotes", + ]; + let with_head: Vec<&str> = [git_dir.as_str(), "rev-list", "HEAD"] + .into_iter() + .chain(refs) + .collect(); + let lost = match git_read(&cwd, &with_head).await { + Ok(lost) => lost, + // A store whose HEAD names a branch with no commit yet — a clone + // `git submodule update` is still writing — holds nothing at HEAD + // to lose: ask again about its refs alone. Any other failure, and + // a failure of that, stands: a store git cannot read is never + // read as empty. + Err(e) if !head_resolves(&cwd, &git_dir).await => { + let refs_only: Vec<&str> = [git_dir.as_str(), "rev-list"] + .into_iter() + .chain(refs) + .collect(); + git_read(&cwd, &refs_only).await.map_err(|_| e)? + } + Err(e) => return Err(e), + }; + let n = if lost.trim().is_empty() { + 0 + } else if let Some(kept) = kept { + unkept_in(&cwd, &kept, &lost).await? + } else { + lost.lines().count() + }; + Ok(u32::try_from(n).unwrap_or(u32::MAX)) +} + +/// Does the store at `git_dir` (a `--git-dir=` argument) have a HEAD +/// that names a commit? No on an unborn branch, as in a clone not yet +/// fetched. +async fn head_resolves(cwd: &Path, git_dir: &str) -> bool { + git_read( + cwd, + &[git_dir, "rev-parse", "--verify", "--quiet", "HEAD^{commit}"], + ) + .await + .is_ok() +} + +/// How many of `commits` (one id per line) the root's store `kept` does +/// not keep: commits it lacks, and commits it has but no ref of it +/// reaches — a commit fetched by id and left dangling goes at its next +/// gc, so it is no keeper. +async fn unkept_in(cwd: &Path, kept: &Path, commits: &str) -> Result { + let kept_dir = format!("--git-dir={}", kept.display()); + let answers = git_read_input(cwd, &[&kept_dir, "cat-file", "--batch-check"], commits).await?; + let mut missing = 0; + let mut present = String::new(); + for line in answers.lines() { + match line.split_once(' ') { + Some((_, "missing")) | None => missing += 1, + Some((id, _)) => { + present.push_str(id); + present.push('\n'); + } + } + } + if present.is_empty() { + return Ok(missing); + } + // Of those it has, the ones none of its refs reach. The walk also + // lists their unreached ancestors; only the asked-about ones count. + let unreached = git_read_input( + cwd, + &[&kept_dir, "rev-list", "--stdin", "--not", "--all"], + &present, + ) + .await?; + let asked: HashSet<&str> = present.lines().collect(); + let dangling = unreached.lines().filter(|id| asked.contains(id)).count(); + Ok(missing + dangling) +} + +/// The submodules checked out in `dir`, relative to it: the gitlinks its +/// index records whose folder has a `.git` of its own — read straight off +/// `git ls-files --stage`, much quicker than `git submodule status`. +async fn checked_out_submodules(dir: &Path) -> Result> { + let out = git_read(dir, &["ls-files", "-z", "--stage"]).await?; + Ok(gitlinks(&out) + .into_iter() + .filter(|p| dir.join(p).join(".git").exists()) + .collect()) +} + +/// Pure core of [`checked_out_submodules`]: the gitlinks (mode `160000`) +/// among `ls-files -z --stage` entries (` \t`). +fn gitlinks(out: &str) -> Vec { + out.split('\0') + .filter_map(|entry| { + let (meta, path) = entry.split_once('\t')?; + meta.starts_with("160000 ").then(|| PathBuf::from(path)) + }) + .collect() +} + +/// Gitignored output a delete costs nothing to lose: what a build, an +/// install or the OS regenerates. Matched against an entry's last path +/// component, so a nested `crates/x/target/` counts too. Anything else +/// ignored — `.env`, an editor's local settings — is someone's, and the +/// delete takes it. +const REGENERABLE: &[&str] = &[ + "target", + "node_modules", + "dist", + "build", + "out", + ".next", + "__pycache__", + ".venv", + "venv", + ".gradle", + ".cache", + ".DS_Store", +]; + +/// The gitignored files and folders in a checkout that are not build +/// output ([`REGENERABLE`]), relative to it: `git ls-files --others +/// --ignored --directory`, so a whole ignored folder is one entry. +pub async fn ignored_keepsakes(worktree: &Path) -> Result> { + let out = git_read( + worktree, + &[ + "ls-files", + "-z", + "--others", + "--ignored", + "--exclude-standard", + "--directory", + ], + ) + .await?; + Ok(keepsakes(&out)) +} + +/// Pure core of [`ignored_keepsakes`]. +fn keepsakes(out: &str) -> Vec { + out.split('\0') + .filter(|entry| !entry.is_empty()) + .filter(|entry| { + let name = entry.trim_end_matches('/').rsplit('/').next().unwrap_or(""); + !REGENERABLE.contains(&name) + }) + .map(str::to_string) + .collect() } /// Is the checkout's HEAD detached — on no branch at all? @@ -1619,13 +2119,58 @@ mod tests { #[test] fn status_z_keeps_odd_names_and_drops_rename_sources() { let out = " M src/a b.rs\0R new.rs\0old.rs\0?? notes/\0 M sub\0"; + let parsed = parse_status_z(out); + let paths: Vec<&Path> = parsed.iter().map(|c| c.path.as_path()).collect(); assert_eq!( - parse_status_z(out), - ["src/a b.rs", "new.rs", "notes/", "sub"].map(PathBuf::from) + paths, + ["src/a b.rs", "new.rs", "notes/", "sub"].map(Path::new) ); + let untracked: Vec = parsed.iter().map(|c| c.untracked).collect(); + assert_eq!(untracked, [false, false, true, false]); assert!(parse_status_z("").is_empty()); } + #[test] + fn ignored_build_output_is_not_a_keepsake() { + let out = ".env\0target/\0crates/x/target/\0node_modules/\0.claude/settings.local.json\0notes/\0.DS_Store\0"; + assert_eq!( + keepsakes(out), + [".env", ".claude/settings.local.json", "notes/"] + ); + } + + /// On a real checkout: an ignored `.env` is listed, an ignored + /// `target/` is not. + #[tokio::test] + async fn ignored_keepsakes_lists_dot_env_but_not_target() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repo"); + std::fs::create_dir(&repo).unwrap(); + init_repo(&repo).await; + std::fs::write(repo.join(".gitignore"), ".env\ntarget/\n").unwrap(); + git(&repo, &["add", ".gitignore"]).await.unwrap(); + git(&repo, &["commit", "-q", "-m", "ignore"]).await.unwrap(); + std::fs::write(repo.join(".env"), "SECRET=1").unwrap(); + std::fs::create_dir_all(repo.join("target/debug")).unwrap(); + std::fs::write(repo.join("target/debug/app"), "bin").unwrap(); + assert_eq!(ignored_keepsakes(&repo).await.unwrap(), [".env"]); + } + + #[test] + fn gitlinks_are_the_160000_entries() { + let out = [ + "100644 0123 0\tsrc/a.rs", + "160000 4567 0\tlib/a", + "160000 89ab 0\tlib/with space", + ] + .join("\0"); + let out = out.as_str(); + assert_eq!( + gitlinks(out), + ["lib/a", "lib/with space"].map(PathBuf::from) + ); + } + /// The DELETE CHECK's git half on a real checkout: edits and untracked /// files are counted, commits are measured against the base, and a /// detached HEAD's commits are the ones nothing else holds. @@ -1644,16 +2189,19 @@ mod tests { Some("main"), "no origin: the root's branch" ); - assert_eq!(commits_ahead(&wt, "main").await.unwrap(), 0); + assert_eq!(unlanded_commits(&wt, "main", "HEAD").await.unwrap(), 0); std::fs::write(wt.join("new.txt"), "x").unwrap(); assert_eq!( changed_paths(&wt).await.unwrap(), - [PathBuf::from("new.txt")] + [ChangedPath { + path: PathBuf::from("new.txt"), + untracked: true, + }] ); git(&wt, &["add", "."]).await.unwrap(); git(&wt, &["commit", "-m", "work"]).await.unwrap(); - assert_eq!(commits_ahead(&wt, "main").await.unwrap(), 1); + assert_eq!(unlanded_commits(&wt, "main", "HEAD").await.unwrap(), 1); assert!(!head_detached(&wt).await); assert_eq!( unreferenced_commits(&wt).await.unwrap(), @@ -1678,4 +2226,396 @@ mod tests { Some("main") ); } + + /// Commit `file` with `body` in `dir`. + async fn commit_file(dir: &Path, file: &str, body: &str) { + std::fs::write(dir.join(file), body).unwrap(); + git(dir, &["add", "--", file]).await.unwrap(); + git(dir, &["commit", "-q", "-m", file]).await.unwrap(); + } + + /// A repo on `main` and a worktree on `feat` holding two commits of + /// its own, for the landing tests to land one way or another. + async fn repo_with_feature() -> (tempfile::TempDir, PathBuf, PathBuf) { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repo"); + std::fs::create_dir(&repo).unwrap(); + init_repo(&repo).await; + let wt = add_worktree(&repo, "feat", None).await.unwrap(); + commit_file(&wt, "a.txt", "a").await; + commit_file(&wt, "b.txt", "b").await; + (tmp, repo, wt) + } + + /// A branch merged into its base, by a merge commit, has landed. + #[tokio::test] + async fn a_branch_landed_by_merge_has_nothing_unlanded() { + let (_tmp, repo, wt) = repo_with_feature().await; + assert_eq!(unlanded_commits(&wt, "main", "HEAD").await.unwrap(), 2); + commit_file(&repo, "other.txt", "o").await; + git(&repo, &["merge", "--no-ff", "-q", "-m", "merge", "feat"]) + .await + .unwrap(); + assert_eq!(unlanded_commits(&wt, "main", "HEAD").await.unwrap(), 0); + assert_eq!(unlanded_commits(&repo, "main", "feat").await.unwrap(), 0); + } + + /// A squash merge leaves `main..feat` at two commits forever; by + /// content nothing in them is missing. + #[tokio::test] + async fn a_branch_landed_by_squash_has_nothing_unlanded() { + let (_tmp, repo, wt) = repo_with_feature().await; + commit_file(&repo, "other.txt", "o").await; + git(&repo, &["merge", "--squash", "-q", "feat"]) + .await + .unwrap(); + git(&repo, &["commit", "-q", "-m", "squashed"]) + .await + .unwrap(); + let ancestry = git(&wt, &["rev-list", "--count", "main..HEAD"]) + .await + .unwrap(); + assert_eq!(ancestry.trim(), "2", "ancestry still counts both"); + assert_eq!(unlanded_commits(&wt, "main", "HEAD").await.unwrap(), 0); + + // One more commit on the branch is unlanded again — the whole + // branch is, since the squash test is all or nothing. + commit_file(&wt, "c.txt", "c").await; + assert!(unlanded_commits(&wt, "main", "HEAD").await.unwrap() > 0); + } + + /// Commits cherry-picked onto the base (a rebase-and-merge, a merge + /// queue) are on it by patch, not by ancestry. + #[tokio::test] + async fn a_branch_landed_by_cherry_pick_has_nothing_unlanded() { + let (_tmp, repo, wt) = repo_with_feature().await; + commit_file(&repo, "other.txt", "o").await; + git(&repo, &["cherry-pick", "main..feat"]).await.unwrap(); + assert_eq!(unlanded_commits(&wt, "main", "HEAD").await.unwrap(), 0); + } + + /// One commit the base has no patch for is counted, alone. + #[tokio::test] + async fn one_unlanded_commit_is_counted() { + let (_tmp, repo, wt) = repo_with_feature().await; + git(&repo, &["cherry-pick", "feat~1"]).await.unwrap(); + assert_eq!(unlanded_commits(&wt, "main", "HEAD").await.unwrap(), 1); + } + + /// The branch a worktree delete may take never includes the base, + /// the default branch or the root's own. + #[tokio::test] + async fn base_and_root_branches_are_protected() { + let (_tmp, repo, _wt) = repo_with_feature().await; + assert!(protected_branch(&repo, "main", Some("main")).await); + assert!(protected_branch(&repo, "main", Some("origin/main")).await); + assert!( + protected_branch(&repo, "main", Some("develop")).await, + "root's" + ); + assert!(!protected_branch(&repo, "feat", Some("main")).await); + assert!( + protected_branch(&repo, "main", None).await, + "no base: still the root's" + ); + assert!(!protected_branch(&repo, "feat", None).await); + } + + /// `delete_branch_at` deletes a branch only at the tip it was given: + /// one a commit has moved since is kept. + #[tokio::test] + async fn delete_branch_at_compares_the_tip_before_it_deletes() { + let (_tmp, repo, wt) = repo_with_feature().await; + let judged = branch_tip(&wt).await.expect("on feat"); + assert_eq!(judged.name, "feat"); + git(&repo, &["config", "branch.feat.remote", "origin"]) + .await + .unwrap(); + commit_file(&wt, "late.txt", "late").await; + remove_worktree(&repo, &wt, true).await.unwrap(); + let moved = delete_branch_at(&repo, &judged).await; + assert!(moved.is_err(), "a commit since the check keeps it"); + assert!(local_branch(&repo, "feat").await); + + let now = nebula_core::BranchTip { + name: "feat".into(), + oid: git(&repo, &["rev-parse", "feat"]) + .await + .unwrap() + .trim() + .into(), + }; + delete_branch_at(&repo, &now).await.unwrap(); + assert!(!local_branch(&repo, "feat").await); + assert_eq!(config_get(&repo, "branch.feat.remote").await, None); + } + + /// A submodule deinitialised in the worktree leaves its store in the + /// worktree's git dir, which the delete takes, commits and all; the + /// main repository's own stores, which it leaves, never count. + #[tokio::test] + async fn a_deinitialised_submodules_store_still_counts() { + let tmp = tempfile::tempdir().unwrap(); + let allow = ["-c", "protocol.file.allow=always"]; + let upstream = tmp.path().join("lib"); + let repo = tmp.path().join("repo"); + for dir in [&upstream, &repo] { + std::fs::create_dir(dir).unwrap(); + init_repo(dir).await; + } + let url = upstream.to_string_lossy().into_owned(); + let mut args = allow.to_vec(); + args.extend(["submodule", "add", "-q", url.as_str(), "lib"]); + git(&repo, &args).await.unwrap(); + git(&repo, &["commit", "-q", "-m", "add lib"]) + .await + .unwrap(); + // A commit only the main repository's store has: never this + // worktree's to lose. + let root_sub = repo.join("lib"); + git(&root_sub, &["config", "user.email", "t@t"]) + .await + .unwrap(); + git(&root_sub, &["config", "user.name", "t"]).await.unwrap(); + commit_file(&root_sub, "root.txt", "r").await; + + let wt = add_worktree(&repo, "feat", None).await.unwrap(); + let mut args = allow.to_vec(); + args.extend(["submodule", "update", "--init", "-q"]); + git(&wt, &args).await.unwrap(); + assert_eq!(submodule_unpushed(&wt, &repo).await.unwrap(), 0); + let sub = wt.join("lib"); + git(&sub, &["config", "user.email", "t@t"]).await.unwrap(); + git(&sub, &["config", "user.name", "t"]).await.unwrap(); + commit_file(&sub, "z.txt", "z").await; + git(&wt, &["submodule", "deinit", "-q", "-f", "lib"]) + .await + .unwrap(); + assert!(!sub.join(".git").exists(), "deinitialised"); + assert_eq!(submodule_unpushed(&wt, &repo).await.unwrap(), 1); + } + + #[test] + fn a_module_name_drops_the_stores_own_modules_levels() { + assert_eq!(module_name(Path::new("lib")), "lib"); + assert_eq!(module_name(Path::new("lib/modules/inner")), "lib/inner"); + } + + /// A store mid-clone — HEAD naming a branch with no commit yet, the + /// state `git submodule update` leaves for a moment — holds nothing to + /// lose: 0, not an error. + #[tokio::test] + async fn a_store_still_being_cloned_counts_nothing() { + let (_tmp, repo, wt) = repo_with_feature().await; + let own = git(&wt, &["rev-parse", "--absolute-git-dir"]) + .await + .unwrap(); + let store = Path::new(own.trim()).join("modules").join("cloning"); + git(&wt, &["init", "-q", "--bare", &store.to_string_lossy()]) + .await + .unwrap(); + assert_eq!(submodule_unpushed(&wt, &repo).await.unwrap(), 0); + } + + /// A store git cannot read names its submodule in the error, for the + /// band to say which. + #[tokio::test] + async fn an_unreadable_store_names_its_submodule() { + let (_tmp, repo, wt) = repo_with_feature().await; + let own = git(&wt, &["rev-parse", "--absolute-git-dir"]) + .await + .unwrap(); + let broken = Path::new(own.trim()).join("modules").join("half"); + std::fs::create_dir_all(broken.join("objects")).unwrap(); + std::fs::write(broken.join("HEAD"), "not a ref\n").unwrap(); + let err = submodule_unpushed(&wt, &repo).await.unwrap_err(); + let failed = err + .downcast_ref::() + .unwrap_or_else(|| panic!("{err:#}")); + assert_eq!(failed.path, "half"); + } + + /// Only a directory with `HEAD` and `objects/` is a store, and a + /// symbolic link under `modules/` is never followed. + #[test] + fn collect_stores_takes_real_stores_and_follows_no_link() { + let tmp = tempfile::tempdir().unwrap(); + let modules = tmp.path().join("modules"); + let store = modules.join("lib"); + std::fs::create_dir_all(store.join("objects")).unwrap(); + std::fs::write(store.join("HEAD"), "ref: refs/heads/main\n").unwrap(); + let headless = modules.join("stray"); + std::fs::create_dir_all(&headless).unwrap(); + std::fs::write(headless.join("HEAD"), "x").unwrap(); + std::os::unix::fs::symlink(&modules, modules.join("loop")).unwrap(); + let mut found = Vec::new(); + collect_stores(&modules, &mut found).unwrap(); + assert_eq!(found, [std::fs::canonicalize(&store).unwrap()]); + let mut none = Vec::new(); + collect_stores(&tmp.path().join("absent"), &mut none).unwrap(); + assert!(none.is_empty()); + } + + /// A nested submodule's commits count like a top-level one's. + #[tokio::test] + async fn nested_submodule_commits_on_no_remote_are_counted() { + let tmp = tempfile::tempdir().unwrap(); + let allow = ["-c", "protocol.file.allow=always"]; + let make = |name: &str| tmp.path().join(name); + for name in ["inner", "lib", "repo"] { + std::fs::create_dir(make(name)).unwrap(); + init_repo(&make(name)).await; + } + let url = |name: &str| make(name).to_string_lossy().into_owned(); + let inner = url("inner"); + let mut args = allow.to_vec(); + args.extend(["submodule", "add", "-q", inner.as_str(), "inner"]); + git(&make("lib"), &args).await.unwrap(); + git(&make("lib"), &["commit", "-q", "-m", "add inner"]) + .await + .unwrap(); + let lib = url("lib"); + let mut args = allow.to_vec(); + args.extend(["submodule", "add", "-q", lib.as_str(), "lib"]); + git(&make("repo"), &args).await.unwrap(); + git(&make("repo"), &["commit", "-q", "-m", "add lib"]) + .await + .unwrap(); + let repo = make("repo"); + let wt = add_worktree(&repo, "feat", None).await.unwrap(); + let mut args = allow.to_vec(); + args.extend(["submodule", "update", "--init", "--recursive", "-q"]); + git(&wt, &args).await.unwrap(); + assert_eq!(submodule_unpushed(&wt, &repo).await.unwrap(), 0); + + let nested = wt.join("lib").join("inner"); + git(&nested, &["config", "user.email", "t@t"]) + .await + .unwrap(); + git(&nested, &["config", "user.name", "t"]).await.unwrap(); + commit_file(&nested, "n.txt", "n").await; + assert_eq!(submodule_unpushed(&wt, &repo).await.unwrap(), 1); + } + + /// A symbolic-ref branch is deleted itself, never what it points at. + #[tokio::test] + async fn delete_branch_at_never_follows_a_symbolic_ref() { + let (_tmp, repo, _wt) = repo_with_feature().await; + git( + &repo, + &["symbolic-ref", "refs/heads/alias", "refs/heads/main"], + ) + .await + .unwrap(); + let main = git(&repo, &["rev-parse", "main"]).await.unwrap(); + let alias = nebula_core::BranchTip { + name: "alias".into(), + oid: main.trim().into(), + }; + delete_branch_at(&repo, &alias).await.unwrap(); + assert!(local_branch(&repo, "main").await, "main survives"); + assert_eq!( + git(&repo, &["rev-parse", "main"]).await.unwrap(), + main, + "untouched" + ); + assert!(git(&repo, &["symbolic-ref", "-q", "refs/heads/alias"]) + .await + .is_err()); + } + + /// A submodule commit made in a linked worktree lives only in that + /// worktree's submodule store until it is pushed; one the root's + /// submodule has, or a remote holds, is safe. + #[tokio::test] + async fn submodule_commits_on_no_remote_are_counted() { + let tmp = tempfile::tempdir().unwrap(); + let upstream = tmp.path().join("lib"); + std::fs::create_dir(&upstream).unwrap(); + init_repo(&upstream).await; + let repo = tmp.path().join("repo"); + std::fs::create_dir(&repo).unwrap(); + init_repo(&repo).await; + let url = upstream.to_string_lossy().into_owned(); + git( + &repo, + &[ + "-c", + "protocol.file.allow=always", + "submodule", + "add", + "-q", + &url, + "lib", + ], + ) + .await + .unwrap(); + git(&repo, &["commit", "-q", "-m", "add lib"]) + .await + .unwrap(); + assert_eq!(submodule_unpushed(&repo, &repo).await.unwrap(), 0); + + let wt = add_worktree(&repo, "feat", None).await.unwrap(); + assert_eq!( + submodule_unpushed(&wt, &repo).await.unwrap(), + 0, + "not checked out in the worktree: nothing in it" + ); + git( + &wt, + &[ + "-c", + "protocol.file.allow=always", + "submodule", + "update", + "--init", + "-q", + ], + ) + .await + .unwrap(); + assert_eq!(submodule_unpushed(&wt, &repo).await.unwrap(), 0); + let sub = wt.join("lib"); + git(&sub, &["config", "user.email", "t@t"]).await.unwrap(); + git(&sub, &["config", "user.name", "t"]).await.unwrap(); + commit_file(&sub, "x.txt", "x").await; + commit_file(&sub, "y.txt", "y").await; + git(&wt, &["commit", "-q", "-am", "bump lib"]) + .await + .unwrap(); + assert!(changed_paths(&wt).await.unwrap().is_empty()); + assert_eq!(submodule_unpushed(&wt, &repo).await.unwrap(), 2); + + // Fetched into the root's copy but on none of its refs, they are + // dangling there — gone at its next gc — so still lost. + let wt_sub = sub.to_string_lossy().into_owned(); + git(&repo.join("lib"), &["fetch", "-q", &wt_sub, "HEAD"]) + .await + .unwrap(); + assert_eq!(submodule_unpushed(&wt, &repo).await.unwrap(), 2); + // A branch there keeps them: they survive the delete. + git(&repo.join("lib"), &["branch", "kept", "FETCH_HEAD"]) + .await + .unwrap(); + assert_eq!(submodule_unpushed(&wt, &repo).await.unwrap(), 0); + + // A commit on a local branch of the worktree's submodule, HEAD + // then detached back to the pin: on no remote, in no other store. + let pin = git(&sub, &["rev-parse", "HEAD"]).await.unwrap(); + git(&sub, &["switch", "-q", "-c", "side"]).await.unwrap(); + commit_file(&sub, "z.txt", "z").await; + git(&sub, &["checkout", "-q", "--detach", pin.trim()]) + .await + .unwrap(); + assert!(changed_paths(&wt).await.unwrap().is_empty()); + assert_eq!(submodule_unpushed(&wt, &repo).await.unwrap(), 1); + + // A stash there is lost with the store too. + git(&sub, &["branch", "-q", "-D", "side"]).await.unwrap(); + assert_eq!(submodule_unpushed(&wt, &repo).await.unwrap(), 0); + std::fs::write(sub.join("x.txt"), "edited").unwrap(); + git(&sub, &["stash", "-q"]).await.unwrap(); + assert!(submodule_unpushed(&wt, &repo).await.unwrap() > 0); + } } diff --git a/crates/nebula-daemon/src/registry.rs b/crates/nebula-daemon/src/registry.rs index 4c8a2759..41946949 100644 --- a/crates/nebula-daemon/src/registry.rs +++ b/crates/nebula-daemon/src/registry.rs @@ -4499,12 +4499,12 @@ mod tests { seed_worktree(&daemon, "p", "feat", &wt.to_string_lossy(), false); let id = WorktreeId("feat".into()); - let clean = daemon.check_worktree(&id, None).await.unwrap(); + let clean = daemon.check_worktree(&id, None, false).await.unwrap(); assert!(!clean.has_findings(), "{clean:?}"); std::fs::write(wt.join("draft.txt"), "half done").unwrap(); let worker = Worker::start(&wt); - let check = daemon.check_worktree(&id, None).await; + let check = daemon.check_worktree(&id, None, false).await; drop(worker); let check = check.unwrap(); assert_eq!(check.changes, 1, "{check:?}"); @@ -4515,6 +4515,191 @@ mod tests { ); } + /// A band's check is the git half alone: no process sweep, and the + /// branch's landing — here, a branch with nothing of its own, which + /// has landed, then one with a commit, which has not. + #[tokio::test] + async fn a_git_only_check_reads_the_branch_and_skips_the_sweep() { + let tmp = tempfile::tempdir().unwrap(); + let root = std::fs::canonicalize(tmp.path()).unwrap(); + let repo = init_repo(&root); + let wt = root.join("repo-worktrees").join("feat"); + git_in( + &repo, + &["worktree", "add", &wt.to_string_lossy(), "-b", "feat"], + ); + let daemon = test_daemon(); + project_at(&daemon, &repo); + seed_worktree(&daemon, "p", "feat", &wt.to_string_lossy(), false); + let id = WorktreeId("feat".into()); + + let worker = Worker::start(&wt); + let check = daemon.check_worktree(&id, None, true).await; + drop(worker); + let check = check.unwrap(); + assert!(check.processes.is_empty(), "{check:?}"); + let landing = check.landing.clone().expect("a landing"); + assert!(landing.branch_landed, "{check:?}"); + + std::fs::write(wt.join("work.txt"), "w").unwrap(); + git_in(&wt, &["add", "work.txt"]); + git_in(&wt, &["commit", "-m", "work"]); + std::fs::write(wt.join("new.txt"), "x").unwrap(); + let check = daemon.check_worktree(&id, None, true).await.unwrap(); + assert_eq!(check.ahead, 1); + assert_eq!( + (check.changes, check.landing.as_ref().unwrap().untracked), + (1, 1) + ); + assert!(!check.landing.unwrap().branch_landed); + } + + /// `delete_branch` takes the branch the confirm judged — landed or not, + /// the confirm being the decision — and only that branch at that tip: + /// not after the checkout switched branches (which the worktree sync + /// writes into the row within seconds), not after a commit landed on + /// it, not while another checkout has it out, and nothing from a + /// detached HEAD. Each keeps the branch with a warning. + #[tokio::test] + async fn a_delete_takes_only_the_branch_and_tip_the_confirm_judged() { + let tmp = tempfile::tempdir().unwrap(); + let root = std::fs::canonicalize(tmp.path()).unwrap(); + let repo = init_repo(&root); + let daemon = test_daemon(); + let project = project_at(&daemon, &repo); + let branch_exists = |name: &str| { + std::process::Command::new("git") + .arg("-C") + .arg(&repo) + .args([ + "rev-parse", + "--verify", + "--quiet", + &format!("refs/heads/{name}"), + ]) + .status() + .unwrap() + .success() + }; + let commit = |wt: &Path, file: &str| { + std::fs::write(wt.join(file), file).unwrap(); + git_in(wt, &["add", file]); + git_in(wt, &["commit", "-m", file]); + }; + let cut = |name: &str, commits: usize| { + let wt = root.join("repo-worktrees").join(name); + git_in( + &repo, + &["worktree", "add", &wt.to_string_lossy(), "-b", name], + ); + for i in 0..commits { + commit(&wt, &format!("c{i}.txt")); + } + seed_worktree(&daemon, "p", name, &wt.to_string_lossy(), false); + (WorktreeId(name.into()), wt) + }; + // What a confirm hands back: the tip its check judged. + let judged = |id: &WorktreeId| { + let daemon = daemon.clone(); + let id = id.clone(); + async move { + let check = daemon.check_worktree(&id, None, true).await.unwrap(); + check.branch_tip().cloned().expect("on a branch") + } + }; + let mut events = daemon.events.subscribe(); + let warned = |events: &mut broadcast::Receiver, words: &str| { + let warnings = drain_warnings(events); + assert!( + warnings.iter().any(|w| w.contains(words)), + "{words:?} in {warnings:?}" + ); + }; + + // Landed, and unlanded with `D`: both go. + let (landed, _) = cut("landed", 0); + let tip = judged(&landed).await; + daemon + .delete_worktree_and_branch(&landed, true, Some(tip)) + .await + .unwrap(); + assert!(!branch_exists("landed")); + let (discarded, _) = cut("discarded", 2); + let tip = judged(&discarded).await; + daemon + .delete_worktree_and_branch(&discarded, true, Some(tip)) + .await + .unwrap(); + assert!(!branch_exists("discarded")); + + // None keeps the branch, as every delete did before. + let (kept, _) = cut("kept", 0); + daemon + .delete_worktree_and_branch(&kept, true, None) + .await + .unwrap(); + assert!(branch_exists("kept")); + + // The checkout switched to a new branch and committed there, and + // the worktree sync moved the row onto it: neither goes. + let (moved, wt) = cut("moved", 0); + let tip = judged(&moved).await; + git_in(&wt, &["switch", "-q", "-c", "elsewhere"]); + commit(&wt, "new.txt"); + daemon.sync_project_worktrees(&project).await.unwrap(); + let row = daemon.store.get_worktree(&moved).unwrap().unwrap(); + assert_eq!(row.branch, "elsewhere", "the sync follows the switch"); + daemon + .delete_worktree_and_branch(&moved, true, Some(tip)) + .await + .unwrap(); + assert!(branch_exists("moved") && branch_exists("elsewhere")); + warned(&mut events, "kept branch 'moved'"); + + // A commit landed while the confirm was open: kept. + let (late, wt) = cut("late", 0); + let tip = judged(&late).await; + commit(&wt, "late.txt"); + daemon + .delete_worktree_and_branch(&late, true, Some(tip)) + .await + .unwrap(); + assert!(branch_exists("late")); + warned(&mut events, "kept branch 'late'"); + + // Another checkout has it out: kept. + let (shared, _) = cut("shared", 0); + let tip = judged(&shared).await; + let other = root.join("elsewhere-shared"); + git_in( + &repo, + &[ + "worktree", + "add", + "--force", + &other.to_string_lossy(), + "shared", + ], + ); + daemon + .delete_worktree_and_branch(&shared, true, Some(tip)) + .await + .unwrap(); + assert!(branch_exists("shared")); + warned(&mut events, "has it checked out"); + + // Detached since the confirm: nothing goes. + let (detached, wt) = cut("detached", 0); + let tip = judged(&detached).await; + git_in(&wt, &["checkout", "-q", "--detach"]); + daemon + .delete_worktree_and_branch(&detached, true, Some(tip)) + .await + .unwrap(); + assert!(branch_exists("detached")); + assert!(branch_exists("main")); + } + /// A process working in `dir` the way an agent's build does: below a /// shell, never a direct child of the DAEMON — in a unit test the /// daemon is this process, and its direct children are the helpers diff --git a/crates/nebula-daemon/src/registry/projects.rs b/crates/nebula-daemon/src/registry/projects.rs index 95591d5a..079ffb09 100644 --- a/crates/nebula-daemon/src/registry/projects.rs +++ b/crates/nebula-daemon/src/registry/projects.rs @@ -455,6 +455,21 @@ impl Daemon { } pub async fn delete_worktree(self: &Arc, id: &WorktreeId, force: bool) -> Result<()> { + self.delete_worktree_and_branch(id, force, None).await + } + + /// Delete a worktree, and with `delete_branch` the branch the confirm + /// judged too ([`Self::delete_checked_out_branch`]) — by its name and + /// tip, never by what the checkout or its row says now: the row's + /// branch follows an in-place switch within seconds. A branch that + /// cannot go is a warning, never an Error for the request — the + /// checkout is already gone. + pub async fn delete_worktree_and_branch( + self: &Arc, + id: &WorktreeId, + force: bool, + delete_branch: Option, + ) -> Result<()> { let ops = self.worktree_ops.lock().await; let worktree_lookup = id.clone(); let worktree = self @@ -470,11 +485,36 @@ impl Daemon { .await? .context("project not found")?; + // The checkout must still be on the branch the confirm judged: one + // switched since (or detached) keeps every branch. + let branch = match delete_branch { + Some(tip) => match git::current_branch(&worktree.path).await { + Some(out) if out == tip.name => Some(tip), + now => { + self.warn_clients(format!( + "kept branch '{}': the checkout moved to {} since the confirm", + tip.name, + now.map_or_else(|| "a detached HEAD".to_string(), |b| format!("'{b}'")) + )); + None + } + }, + None => None, + }; + // Kill sessions living in this worktree. let (_, _, agents, terminals) = self.store_blocking(|store| store.load_tree()).await?; self.kill_sessions_in(std::slice::from_ref(id), &agents, &terminals); git::remove_worktree(&project.repo_path, &worktree.path, force).await?; + if let Some(tip) = branch { + if let Err(e) = self + .delete_checked_out_branch(&project.repo_path, &tip) + .await + { + self.warn_clients(format!("kept branch '{}': {e:#}", tip.name)); + } + } let delete_id = id.clone(); self.store_blocking(move |store| store.delete_worktree(&delete_id)) .await?; @@ -495,6 +535,31 @@ impl Daemon { Ok(()) } + /// Delete the branch the confirm judged once its worktree is gone — + /// only while its tip is still the commit the confirm saw (an atomic + /// compare-and-delete, so a commit made while the dialog was open, by + /// a subagent say, keeps it). Never the base, origin's default branch + /// or the root's, and never one another checkout has out, whatever + /// the confirm said. + async fn delete_checked_out_branch( + &self, + repo: &Path, + tip: &nebula_core::BranchTip, + ) -> Result<()> { + let configured = crate::config::Config::load() + .worktree_base_branch() + .map(str::to_string); + let base = git::comparison_base(repo, configured.as_deref()).await; + if git::protected_branch(repo, &tip.name, base.as_deref()).await { + bail!("it is the base, default or root branch"); + } + let checkouts = git::list_worktrees(repo).await?; + if let Some(other) = checkouts.iter().find(|w| w.branch == tip.name) { + bail!("{} has it checked out", other.path.display()); + } + git::delete_branch_at(repo, tip).await + } + /// The DELETE CHECK on a worktree: what taking it off disk would /// interrupt or lose, for the confirm a client shows first /// ([`crate::worktree_check`]). Reads only, and outside the worktree @@ -502,10 +567,12 @@ impl Daemon { /// /// `client` is the asking client's pid, when the socket says it: its /// own process tree (the TUI, its background git reads) is spared. + /// `git_only` skips the process sweep — an idle band's line. pub async fn check_worktree( self: &Arc, id: &WorktreeId, client: Option, + git_only: bool, ) -> Result { let lookup = id.clone(); let worktree = self @@ -517,8 +584,13 @@ impl Daemon { .store_blocking(move |store| store.get_project(&project_lookup)) .await? .context("project not found")?; - let mut spare = self.own_session_pids(id).await?; - spare.extend(self.nebula_helper_pids(client).await?); + let spare = if git_only { + None + } else { + let mut spare = self.own_session_pids(id).await?; + spare.extend(self.nebula_helper_pids(client).await?); + Some(spare) + }; let base = crate::config::Config::load() .worktree_base_branch() .map(str::to_string); diff --git a/crates/nebula-daemon/src/server.rs b/crates/nebula-daemon/src/server.rs index d7a70088..4aa3d1d0 100644 --- a/crates/nebula-daemon/src/server.rs +++ b/crates/nebula-daemon/src/server.rs @@ -226,12 +226,21 @@ impl ClientConnection { self.spawn_create_worktree(req_id, project, branch, base); Ok(true) } - ClientRequest::DeleteWorktree { req_id, id, force } => { - self.spawn_delete_worktree(req_id, id, force); + ClientRequest::DeleteWorktree { + req_id, + id, + force, + delete_branch, + } => { + self.spawn_delete_worktree(req_id, id, force, delete_branch); Ok(true) } - ClientRequest::CheckWorktree { req_id, id } => { - self.spawn_check_worktree(req_id, id); + ClientRequest::CheckWorktree { + req_id, + id, + git_only, + } => { + self.spawn_check_worktree(req_id, id, git_only); Ok(true) } ClientRequest::CleanupMergedWorktree { @@ -899,30 +908,44 @@ impl ClientConnection { }); } - fn spawn_delete_worktree(&self, req_id: u64, id: nebula_core::WorktreeId, force: bool) { + fn spawn_delete_worktree( + &self, + req_id: u64, + id: nebula_core::WorktreeId, + force: bool, + delete_branch: Option, + ) { // `git worktree remove` can take seconds on a large checkout; run it // off the request loop so Input/Attach frames keep flowing while it // grinds. `worktree_ops` still serializes it against create/sync. let daemon = self.daemon.clone(); let out_tx = self.out_tx.clone(); tokio::spawn(async move { - reply_done(&out_tx, req_id, daemon.delete_worktree(&id, force).await).await; + let result = daemon + .delete_worktree_and_branch(&id, force, delete_branch) + .await; + reply_done(&out_tx, req_id, result).await; }); } - fn spawn_check_worktree(&self, req_id: u64, id: nebula_core::WorktreeId) { + fn spawn_check_worktree(&self, req_id: u64, id: nebula_core::WorktreeId, git_only: bool) { // `git status` on a big checkout and an `lsof` of every process are // each a fraction of a second; off the request loop, like a delete. let daemon = self.daemon.clone(); let out_tx = self.out_tx.clone(); let client = self.peer_pid; + // A client from before the check grew a field cannot decode it. let reads_lock = self.client_protocol >= nebula_core::WORKTREE_LOCK_PROTOCOL; + let landing = self.client_protocol >= nebula_core::BRANCH_LANDING_PROTOCOL; tokio::spawn(async move { - let ev = match daemon.check_worktree(&id, client).await { + let ev = match daemon.check_worktree(&id, client, git_only).await { Ok(mut check) => { if !reads_lock { check.locked = None; } + if !landing { + check.landing = None; + } ServerEvent::WorktreeChecked { req_id, id, check } } Err(e) => ServerEvent::Error { diff --git a/crates/nebula-daemon/src/worktree_check.rs b/crates/nebula-daemon/src/worktree_check.rs index 78df8885..a06a1a17 100644 --- a/crates/nebula-daemon/src/worktree_check.rs +++ b/crates/nebula-daemon/src/worktree_check.rs @@ -14,81 +14,307 @@ use crate::git; use anyhow::{Context, Result}; -use nebula_core::{WorktreeCheck, WorktreeProcess}; +use nebula_core::{WorktreeCheck, WorktreeLanding, WorktreeProcess}; use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::time::UNIX_EPOCH; +/// Ignored files a confirm names before it says "and N more". +const IGNORED_NAMED: usize = 3; + /// Run the DELETE CHECK on `worktree`, a checkout of `repo`. `spare` is /// every pid the worktree's own sessions lead or run — the confirm counts /// those already and they go down with it — and `configured_base` the -/// WORKTREE BASE BRANCH setting. +/// WORKTREE BASE BRANCH setting. `spare` None is an idle band's check: +/// the git half, with the sweep used only to see whether a git is at +/// work in the checkout, and no process listed. pub async fn check( repo: &Path, worktree: &Path, - spare: HashSet, + spare: Option>, configured_base: Option, ) -> WorktreeCheck { if !worktree.exists() { // Already gone: a delete only drops the row, and loses nothing. return WorktreeCheck::default(); } + let Some(spare) = spare else { + // A band's check: the git half, and whether a git is at work in + // the checkout (a `git submodule update` still cloning, a rebase) + // — then its state is in flux, which the band says instead of + // reading it. The sweep runs first, so this check's own gits are + // not in it (and they only read, which the sweep ignores). + let dir = worktree.to_path_buf(); + let sweep = tokio::task::spawn_blocking(move || processes_in(&dir, &HashSet::new())).await; + let mut check = git_half(repo, worktree, configured_base, false).await; + let live = match sweep { + Ok(Ok(processes)) => live_git_op(&processes).await, + _ => None, + }; + let op = match live { + Some(op) => Some((op, true)), + None => paused_git_op(worktree).map(|op| (op, false)), + }; + note_op(&mut check, op); + return check; + }; // The sweep first, then git: run side by side, the daemon's own // `git -C ` (and the submodule gits under it) would be in // the sweep, working in the checkout. let dir = worktree.to_path_buf(); let sweep = tokio::task::spawn_blocking(move || processes_in(&dir, &spare)).await; - let mut check = git_half(repo, worktree, configured_base).await; + let mut check = git_half(repo, worktree, configured_base, true).await; match sweep { Ok(Ok(processes)) => check.processes = processes, Ok(Err(e)) => check.process_error = Some(format!("{e:#}")), Err(e) => check.process_error = Some(format!("process sweep failed: {e}")), } + let op = match live_git_op(&check.processes).await { + Some(op) => Some((op, true)), + None => paused_git_op(worktree).map(|op| (op, false)), + }; + note_op(&mut check, op); check } +/// Record a git operation found under way in the checkout. +fn note_op(check: &mut WorktreeCheck, op: Option<(String, bool)>) { + if let (Some((op, live)), Some(landing)) = (op, check.landing.as_mut()) { + landing.git_op = Some(op); + landing.git_op_live = live; + } +} + +/// A git operation stopped part-way in the checkout, as its git dir's +/// state says: a rebase (`rebase-merge/`, `rebase-apply/`), an `am`, a +/// merge, a cherry-pick or a revert waiting on the user. +fn paused_git_op(worktree: &Path) -> Option { + let git_dir = git::store_of(worktree)?; + let op = if git_dir.join("rebase-merge").is_dir() { + "rebase" + } else if git_dir.join("rebase-apply").join("applying").exists() { + "am" + } else if git_dir.join("rebase-apply").is_dir() { + "rebase" + } else if git_dir.join("MERGE_HEAD").exists() { + "merge" + } else if git_dir.join("CHERRY_PICK_HEAD").exists() { + "cherry-pick" + } else if git_dir.join("REVERT_HEAD").exists() { + "revert" + } else { + return None; + }; + Some(op.to_string()) +} + +/// Git commands that change a checkout's state while they run — what a +/// band waits out. Reads (`status`, `rev-list`, nebula's own) are not. +const WRITING_GIT: &[&str] = &[ + "submodule", + "clone", + "fetch", + "pull", + "rebase", + "merge", + "am", + "cherry-pick", + "revert", + "checkout", + "switch", +]; + +/// The git operation one of `processes` (working in the checkout) is +/// running, from its command line: the oldest such process's, which is +/// the one the user started (`git submodule update` over the clones it +/// runs). +async fn live_git_op(processes: &[WorktreeProcess]) -> Option { + let pids: Vec = processes + .iter() + .filter(|p| p.name.starts_with("git")) + .map(|p| p.pid.to_string()) + .collect(); + if pids.is_empty() { + return None; + } + let out = tokio::process::Command::new("ps") + .args(["-o", "pid=,args=", "-p", &pids.join(",")]) + .output() + .await + .ok()?; + let mut ops: Vec<(u32, String)> = String::from_utf8_lossy(&out.stdout) + .lines() + .filter_map(|line| { + let (pid, args) = line.trim().split_once(' ')?; + Some((pid.parse().ok()?, git_op_of(args)?)) + }) + .collect(); + ops.sort(); + ops.into_iter().next().map(|(_, op)| op) +} + +/// The operation a git command line runs (`git -c x=y submodule update +/// --init` is `submodule update`), when it is one of [`WRITING_GIT`]. +fn git_op_of(args: &str) -> Option { + let mut words = args.split_whitespace(); + let program = words.next()?; + if !program.rsplit('/').next()?.starts_with("git") { + return None; + } + let mut words = words.peekable(); + // Skip the global options, and the value of those that take one. + while let Some(word) = words.peek() { + match *word { + "-c" | "-C" => { + words.next(); + words.next(); + } + w if w.starts_with('-') => { + words.next(); + } + _ => break, + } + } + let command = words.next()?; + let command = command.strip_suffix("--helper").unwrap_or(command); + if !WRITING_GIT.contains(&command) { + return None; + } + if command == "submodule" { + if let Some(sub) = words.find(|w| !w.starts_with('-')) { + return Some(format!("submodule {sub}")); + } + } + Some(command.to_string()) +} + /// What git has to say about the checkout: its changed paths and how -/// recently they were written, and its commits the base lacks — or, on a -/// detached HEAD, the commits nothing else holds. -async fn git_half(repo: &Path, worktree: &Path, configured_base: Option) -> WorktreeCheck { +/// recently they were written, its commits the base lacks by content — or, +/// on a detached HEAD, the commits nothing else holds — and its +/// submodules' commits that live in this checkout's store alone. +/// `ignored` also lists the gitignored files a delete takes: a confirm's +/// question, not a band's, which would pay for it every rotation. +async fn git_half( + repo: &Path, + worktree: &Path, + configured_base: Option, + ignored: bool, +) -> WorktreeCheck { let mut check = WorktreeCheck::default(); + let mut landing = WorktreeLanding::default(); + // The four questions are independent; asked side by side, the check + // takes as long as the slowest (the submodules, in a repo with many). + let (status, commits, submodules, keepsakes) = tokio::join!( + git::changed_paths(worktree), + commits_half(repo, worktree, configured_base), + git::submodule_unpushed(worktree, repo), + async { + match ignored { + true => Some(git::ignored_keepsakes(worktree).await), + false => None, + } + }, + ); // The first thing git could not answer is the one the confirm names. let note = |e: anyhow::Error, check: &mut WorktreeCheck| { check.git_error.get_or_insert_with(|| format!("{e:#}")); }; - match git::changed_paths(worktree).await { + match status { Ok(paths) => { check.changes = u32::try_from(paths.len()).unwrap_or(u32::MAX); + let untracked = paths.iter().filter(|p| p.untracked).count(); + landing.untracked = u32::try_from(untracked).unwrap_or(u32::MAX); + let paths: Vec = paths.into_iter().map(|p| p.path).collect(); check.newest_change_ms = newest_write_ms(worktree, &paths); } Err(e) => note(e, &mut check), } - if git::head_detached(worktree).await { - check.detached = true; - match git::unreferenced_commits(worktree).await { - Ok(n) => check.ahead = n, - Err(e) => note(e, &mut check), + check.detached = commits.detached; + check.ahead = commits.ahead; + check.base = commits.base; + landing.branch_landed = commits.branch_landed; + landing.branch = commits.tip; + if let Some(e) = commits.error { + note(e, &mut check); + } + match submodules { + Ok(n) => landing.submodule_unpushed = n, + Err(e) => { + // Which submodule, when it is the failure the check names. + if check.git_error.is_none() { + landing.failed_submodule = e + .downcast_ref::() + .map(|failed| failed.path.clone()); + } + note(e.context("submodules"), &mut check); } - } else if let Some(base) = git::comparison_base(repo, configured_base.as_deref()).await { - match git::commits_ahead(worktree, &base).await { - Ok(n) => check.ahead = n, - Err(e) => note(e, &mut check), + } + match keepsakes { + Some(Ok(files)) => { + landing.ignored_total = u32::try_from(files.len()).unwrap_or(u32::MAX); + landing.ignored_files = files.into_iter().take(IGNORED_NAMED).collect(); } - check.base = Some(base); - } else { - // Nothing to compare with is not "nothing its base lacks". - note( - anyhow::anyhow!("found no base branch to compare its commits with"), - &mut check, - ); + Some(Err(e)) => note(e.context("ignored files"), &mut check), + None => {} } match git::worktree_lock(repo, worktree).await { Ok(lock) => check.locked = lock, Err(e) => note(e, &mut check), } + check.landing = Some(landing); check } +/// What [`commits_half`] found about the checkout's commits. +#[derive(Default)] +struct Commits { + detached: bool, + ahead: u32, + base: Option, + tip: Option, + branch_landed: bool, + /// The first thing git could not answer. + error: Option, +} + +/// The checkout's commits its base lacks by content — or, on a detached +/// HEAD, the commits nothing else holds — and where its branch stands. +async fn commits_half(repo: &Path, worktree: &Path, configured_base: Option) -> Commits { + let mut out = Commits::default(); + if git::head_detached(worktree).await { + out.detached = true; + match git::unreferenced_commits(worktree).await { + Ok(n) => out.ahead = n, + Err(e) => out.error = Some(e), + } + } else if let Some(base) = git::comparison_base(repo, configured_base.as_deref()).await { + // The branch and tip judged here are the ones a confirm hands back + // to the delete: counted at that tip, so the verdict and the + // commit the delete compares against are the same. + let tip = git::branch_tip(worktree).await; + let head = tip.as_ref().map_or("HEAD", |t| t.oid.as_str()); + match git::unlanded_commits(worktree, &base, head).await { + Ok(n) => { + out.ahead = n; + out.branch_landed = n == 0 + && match &tip { + Some(t) => !git::protected_branch(repo, &t.name, Some(&base)).await, + None => false, + }; + } + Err(e) => out.error = Some(e), + } + out.tip = tip; + out.base = Some(base); + } else { + // Nothing to compare with is not "nothing its base lacks". + out.error = Some(anyhow::anyhow!( + "found no base branch to compare its commits with" + )); + } + out +} + /// Epoch ms of the newest modification among `paths` (relative to /// `root`); a path that is gone — a deletion git reports — has none. fn newest_write_ms(root: &Path, paths: &[PathBuf]) -> Option { @@ -163,8 +389,10 @@ fn process_cwds() -> Result> { } /// Every process this user can see, with its working directory: one -/// `lsof` asked for the `cwd` of everything, a few hundred ms on a busy -/// Mac — this runs once per confirm, never on a beat. +/// `lsof` asked for the `cwd` of everything, under a tenth of a second +/// to a few hundred ms on a busy Mac. It runs once per confirm, and once +/// per idle band's check (at most one each git tick), to see a git at +/// work in the checkout. #[cfg(not(target_os = "linux"))] fn process_cwds() -> Result> { let out = std::process::Command::new("lsof") @@ -278,11 +506,189 @@ mod tests { } /// A checkout already gone from disk has nothing left to lose. + #[test] + fn a_git_command_line_names_its_operation() { + assert_eq!( + git_op_of("git -c protocol.file.allow=always submodule update --init").as_deref(), + Some("submodule update") + ); + assert_eq!( + git_op_of("/usr/libexec/git-core/git submodule--helper update --init").as_deref(), + Some("submodule update") + ); + assert_eq!( + git_op_of("git -C /w rebase -i main").as_deref(), + Some("rebase") + ); + assert_eq!(git_op_of("git status --porcelain"), None); + assert_eq!(git_op_of("cargo build"), None); + } + + /// A rebase stopped part-way is a state, not a failure: the check says + /// which, and that it is not running on its own. + #[tokio::test] + async fn a_stopped_rebase_is_named() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repo"); + std::fs::create_dir(&repo).unwrap(); + let run = |args: &[&str]| { + let out = std::process::Command::new("git") + .arg("-C") + .arg(&repo) + .args(args) + .env("GIT_AUTHOR_NAME", "t") + .env("GIT_AUTHOR_EMAIL", "t@t") + .env("GIT_COMMITTER_NAME", "t") + .env("GIT_COMMITTER_EMAIL", "t@t") + .output() + .unwrap(); + out.status.success() + }; + assert!(run(&["init", "-q", "-b", "main"])); + std::fs::write(repo.join("a.txt"), "base\n").unwrap(); + assert!(run(&["add", "a.txt"])); + assert!(run(&["commit", "-q", "-m", "base"])); + assert!(run(&["switch", "-q", "-c", "feat"])); + std::fs::write(repo.join("a.txt"), "feat\n").unwrap(); + assert!(run(&["commit", "-q", "-am", "feat"])); + assert!(run(&["switch", "-q", "main"])); + std::fs::write(repo.join("a.txt"), "main\n").unwrap(); + assert!(run(&["commit", "-q", "-am", "main"])); + assert!(run(&["switch", "-q", "feat"])); + assert!(!run(&["rebase", "-q", "main"]), "conflicts and stops"); + let check = check(&repo, &repo, None, None).await; + let landing = check.landing.expect("a landing"); + assert_eq!(landing.git_op.as_deref(), Some("rebase")); + assert!(!landing.git_op_live); + } + + /// A `git submodule update` at work in the checkout is named, live, + /// by a band's check and a confirm's — slowed here by a hook that + /// sleeps on every ref update, so the check lands while it runs. + #[tokio::test] + async fn a_running_submodule_update_is_named() { + let tmp = tempfile::tempdir().unwrap(); + let root = std::fs::canonicalize(tmp.path()).unwrap(); + let run = |dir: &Path, args: &[&str]| { + let out = std::process::Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .env("GIT_AUTHOR_NAME", "t") + .env("GIT_AUTHOR_EMAIL", "t@t") + .env("GIT_COMMITTER_NAME", "t") + .env("GIT_COMMITTER_EMAIL", "t@t") + .output() + .unwrap(); + assert!(out.status.success(), "{args:?}: {out:?}"); + }; + let repo = root.join("repo"); + let up = root.join("up"); + for dir in [&repo, &up] { + std::fs::create_dir(dir).unwrap(); + run(dir, &["init", "-q", "-b", "main"]); + run(dir, &["commit", "-q", "--allow-empty", "-m", "init"]); + } + let url = up.to_string_lossy().into_owned(); + run( + &repo, + &[ + "-c", + "protocol.file.allow=always", + "submodule", + "add", + "-q", + &url, + "sub", + ], + ); + run(&repo, &["commit", "-q", "-m", "sub"]); + let wt = root.join("wt"); + run( + &repo, + &["worktree", "add", "-q", &wt.to_string_lossy(), "-b", "feat"], + ); + let hooks = root.join("hooks"); + std::fs::create_dir(&hooks).unwrap(); + let hook = hooks.join("reference-transaction"); + std::fs::write(&hook, "#!/bin/sh\ncat >/dev/null\nsleep 2\n").unwrap(); + use std::os::unix::fs::PermissionsExt; + use std::os::unix::process::CommandExt; + std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap(); + let mut update = std::process::Command::new("git") + .arg("-C") + .arg(&wt) + .args([ + "-c", + &format!("core.hooksPath={}", hooks.display()), + "-c", + "protocol.file.allow=always", + "submodule", + "update", + "--init", + "-q", + ]) + .stderr(std::process::Stdio::null()) + .process_group(0) + .spawn() + .unwrap(); + let mut seen = None; + for _ in 0..40 { + let band = check(&repo, &wt, None, None).await; + if let Some(op) = band.landing.and_then(|l| l.git_op_live.then_some(l.git_op)) { + seen = op; + break; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + let confirm = check(&repo, &wt, Some(HashSet::new()), None).await; + // The update and the clone under it, before the repo goes. + let group = nix::unistd::Pid::from_raw(update.id() as i32); + let _ = nix::sys::signal::killpg(group, nix::sys::signal::Signal::SIGKILL); + let _ = update.wait(); + assert_eq!(seen.as_deref(), Some("submodule update")); + let landing = confirm.landing.expect("a landing"); + assert!(landing.git_op.is_some(), "{landing:?}"); + } + + /// A submodule git cannot read fails the check, naming the submodule. + #[tokio::test] + async fn a_submodule_git_cannot_read_is_named() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repo"); + std::fs::create_dir(&repo).unwrap(); + let run = |args: &[&str]| { + let out = std::process::Command::new("git") + .arg("-C") + .arg(&repo) + .args(args) + .env("GIT_AUTHOR_NAME", "t") + .env("GIT_AUTHOR_EMAIL", "t@t") + .env("GIT_COMMITTER_NAME", "t") + .env("GIT_COMMITTER_EMAIL", "t@t") + .output() + .unwrap(); + assert!(out.status.success(), "{out:?}"); + String::from_utf8_lossy(&out.stdout).into_owned() + }; + run(&["init", "-q", "-b", "main"]); + run(&["commit", "-q", "--allow-empty", "-m", "init"]); + let broken = Path::new(run(&["rev-parse", "--absolute-git-dir"]).trim()) + .join("modules") + .join("half"); + std::fs::create_dir_all(broken.join("objects")).unwrap(); + std::fs::write(broken.join("HEAD"), "not a ref\n").unwrap(); + let check = check(&repo, &repo, None, None).await; + assert!(check.git_error.is_some(), "{check:?}"); + let landing = check.landing.expect("a landing"); + assert_eq!(landing.failed_submodule.as_deref(), Some("half")); + } + #[tokio::test] async fn a_missing_checkout_checks_clean() { let tmp = tempfile::tempdir().unwrap(); let gone = tmp.path().join("gone"); - let check = check(tmp.path(), &gone, HashSet::new(), None).await; + let check = check(tmp.path(), &gone, Some(HashSet::new()), None).await; assert!(!check.has_findings(), "{check:?}"); } } diff --git a/crates/nebula-tui/src/app.rs b/crates/nebula-tui/src/app.rs index b1674fbd..f9c9acd3 100644 --- a/crates/nebula-tui/src/app.rs +++ b/crates/nebula-tui/src/app.rs @@ -4381,6 +4381,9 @@ pub struct JobState { pub worktree_changes: HashMap, std::time::Instant)>, /// The checkout the sweep is reading right now; its answer clears it. pub worktree_changes_inflight: Option, + /// The DELETE CHECK's git half for each idle band, and when it was + /// read: what its line says a delete would lose (`band_safety`). + pub band_checks: crate::band_safety::BandChecks, /// The lines added and removed in each checkout, read beside its /// changed-file count: what its cards print after `+3 files`. Only a /// checkout with changed lines has an entry. diff --git a/crates/nebula-tui/src/band_safety.rs b/crates/nebula-tui/src/band_safety.rs new file mode 100644 index 00000000..924f8857 --- /dev/null +++ b/crates/nebula-tui/src/band_safety.rs @@ -0,0 +1,468 @@ +//! What an idle band says a delete would lose, before anyone presses `d`. +//! +//! An EMPTY BAND used to 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` — or a question to an agent — before it could be cleared. Each idle +//! band now carries the DELETE CHECK's git half (`ClientRequest::CheckWorktree` +//! with `git_only`; the process sweep stays with the confirm), asked in the +//! background on the git tick: one band per tick, the least recently read +//! first, at most one in flight, and none read again sooner than +//! [`FRESH_FOR`]. Its verdict takes the place of `nothing running`, in +//! git's own compact vocabulary (`git status -sb`, shell prompts): +//! +//! - `✓ clean · merged` — nothing destroyed, and the branch has landed; +//! - `✓ clean · detached` — nothing destroyed, on no branch; +//! - `↑4 unmerged` — the branch holds commits its base lacks (a delete +//! keeps the branch, and them); +//! - `⚠ 3 modified · 2 untracked`, `⚠ 6 unpushed in submodules`, +//! `⚠ detached · 2 unreachable` — what a delete destroys. +//! +//! "Merged" is counted by content, not ancestry: a branch landed by squash +//! merge, rebase or cherry-pick reads merged. Before the first read a band +//! says what it always did. A daemon too old to answer is never asked. + +use crate::app::{App, PendingIntent}; +use nebula_core::{ClientRequest, WorktreeCheck, WorktreeId, BRANCH_LANDING_PROTOCOL}; +use std::collections::{HashMap, HashSet}; +use std::time::{Duration, Instant}; + +/// A band read this recently is not read again: an idle checkout changes +/// rarely, and a project with one idle band would otherwise pay a `git +/// status`, a `git cherry` and a walk of its submodules every tick. +pub(crate) const FRESH_FOR: Duration = Duration::from_secs(30); + +/// How many unsettled checks in a row — a failure, or a git operation +/// stopped part-way — a band re-reads on the very next tick before it +/// goes back to [`FRESH_FOR`], so a repo that stays broken (or a rebase +/// left for the evening) does not spawn git every two seconds. A git +/// still running in the checkout is re-read every tick however long it +/// takes: it ends on its own. +pub(crate) const FAST_RETRIES: u32 = 3; + +/// How long a band check may stay out before the sweep stops waiting for +/// it and moves on; its answer, if it ever comes, is dropped. +pub(crate) const PATIENCE: Duration = Duration::from_secs(30); + +/// The band checks: the last answer per checkout and when it came, the +/// one in flight, and the ones given up on. +#[derive(Debug, Default)] +pub struct BandChecks { + pub entries: HashMap, + inflight: Option<(u64, WorktreeId, Instant)>, + /// Checks the sweep stopped waiting for: their answer or error is + /// dropped without a word. + dropped: HashSet, + /// How many unsettled checks in a row each band has had + /// ([`FAST_RETRIES`]); a settled one clears it. + unsettled: HashMap, +} + +/// Is `check` one to read again soon — a failure, or a git operation +/// under way? +fn unsettled(check: &WorktreeCheck) -> bool { + check.git_error.is_some() || check.landing.as_ref().is_some_and(|l| l.git_op.is_some()) +} + +/// Is a git process at work in the checkout `check` read? +fn git_running(check: &WorktreeCheck) -> bool { + check + .landing + .as_ref() + .is_some_and(|l| l.git_op.is_some() && l.git_op_live) +} + +/// Ask about one more idle band, when one is due. Run on the git tick. +pub(crate) fn sweep(app: &mut App, out: &mut Vec) { + if app.requests.daemon_protocol < BRANCH_LANDING_PROTOCOL { + return; + } + let tree = &app.tree.worktrees; + let checks = &mut app.jobs.band_checks; + checks + .entries + .retain(|id, _| tree.iter().any(|w| &w.id == id)); + if let Some((req_id, _, since)) = &checks.inflight { + if since.elapsed() < PATIENCE { + return; + } + checks.dropped.insert(*req_id); + checks.inflight = None; + } + let Some(id) = target(app) else { + return; + }; + let req_id = app.alloc_req_id(PendingIntent::None); + out.push(ClientRequest::CheckWorktree { + req_id, + id: id.clone(), + git_only: true, + }); + app.jobs.band_checks.inflight = Some((req_id, id, Instant::now())); +} + +/// The idle band this tick reads: of the EMPTY BANDS the grid draws (the +/// ROOT WORKTREE's aside: it is never deleted), the one read least +/// recently, never read first — and none read within [`FRESH_FOR`], +/// unless its last read was unsettled ([`due`]). +pub(crate) fn target(app: &App) -> Option { + if !app.launcher_grid() { + return None; + } + let entries = &app.jobs.band_checks.entries; + crate::launcher::bands(app) + .into_iter() + .filter(|b| b.cards.is_empty() && !b.is_main && !app.is_placeholder_worktree(&b.worktree)) + .map(|b| b.worktree) + .filter(|id| due(&app.jobs.band_checks, id)) + .min_by_key(|id| entries.get(id).map(|(_, at)| *at)) +} + +/// Is band `id` due a read? Never read, read [`FRESH_FOR`] ago, or last +/// read unsettled: a git still running there, or a failure or paused +/// operation within its first [`FAST_RETRIES`]. +fn due(checks: &BandChecks, id: &WorktreeId) -> bool { + let Some((check, at)) = checks.entries.get(id) else { + return true; + }; + at.elapsed() >= FRESH_FOR + || git_running(check) + || (unsettled(check) && checks.unsettled.get(id).copied().unwrap_or(0) <= FAST_RETRIES) +} + +/// A `WorktreeChecked` for the band check in flight: kept for the band's +/// line. False when `req_id` is no band check's, and the answer is +/// someone else's (a confirm's). +pub(crate) fn land(app: &mut App, req_id: u64, id: &WorktreeId, check: &WorktreeCheck) -> bool { + let checks = &mut app.jobs.band_checks; + if checks.dropped.remove(&req_id) { + app.requests.pending.remove(&req_id); + return true; + } + if !matches!(&checks.inflight, Some((r, _, _)) if *r == req_id) { + return false; + } + checks.inflight = None; + app.requests.pending.remove(&req_id); + note(app, id, check.clone()); + true +} + +/// A confirm's check, or a band's: the band shows the newest. Only a check +/// that says where the branch stands is any use to the band. +pub(crate) fn note(app: &mut App, id: &WorktreeId, check: WorktreeCheck) { + if check.landing.is_none() { + return; + } + if unsettled(&check) { + *app.jobs + .band_checks + .unsettled + .entry(id.clone()) + .or_default() += 1; + } else { + app.jobs.band_checks.unsettled.remove(id); + } + let before = app + .jobs + .band_checks + .entries + .insert(id.clone(), (check.clone(), Instant::now())); + if before.is_none_or(|(was, _)| verdict(&was) != verdict(&check)) { + app.chrome.dirty = true; + } +} + +/// An `Error` for a band check: the band says it could not look, and is +/// asked again in its turn — never a flash, which a background read the +/// user did not ask for has no business raising. True when `req_id` was +/// a band check's. +pub(crate) fn land_error(app: &mut App, req_id: u64, message: &str) -> bool { + let checks = &mut app.jobs.band_checks; + if checks.dropped.remove(&req_id) { + app.requests.pending.remove(&req_id); + return true; + } + let Some((_, id, _)) = checks.inflight.take_if(|(r, _, _)| *r == req_id) else { + return false; + }; + app.requests.pending.remove(&req_id); + let failed = WorktreeCheck { + git_error: Some(message.to_string()), + landing: Some(Default::default()), + ..WorktreeCheck::default() + }; + note(app, &id, failed); + true +} + +/// How a band's verdict is drawn. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Tone { + /// Nothing a delete loses: dim, like the rest of the line. + Clean, + /// Commits the branch keeps. + Ahead, + /// What a delete destroys: the warning color. + Warn, +} + +/// What a band's line says about its checkout. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Verdict { + pub text: String, + pub tone: Tone, +} + +/// The band's verdict on `check`: None when there is nothing to say — a +/// daemon that did not say where the branch stands, or a branch that is +/// its base's own. +pub(crate) fn verdict(check: &WorktreeCheck) -> Option { + let landing = check.landing.as_ref()?; + // A git at work: the checkout is in flux, and that is what there is to + // say — not an error, and not a warning. One stopped part-way (a + // rebase on a conflict) says so too, but never hides what a delete + // would destroy: that leads, in the warning color, below. + let paused = match &landing.git_op { + Some(op) if landing.git_op_live || check.git_error.is_some() => { + let state = if landing.git_op_live { + "running" + } else { + "in progress" + }; + return Some(Verdict { + text: format!("⋯ git {op} {state}"), + tone: Tone::Clean, + }); + } + Some(op) => Some(format!("git {op} in progress")), + None => None, + }; + if check.git_error.is_some() { + let text = match &landing.failed_submodule { + Some(path) => format!("? can't read submodule {path} · retrying"), + None => "? can't read git state · retrying".into(), + }; + return Some(Verdict { + text, + tone: Tone::Clean, + }); + } + let mut lost = Vec::new(); + let modified = check.changes.saturating_sub(landing.untracked); + if modified > 0 { + lost.push(format!("{modified} modified")); + } + if landing.untracked > 0 { + lost.push(format!("{} untracked", landing.untracked)); + } + if landing.submodule_unpushed > 0 { + lost.push(format!( + "{} unpushed in submodules", + landing.submodule_unpushed + )); + } + if check.detached && check.ahead > 0 { + lost.push(format!("detached · {} unreachable", check.ahead)); + } + let unmerged = + (!check.detached && check.ahead > 0).then(|| format!("↑{} unmerged", check.ahead)); + if !lost.is_empty() { + let mut text = format!("⚠ {}", lost.join(" · ")); + // Mid-operation the branch's count means little; the operation + // is what to know beside what would be lost. + if let Some(paused) = &paused { + text.push_str(" · "); + text.push_str(paused); + } else if let Some(unmerged) = unmerged { + text.push_str(" · "); + text.push_str(&unmerged); + } + return Some(Verdict { + text, + tone: Tone::Warn, + }); + } + if let Some(paused) = paused { + return Some(Verdict { + text: format!("⋯ {paused}"), + tone: Tone::Clean, + }); + } + if let Some(text) = unmerged { + return Some(Verdict { + text, + tone: Tone::Ahead, + }); + } + let text = if check.detached { + "✓ clean · detached" + } else if landing.branch_landed { + "✓ clean · merged" + } else { + return None; + }; + Some(Verdict { + text: text.into(), + tone: Tone::Clean, + }) +} + +/// The verdict for `id`'s band, once one has been read. +pub(crate) fn band_verdict(app: &App, id: &WorktreeId) -> Option { + let (check, _) = app.jobs.band_checks.entries.get(id)?; + verdict(check) +} + +#[cfg(test)] +mod tests { + use super::*; + use nebula_core::WorktreeLanding; + + fn with(landing: WorktreeLanding, check: WorktreeCheck) -> WorktreeCheck { + WorktreeCheck { + landing: Some(landing), + ..check + } + } + + fn text(check: &WorktreeCheck) -> Option<(String, Tone)> { + verdict(check).map(|v| (v.text, v.tone)) + } + + #[test] + fn each_state_reads_in_gits_own_words() { + let landed = WorktreeLanding { + branch_landed: true, + ..WorktreeLanding::default() + }; + assert_eq!( + text(&with(landed.clone(), WorktreeCheck::default())), + Some(("✓ clean · merged".into(), Tone::Clean)) + ); + let detached = WorktreeCheck { + detached: true, + ..WorktreeCheck::default() + }; + assert_eq!( + text(&with(WorktreeLanding::default(), detached.clone())), + Some(("✓ clean · detached".into(), Tone::Clean)) + ); + let ahead = WorktreeCheck { + ahead: 4, + base: Some("origin/main".into()), + ..WorktreeCheck::default() + }; + assert_eq!( + text(&with(WorktreeLanding::default(), ahead)), + Some(("↑4 unmerged".into(), Tone::Ahead)) + ); + let dirty = WorktreeCheck { + changes: 5, + ..WorktreeCheck::default() + }; + let untracked = WorktreeLanding { + untracked: 2, + ..WorktreeLanding::default() + }; + assert_eq!( + text(&with(untracked, dirty)), + Some(("⚠ 3 modified · 2 untracked".into(), Tone::Warn)) + ); + let submodules = WorktreeLanding { + submodule_unpushed: 6, + branch_landed: true, + ..WorktreeLanding::default() + }; + assert_eq!( + text(&with(submodules, WorktreeCheck::default())), + Some(("⚠ 6 unpushed in submodules".into(), Tone::Warn)) + ); + let loose = WorktreeCheck { + ahead: 2, + ..detached + }; + assert_eq!( + text(&with(WorktreeLanding::default(), loose)), + Some(("⚠ detached · 2 unreachable".into(), Tone::Warn)) + ); + } + + #[test] + fn what_a_delete_destroys_leads_and_the_branchs_commits_follow() { + let check = WorktreeCheck { + changes: 3, + ahead: 4, + ..WorktreeCheck::default() + }; + assert_eq!( + text(&with(WorktreeLanding::default(), check)), + Some(("⚠ 3 modified · ↑4 unmerged".into(), Tone::Warn)) + ); + } + + #[test] + fn an_old_daemon_a_failed_check_or_the_bases_own_branch_say_no_more() { + assert_eq!(text(&WorktreeCheck::default()), None, "no landing"); + assert_eq!( + text(&with(WorktreeLanding::default(), WorktreeCheck::default())), + None, + "not landed, nothing ahead: the base's own branch" + ); + let failed = WorktreeCheck { + git_error: Some("boom".into()), + ..WorktreeCheck::default() + }; + let (words, tone) = text(&with(WorktreeLanding::default(), failed)).unwrap(); + assert!(!words.starts_with('✓'), "{words}"); + assert_eq!(tone, Tone::Clean); + } + + #[test] + fn a_git_at_work_or_a_failure_says_what_and_never_warns() { + let live = WorktreeLanding { + git_op: Some("submodule update".into()), + git_op_live: true, + ..WorktreeLanding::default() + }; + assert_eq!( + text(&with(live, WorktreeCheck::default())), + Some(("⋯ git submodule update running".into(), Tone::Clean)) + ); + let paused = WorktreeLanding { + git_op: Some("rebase".into()), + ..WorktreeLanding::default() + }; + let dirty = WorktreeCheck { + changes: 2, + ..WorktreeCheck::default() + }; + assert_eq!( + text(&with(paused.clone(), WorktreeCheck::default())), + Some(("⋯ git rebase in progress".into(), Tone::Clean)) + ); + assert_eq!( + text(&with(paused, dirty)), + Some(("⚠ 2 modified · git rebase in progress".into(), Tone::Warn)), + "a paused rebase never hides what a delete destroys" + ); + let failed = WorktreeCheck { + git_error: Some("boom".into()), + ..WorktreeCheck::default() + }; + let submodule = WorktreeLanding { + failed_submodule: Some("lib/a".into()), + ..WorktreeLanding::default() + }; + assert_eq!( + text(&with(submodule, failed.clone())), + Some(( + "? can't read submodule lib/a · retrying".into(), + Tone::Clean + )) + ); + assert_eq!( + text(&with(WorktreeLanding::default(), failed)), + Some(("? can't read git state · retrying".into(), Tone::Clean)) + ); + } +} diff --git a/crates/nebula-tui/src/config.rs b/crates/nebula-tui/src/config.rs index b5b7ce75..4e07107f 100644 --- a/crates/nebula-tui/src/config.rs +++ b/crates/nebula-tui/src/config.rs @@ -438,6 +438,7 @@ pub enum SettingKind { FeedbackSound, PresetText, DeleteEmptyWorktree, + DeleteLandedBranch, AutoCleanupMerged, ShowAllWorktrees, Appearance, @@ -560,6 +561,7 @@ impl SettingKind { SettingKind::ConfirmDragMove => (2026, 10, 2), SettingKind::SourceControlGraph | SettingKind::InlineGraphics => (2026, 10, 9), SettingKind::Appearance | SettingKind::DiffInline => (2026, 10, 28), + SettingKind::DeleteLandedBranch => (2026, 10, 10), } } @@ -680,6 +682,12 @@ pub const SETTINGS_TABS: &[SettingsTab] = &[ hint: "Deleting a worktree's last session or terminal deletes the worktree with it, no question asked (off = that delete's confirm asks first)", group: "", }, + SettingSpec { + kind: SettingKind::DeleteLandedBranch, + label: "Delete landed branch", + hint: "Deleting a worktree whose branch has landed on its base (by content: squash and cherry-pick count) deletes the branch too; k in the confirm keeps it (off = kept unless D)", + group: "", + }, SettingSpec { kind: SettingKind::AutoCleanupMerged, label: "Auto-clean merged worktrees", @@ -1200,6 +1208,15 @@ pub struct Config { /// [`Config::show_all_worktrees`] on too: only the question is that /// setting's to drop. pub delete_empty_worktree: bool, + /// DELETE LANDED BRANCH: a worktree delete whose DELETE CHECK found + /// its branch landed on its base — every commit there by content, so + /// a squash merge or a cherry-pick counts — deletes the branch with + /// the checkout; `k` in the confirm keeps it. On by default. Off, the + /// branch is kept unless `D` says otherwise. A branch with unlanded + /// commits is kept by default either way. `git worktree remove` + /// alone leaves every branch behind, and nebula lists worktrees, not + /// branches: a kept branch is one nobody sees again. + pub delete_landed_branch: bool, /// AUTO-CLEAN MERGED WORKTREES: when the PR row for a linked worktree is /// a trustworthy merge, ask the daemon to archive idle sessions there /// and remove the checkout from disk. Off by default; root/default/base @@ -1638,6 +1655,7 @@ impl Default for Config { feedback_sound: "Sosumi".into(), preset_text: PresetText::DEFAULT.as_str().into(), delete_empty_worktree: false, + delete_landed_branch: true, auto_cleanup_merged: false, show_all_worktrees: true, theme: "default".into(), @@ -2568,6 +2586,7 @@ impl Config { SettingKind::FeedbackSound => self.feedback_sound.clone(), SettingKind::PresetText => self.preset_text().as_str().into(), SettingKind::DeleteEmptyWorktree => on_off(self.delete_empty_worktree).into(), + SettingKind::DeleteLandedBranch => on_off(self.delete_landed_branch).into(), SettingKind::AutoCleanupMerged => on_off(self.auto_cleanup_merged).into(), SettingKind::ShowAllWorktrees => on_off(self.show_all_worktrees).into(), SettingKind::AskBeforeArchive => on_off(self.ask_before_archive).into(), @@ -2665,6 +2684,9 @@ impl Config { SettingKind::DeleteEmptyWorktree => { self.delete_empty_worktree = !self.delete_empty_worktree; } + SettingKind::DeleteLandedBranch => { + self.delete_landed_branch = !self.delete_landed_branch; + } SettingKind::AutoCleanupMerged => { self.auto_cleanup_merged = !self.auto_cleanup_merged; } @@ -3856,6 +3878,32 @@ mod tests { assert!(cfg.delete_empty_worktree); } + /// DELETE LANDED BRANCH starts on, sits on the Sessions tab beside + /// DELETE EMPTIED WORKTREE, toggles like any bool, and a config + /// predating the key reads as on. + #[test] + fn delete_landed_branch_is_on_by_default_and_toggles() { + let mut cfg = Config::default(); + assert!(cfg.delete_landed_branch); + let (tab, row) = locate(SettingKind::DeleteLandedBranch).unwrap(); + assert_eq!(SETTINGS_TABS[tab].title, "Sessions"); + assert_eq!( + locate(SettingKind::DeleteEmptyWorktree), + Some((tab, row - 1)) + ); + assert_eq!(cfg.value_label(SettingKind::DeleteLandedBranch), "on"); + cfg.cycle(tab, row, 0); + assert!(!cfg.delete_landed_branch); + assert_eq!(cfg.value_label(SettingKind::DeleteLandedBranch), "off"); + cfg.cycle(tab, row, 1); + assert!(cfg.delete_landed_branch, "←/→ toggle it like Enter does"); + + let cfg: Config = serde_json::from_str("{}").unwrap(); + assert!(cfg.delete_landed_branch, "a missing key reads as on"); + let cfg: Config = serde_json::from_str(r#"{"delete_landed_branch": false}"#).unwrap(); + assert!(!cfg.delete_landed_branch); + } + /// AUTO-CLEAN MERGED WORKTREES is opt-in, sits on the Sessions tab, and /// persists under its own key. #[test] diff --git a/crates/nebula-tui/src/delete_check.rs b/crates/nebula-tui/src/delete_check.rs index b7666ad0..72c1cdad 100644 --- a/crates/nebula-tui/src/delete_check.rs +++ b/crates/nebula-tui/src/delete_check.rs @@ -14,9 +14,19 @@ //! subagent keeps its parent's working directory — so a band that reads //! "nothing running" can have a build running in it and edits nobody //! committed. +//! +//! From a daemon at [`BRANCH_LANDING_PROTOCOL`] the check also says where +//! the checkout's branch stands, and the confirm decides it: a branch +//! landed on its base (by content — squash merges and cherry-picks count) +//! goes with the checkout by default (the **Delete landed branch** +//! setting), `k` keeping it; a branch with unlanded commits is kept by +//! default, `D` discarding it. The confirm is the decision: the DAEMON +//! deletes what it was told, short of the branches no delete takes. use crate::app::{App, PendingAction}; -use nebula_core::{ClientRequest, WorktreeCheck, WorktreeId, CHECK_WORKTREE_PROTOCOL}; +use nebula_core::{ + ClientRequest, WorktreeCheck, WorktreeId, BRANCH_LANDING_PROTOCOL, CHECK_WORKTREE_PROTOCOL, +}; use std::collections::HashMap; use std::time::{Duration, Instant}; @@ -36,6 +46,10 @@ pub struct DeleteChecks { /// Checks still out when their confirm closed: their answer, or their /// error, is dropped without a word. dropped: std::collections::HashSet, + /// The **Delete landed branch** setting, read when the confirm's + /// checks were asked for (a confirm is modal: it cannot change under + /// one), so drawing never reads the config file. + delete_landed: bool, /// The footer line said while a confirm waits for its check, so its /// answer can take it down again — and nothing else's. status: Option, @@ -44,7 +58,7 @@ pub struct DeleteChecks { #[derive(Debug, Clone)] enum CheckState { Pending { req_id: u64, since: Instant }, - Done(WorktreeCheck), + Done(Box), Failed(String), } @@ -88,6 +102,13 @@ pub(crate) fn sync(app: &mut App, out: &mut Vec) { if app.requests.daemon_protocol < CHECK_WORKTREE_PROTOCOL { return; } + if targets + .iter() + .any(|id| !app.requests.delete_checks.entries.contains_key(id)) + { + app.requests.delete_checks.delete_landed = + crate::config::Config::load().delete_landed_branch; + } let asking: Vec = targets .iter() .filter(|id| !app.requests.delete_checks.entries.contains_key(*id)) @@ -110,6 +131,7 @@ pub(crate) fn sync(app: &mut App, out: &mut Vec) { out.push(ClientRequest::CheckWorktree { req_id, id: id.clone(), + git_only: false, }); app.requests.delete_checks.entries.insert( id, @@ -175,15 +197,127 @@ pub(crate) fn land(app: &mut App, req_id: u64, id: WorktreeId, check: WorktreeCh app.requests.delete_checks.dropped.remove(&req_id); if let Some(state) = app.requests.delete_checks.entries.get_mut(&id) { if matches!(state, CheckState::Pending { req_id: r, .. } if *r == req_id) { - *state = CheckState::Done(check); + *state = CheckState::Done(Box::new(check.clone())); app.chrome.dirty = true; if oldest_pending(app).is_none() { clear_status(app); } + // The newest word on the checkout: its band shows it too. + crate::band_safety::note(app, &id, check); } } } +/// Where a checkout's branch stands, as its finished check says — what +/// the confirm offers to do with it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum BranchStanding { + /// Every commit is on `base` by content: deleting it loses nothing. + Landed { base: String }, + /// `commits` are on the branch and not on `base`. + Unlanded { commits: u32, base: String }, +} + +/// Where `id`'s branch stands, once its check has said — and only when it +/// said for certain: a DAEMON older than [`BRANCH_LANDING_PROTOCOL`], a +/// check still out or failed, a detached HEAD or a base it could not find +/// all leave the branch out of it, and kept. +pub(crate) fn branch_standing(app: &App, id: &WorktreeId) -> Option { + if app.requests.daemon_protocol < BRANCH_LANDING_PROTOCOL { + return None; + } + let CheckState::Done(check) = app.requests.delete_checks.entries.get(id)? else { + return None; + }; + standing_of(check) +} + +fn standing_of(check: &WorktreeCheck) -> Option { + let landing = check.landing.as_ref()?; + if check.detached || check.git_error.is_some() { + return None; + } + let base = check.base.clone()?; + // A branch the delete could take is one the check named, with its tip + // — and never mid-operation: a rebase or an update under way has the + // branch in flux, so it stays whatever the check counted. + landing.branch.as_ref()?; + if landing.git_op.is_some() { + return None; + } + if landing.branch_landed { + Some(BranchStanding::Landed { base }) + } else if check.ahead > 0 { + Some(BranchStanding::Unlanded { + commits: check.ahead, + base, + }) + } else { + // Nothing of its own, yet not landed: the base's own branch, or + // the root's, which no delete takes. + None + } +} + +/// The branch and tip `id`'s finished check judged — what a delete that +/// takes the branch hands back, so the DAEMON deletes exactly that. +pub(crate) fn judged_tip(app: &App, id: &WorktreeId) -> Option { + branch_standing(app, id)?; + let CheckState::Done(check) = app.requests.delete_checks.entries.get(id)? else { + return None; + }; + check.branch_tip().cloned() +} + +/// What `Enter` does with `id`'s branch: delete it at the tip the check +/// judged — only a landed one, and only with **Delete landed branch** on +/// — or keep it (None). +pub(crate) fn default_delete_branch(app: &App, id: &WorktreeId) -> Option { + let landed = matches!( + branch_standing(app, id), + Some(BranchStanding::Landed { .. }) + ); + if landed && app.requests.delete_checks.delete_landed { + judged_tip(app, id) + } else { + None + } +} + +/// The branch keys a worktree's own confirm offers beside `Enter`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum BranchKeys { + /// `Enter` deletes the branch too; `k` keeps it. + DeleteByDefault, + /// `Enter` keeps the branch; `D` deletes it, and its `unlanded` + /// commits with it. + KeepByDefault { unlanded: u32 }, +} + +/// The worktree whose branch the confirm for `action` decides: a band's +/// own `d`, or a card's delete that takes its emptied worktree too — one +/// worktree either way (a bulk delete takes the default for each). +pub(crate) fn branch_decided_by(action: &PendingAction) -> Option<&WorktreeId> { + match action { + PendingAction::DeleteWorktree(id) => Some(id), + PendingAction::ThenDeleteWorktree { worktree, .. } => Some(worktree), + _ => None, + } +} + +/// The branch keys the confirm for `action` offers, once its check has +/// said where the branch stands ([`branch_decided_by`]). +pub(crate) fn branch_keys(app: &App, action: &PendingAction) -> Option { + let id = branch_decided_by(action)?; + Some(match branch_standing(app, id)? { + BranchStanding::Landed { .. } if app.requests.delete_checks.delete_landed => { + BranchKeys::DeleteByDefault + } + BranchStanding::Landed { .. } => BranchKeys::KeepByDefault { unlanded: 0 }, + BranchStanding::Unlanded { commits, .. } => BranchKeys::KeepByDefault { unlanded: commits }, + }) +} + /// An `Error` the DAEMON sent for a check: the confirm says the check /// failed instead of the footer flashing it. True when `req_id` was a /// check's, and the error is handled. @@ -262,14 +396,23 @@ pub(crate) fn lines(app: &App, action: &PendingAction, now_ms: i64) -> Vec= BRANCH_LANDING_PROTOCOL; match states.as_slice() { [] => Vec::new(), - [(branch, state)] if ids.len() == 1 => single_lines(branch, state, now_ms), - _ => bulk_lines(&states, now_ms), + [(branch, state)] if ids.len() == 1 => single_lines(branch, state, now_ms, delete_landed), + _ => bulk_lines(&states, now_ms, delete_landed), } } -fn single_lines(branch: &str, state: &CheckState, now_ms: i64) -> Vec { +fn single_lines( + branch: &str, + state: &CheckState, + now_ms: i64, + delete_landed: bool, +) -> Vec { match state { CheckState::Pending { since, .. } if since.elapsed() < CHECK_PATIENCE => { vec![CheckLine::note(format!( @@ -282,6 +425,9 @@ fn single_lines(branch: &str, state: &CheckState, now_ms: i64) -> Vec CheckState::Failed(message) => vec![CheckLine::warn(format!( "⚠ Could not check '{branch}': {message}" ))], + CheckState::Done(check) if check.landing.is_some() => { + landing_lines(check, now_ms, delete_landed) + } CheckState::Done(check) if !check.has_findings() => vec![CheckLine::note( "Checked: nothing is running in it, uncommitted, or missing from its base.".into(), )], @@ -292,7 +438,71 @@ fn single_lines(branch: &str, state: &CheckState, now_ms: i64) -> Vec } } -fn bulk_lines(states: &[(String, &CheckState)], now_ms: i64) -> Vec { +/// A finished check that says where the branch stands: what the delete +/// destroys first, each a `⚠` line as before, then the branch — kept with +/// its unlanded commits, or landed and going with the checkout (unless +/// **Delete landed branch** is off). +fn landing_lines(check: &WorktreeCheck, now_ms: i64, delete_landed: bool) -> Vec { + let standing = standing_of(check); + // The branch's own commits are its line below, not a warning: the + // branch keeps them. + let branch_line = matches!(standing, Some(BranchStanding::Unlanded { .. })); + let mut lines: Vec = findings_but(check, now_ms, branch_line) + .into_iter() + .map(|f| CheckLine::warn(format!("⚠ {f}"))) + .collect(); + let unpushed = check.submodule_unpushed(); + if unpushed > 0 { + lines.push(CheckLine::warn(format!( + "⚠ {unpushed} {} in its submodules {} on no remote, and {} with it", + plural(unpushed as usize, "commit", "commits"), + plural(unpushed as usize, "is", "are"), + plural(unpushed as usize, "goes", "go"), + ))); + } + if let Some(line) = ignored_line(check) { + lines.push(CheckLine::warn(line)); + } + // A git stopped part-way (a running one is a process line already). + if let Some(op) = check + .landing + .as_ref() + .filter(|l| !l.git_op_live) + .and_then(|l| l.git_op.as_ref()) + { + lines.push(CheckLine::warn(format!( + "⚠ a git {op} is stopped part-way in it; its branch is kept" + ))); + } + if lines.is_empty() && !matches!(standing, Some(BranchStanding::Unlanded { .. })) { + lines.push(CheckLine::note( + "Checked: nothing is running in it, uncommitted, or unlanded.".into(), + )); + } + match standing { + Some(BranchStanding::Landed { base }) if delete_landed => lines.push(CheckLine::note( + format!("Its branch has landed on {base} and is deleted with it."), + )), + Some(BranchStanding::Landed { base }) => { + lines.push(CheckLine::note(format!("Its branch has landed on {base}."))) + } + Some(BranchStanding::Unlanded { commits, base }) => { + let n = commits as usize; + lines.push(CheckLine::note(format!( + "{n} {} on this branch and not on {base}.", + plural(n, "commit is", "commits are") + ))); + } + None => {} + } + lines +} + +fn bulk_lines( + states: &[(String, &CheckState)], + now_ms: i64, + delete_landed: bool, +) -> Vec { let waiting = states .iter() .filter(|(_, s)| matches!(s, CheckState::Pending { since, .. } if since.elapsed() < CHECK_PATIENCE)) @@ -320,11 +530,51 @@ fn bulk_lines(states: &[(String, &CheckState)], now_ms: i64) -> Vec { "Checked: nothing is running in them, uncommitted, or missing from a base.".into(), )); } + let landed = states + .iter() + .filter(|(_, state)| { + matches!(state, CheckState::Done(check) + if matches!(standing_of(check), Some(BranchStanding::Landed { .. }))) + }) + .count(); + if delete_landed && waiting == 0 && landed > 0 { + lines.push(CheckLine::note(format!( + "{landed} landed {} deleted with {}.", + plural(landed, "branch is", "branches are"), + plural(landed, "its worktree", "their worktrees"), + ))); + } lines } +/// The gitignored files a delete takes that are not build output — which +/// `git status` never shows, so nothing else would: `⚠ 2 ignored files +/// are deleted too: .env, .claude/settings.local.json`. +fn ignored_line(check: &WorktreeCheck) -> Option { + let landing = check.landing.as_ref()?; + let total = landing.ignored_total as usize; + if total == 0 { + return None; + } + let mut names = landing.ignored_files.join(", "); + let rest = total.saturating_sub(landing.ignored_files.len()); + if rest > 0 { + names.push_str(&format!(" and {rest} more")); + } + Some(format!( + "⚠ {total} ignored {} deleted too: {names}", + plural(total, "file is", "files are") + )) +} + /// A finished check's findings, one sentence each. fn findings(check: &WorktreeCheck, now_ms: i64) -> Vec { + findings_but(check, now_ms, false) +} + +/// [`findings`], less the branch's unlanded commits when `skip_ahead`: +/// the confirm words those as the branch's own line. +fn findings_but(check: &WorktreeCheck, now_ms: i64, skip_ahead: bool) -> Vec { let mut out = Vec::new(); // First: the one finding someone stated on purpose. match check.locked.as_deref() { @@ -356,7 +606,7 @@ fn findings(check: &WorktreeCheck, now_ms: i64) -> Vec { } out.push(line); } - if check.ahead > 0 { + if check.ahead > 0 && !skip_ahead { let n = check.ahead as usize; let commits = plural(n, "commit", "commits"); out.push(match (&check.base, check.detached) { @@ -400,6 +650,14 @@ fn short_findings(check: &WorktreeCheck, now_ms: i64) -> Vec { (None, false) => format!("{} commit(s) off its base", check.ahead), }); } + let unpushed = check.submodule_unpushed(); + if unpushed > 0 { + out.push(format!("{unpushed} unpushed in submodules")); + } + let ignored = check.ignored_total(); + if ignored > 0 { + out.push(format!("{ignored} ignored")); + } if check.process_error.is_some() || check.git_error.is_some() { out.push("not fully checked".into()); } @@ -441,15 +699,15 @@ fn plural<'a>(n: usize, one: &'a str, many: &'a str) -> &'a str { #[cfg(test)] mod tests { use super::*; - use nebula_core::WorktreeProcess; + use nebula_core::{WorktreeLanding, WorktreeProcess}; fn done(check: WorktreeCheck) -> CheckState { - CheckState::Done(check) + CheckState::Done(Box::new(check)) } #[test] fn a_clean_check_says_so_and_a_busy_one_names_each_finding() { - let clean = single_lines("feat", &done(WorktreeCheck::default()), 0); + let clean = single_lines("feat", &done(WorktreeCheck::default()), 0, true); assert_eq!(clean.len(), 1); assert!(!clean[0].warn); @@ -467,7 +725,7 @@ mod tests { base: Some("origin/main".into()), ..WorktreeCheck::default() }; - let text: Vec = single_lines("feat", &done(busy), now) + let text: Vec = single_lines("feat", &done(busy), now, true) .into_iter() .inspect(|l| assert!(l.warn)) .map(|l| l.text) @@ -489,7 +747,7 @@ mod tests { changes: 1, ..WorktreeCheck::default() }; - let text: Vec = single_lines("probe", &done(check.clone()), 0) + let text: Vec = single_lines("probe", &done(check.clone()), 0, true) .into_iter() .inspect(|l| assert!(l.warn)) .map(|l| l.text) @@ -511,7 +769,7 @@ mod tests { ..WorktreeCheck::default() }; assert!(bare.has_findings(), "a lock alone is a finding"); - let text = &single_lines("probe", &done(bare), 0)[0].text; + let text = &single_lines("probe", &done(bare), 0, true)[0].text; assert!(text.contains("no reason given"), "{text}"); } @@ -522,7 +780,7 @@ mod tests { detached: true, ..WorktreeCheck::default() }; - let text = &single_lines("feat", &done(check), 0)[0].text; + let text = &single_lines("feat", &done(check), 0, true)[0].text; assert!( text.contains("detached HEAD") && text.contains("reflog"), "{text}" @@ -535,9 +793,9 @@ mod tests { process_error: Some("lsof failed".into()), ..WorktreeCheck::default() }; - let lines = single_lines("feat", &done(partial), 0); + let lines = single_lines("feat", &done(partial), 0, true); assert!(lines[0].warn && lines[0].text.contains("lsof failed")); - let failed = single_lines("feat", &CheckState::Failed("gone".into()), 0); + let failed = single_lines("feat", &CheckState::Failed("gone".into()), 0, true); assert!(failed[0].warn && failed[0].text.contains("gone")); } @@ -547,7 +805,7 @@ mod tests { req_id: 1, since: Instant::now() - CHECK_PATIENCE, }; - let lines = single_lines("feat", &stale, 0); + let lines = single_lines("feat", &stale, 0, true); assert!(lines[0].warn && lines[0].text.contains("too long")); } @@ -563,18 +821,211 @@ mod tests { since: Instant::now(), }; let states = [("a".to_string(), &busy), ("b".to_string(), &clean)]; - let lines = bulk_lines(&states, 0); + let lines = bulk_lines(&states, 0, true); assert_eq!(lines.len(), 1); assert_eq!(lines[0].text, "⚠ 'a': 1 uncommitted"); let states = [("a".to_string(), &busy), ("c".to_string(), &waiting)]; - let lines = bulk_lines(&states, 0); + let lines = bulk_lines(&states, 0, true); assert_eq!( lines.last().unwrap().text, "Checking 1 worktree(s) for work in progress…" ); let states = [("b".to_string(), &clean)]; - assert!(!bulk_lines(&states, 0)[0].warn); + assert!(!bulk_lines(&states, 0, true)[0].warn); + + let landed = done(WorktreeCheck { + base: Some("origin/main".into()), + landing: Some(nebula_core::WorktreeLanding { + branch_landed: true, + branch: Some(tip()), + ..Default::default() + }), + ..WorktreeCheck::default() + }); + let states = [("a".to_string(), &busy), ("d".to_string(), &landed)]; + let lines = bulk_lines(&states, 0, true); + assert_eq!( + lines.last().unwrap().text, + "1 landed branch is deleted with its worktree." + ); + assert!(bulk_lines(&states, 0, false) + .iter() + .all(|l| !l.text.contains("landed"))); + } + + fn texts(lines: Vec) -> Vec { + lines.into_iter().map(|l| l.text).collect() + } + + fn landed() -> WorktreeCheck { + WorktreeCheck { + base: Some("origin/main".into()), + landing: Some(WorktreeLanding { + branch_landed: true, + branch: Some(tip()), + ..WorktreeLanding::default() + }), + ..WorktreeCheck::default() + } + } + + fn tip() -> nebula_core::BranchTip { + nebula_core::BranchTip { + name: "feat".into(), + oid: "f00d".into(), + } + } + + #[test] + fn a_landed_branch_is_said_to_go_with_the_checkout() { + assert_eq!( + texts(single_lines("feat", &done(landed()), 0, true)), + [ + "Checked: nothing is running in it, uncommitted, or unlanded.", + "Its branch has landed on origin/main and is deleted with it.", + ] + ); + assert_eq!( + texts(single_lines("feat", &done(landed()), 0, false))[1], + "Its branch has landed on origin/main.", + "the setting off keeps it" + ); + assert_eq!( + standing_of(&landed()), + Some(BranchStanding::Landed { + base: "origin/main".into() + }) + ); + } + + #[test] + fn an_unlanded_branch_is_counted_not_warned_about() { + let check = WorktreeCheck { + ahead: 4, + landing: Some(WorktreeLanding { + branch: Some(tip()), + ..WorktreeLanding::default() + }), + ..landed() + }; + let lines = single_lines("feat", &done(check.clone()), 0, true); + assert_eq!( + texts(lines.clone()), + ["4 commits are on this branch and not on origin/main."] + ); + assert!(!lines[0].warn, "the branch keeps them"); + assert_eq!( + standing_of(&check), + Some(BranchStanding::Unlanded { + commits: 4, + base: "origin/main".into() + }) + ); + } + + #[test] + fn what_a_delete_destroys_comes_first_and_submodules_are_named() { + let check = WorktreeCheck { + changes: 2, + landing: Some(WorktreeLanding { + branch_landed: true, + submodule_unpushed: 6, + branch: Some(tip()), + ..WorktreeLanding::default() + }), + ..landed() + }; + let lines = single_lines("feat", &done(check), 0, true); + assert_eq!( + texts(lines.clone()), + [ + "⚠ 2 uncommitted changes", + "⚠ 6 commits in its submodules are on no remote, and go with it", + "Its branch has landed on origin/main and is deleted with it.", + ] + ); + assert!(lines[0].warn && lines[1].warn && !lines[2].warn); + } + + #[test] + fn a_detached_failed_or_baseless_check_leaves_the_branch_alone() { + let detached = WorktreeCheck { + detached: true, + ..landed() + }; + assert_eq!(standing_of(&detached), None); + let failed = WorktreeCheck { + git_error: Some("x".into()), + ..landed() + }; + assert_eq!(standing_of(&failed), None); + let baseless = WorktreeCheck { + base: None, + ..landed() + }; + assert_eq!(standing_of(&baseless), None); + let nameless = WorktreeCheck { + landing: Some(WorktreeLanding { + branch_landed: true, + ..WorktreeLanding::default() + }), + ..landed() + }; + assert_eq!(standing_of(&nameless), None, "no branch named, none taken"); + let mid_rebase = WorktreeCheck { + landing: Some(WorktreeLanding { + branch_landed: true, + branch: Some(tip()), + git_op: Some("rebase".into()), + ..WorktreeLanding::default() + }), + ..landed() + }; + assert_eq!(standing_of(&mid_rebase), None, "mid-operation: kept"); + let lines = texts(single_lines("feat", &done(mid_rebase), 0, true)); + assert_eq!( + lines, + ["⚠ a git rebase is stopped part-way in it; its branch is kept"] + ); + assert_eq!(standing_of(&WorktreeCheck::default()), None, "old daemon"); + } + + #[test] + fn ignored_files_that_are_no_build_output_are_named() { + let check = WorktreeCheck { + landing: Some(WorktreeLanding { + branch_landed: true, + branch: Some(tip()), + ignored_files: vec![".env".into(), ".claude/settings.local.json".into()], + ignored_total: 2, + ..WorktreeLanding::default() + }), + ..landed() + }; + let lines = single_lines("feat", &done(check), 0, true); + assert_eq!( + texts(lines.clone()), + [ + "⚠ 2 ignored files are deleted too: .env, .claude/settings.local.json", + "Its branch has landed on origin/main and is deleted with it.", + ] + ); + assert!(lines[0].warn); + + let many = WorktreeCheck { + landing: Some(WorktreeLanding { + ignored_files: vec!["a".into(), "b".into(), "c".into()], + ignored_total: 5, + branch: Some(tip()), + ..WorktreeLanding::default() + }), + ..landed() + }; + assert_eq!( + ignored_line(&many).as_deref(), + Some("⚠ 5 ignored files are deleted too: a, b, c and 2 more") + ); } } diff --git a/crates/nebula-tui/src/event_loop/commands.rs b/crates/nebula-tui/src/event_loop/commands.rs index e479c2a9..66263f6c 100644 --- a/crates/nebula-tui/src/event_loop/commands.rs +++ b/crates/nebula-tui/src/event_loop/commands.rs @@ -428,9 +428,21 @@ pub(crate) fn delete_worktree_and_settle( app: &mut App, id: WorktreeId, out: &mut Vec, +) { + let delete_branch = crate::delete_check::default_delete_branch(app, &id); + delete_worktree_and_settle_with(app, id, delete_branch, out); +} + +/// [`delete_worktree_and_settle`] with the branch's fate decided by a key +/// in the confirm (`k` keeps it, `D` deletes it) rather than its default. +pub(crate) fn delete_worktree_and_settle_with( + app: &mut App, + id: WorktreeId, + delete_branch: Option, + out: &mut Vec, ) { let before = selection_snapshot(app); - delete_worktree(app, id, out); + delete_worktree_with(app, id, delete_branch, out); reconcile_selection(app, before, out); } @@ -539,7 +551,26 @@ pub(crate) fn confirm_close_terminal_in(app: &App, t: &nebula_core::TerminalTab) /// in the background — `git worktree remove` can take seconds). The /// eventual EntityRemoved is a no-op; an Error for this req_id restores the /// rows via the rollback stashed in the intent. +/// The branch goes too when its DELETE CHECK found it landed and the +/// **Delete landed branch** setting is on +/// ([`crate::delete_check::default_delete_branch`]). pub(crate) fn delete_worktree(app: &mut App, id: WorktreeId, out: &mut Vec) { + let delete_branch = crate::delete_check::default_delete_branch(app, &id); + delete_worktree_with(app, id, delete_branch, out); +} + +/// [`delete_worktree`], the branch's fate given: `Some` deletes that +/// branch too, at the tip the confirm judged. +/// Never sent to a DAEMON too old to read it, which would refuse the +/// whole request. +pub(crate) fn delete_worktree_with( + app: &mut App, + id: WorktreeId, + delete_branch: Option, + out: &mut Vec, +) { + let delete_branch = delete_branch + .filter(|_| app.requests.daemon_protocol >= nebula_core::BRANCH_LANDING_PROTOCOL); let intent = match remove_worktree_rows(app, &id) { Some(rollback) => PendingIntent::DeleteWorktree(rollback), None => PendingIntent::None, @@ -548,6 +579,7 @@ pub(crate) fn delete_worktree(app: &mut App, id: WorktreeId, out: &mut Vec *req_id, + other => panic!("one git-only check of the idle band: {other:?}"), + }; + out.clear(); + crate::band_safety::sweep(&mut app, &mut out); + assert!(out.is_empty(), "one in flight at a time: {out:?}"); + + hse( + &mut app, + ServerEvent::WorktreeChecked { + req_id, + id: WorktreeId("w3".into()), + check: nebula_core::WorktreeCheck { + changes: 5, + ahead: 4, + base: Some("origin/main".into()), + landing: Some(nebula_core::WorktreeLanding { + untracked: 2, + ..Default::default() + }), + ..Default::default() + }, + }, + ); + assert!(!app.requests.pending.contains_key(&req_id)); + let screen = screen_text(&draw_tall(&mut app)); + assert!( + screen.contains( + "⚠ 3 modified · 2 untracked · ↑4 unmerged · p: new session · t: terminal · d: delete worktree" + ), + "{screen}" + ); + crate::band_safety::sweep(&mut app, &mut out); + assert!(out.is_empty(), "read a moment ago: {out:?}"); + + let mut failing = with_empty_band(); + crate::band_safety::sweep(&mut failing, &mut out); + let [ClientRequest::CheckWorktree { req_id, .. }] = out.as_slice() else { + panic!("{out:?}"); + }; + let req_id = *req_id; + out.clear(); + hse( + &mut failing, + ServerEvent::Error { + req_id: Some(req_id), + message: "worktree not found".into(), + }, + ); + assert_eq!(failing.chrome.flash, None); + let screen = screen_text(&draw_tall(&mut failing)); + assert!( + screen.contains("? can't read git state · retrying · p: new session"), + "{screen}" + ); + + // A failed band is read again on the very next tick, up to + // FAST_RETRIES times in a row; then it waits its turn. + for _ in 0..crate::band_safety::FAST_RETRIES { + crate::band_safety::sweep(&mut failing, &mut out); + let [ClientRequest::CheckWorktree { req_id, .. }] = out.as_slice() else { + panic!("retried at once: {out:?}"); + }; + let req_id = *req_id; + out.clear(); + hse( + &mut failing, + ServerEvent::Error { + req_id: Some(req_id), + message: "still not".into(), + }, + ); + } + crate::band_safety::sweep(&mut failing, &mut out); + assert!(out.is_empty(), "backed off: {out:?}"); + + // A git still running there is read again every tick. + let mut busy = with_empty_band(); + for round in 0..crate::band_safety::FAST_RETRIES + 2 { + crate::band_safety::sweep(&mut busy, &mut out); + let [ClientRequest::CheckWorktree { req_id, .. }] = out.as_slice() else { + panic!("round {round}: {out:?}"); + }; + let req_id = *req_id; + out.clear(); + hse( + &mut busy, + ServerEvent::WorktreeChecked { + req_id, + id: WorktreeId("w3".into()), + check: nebula_core::WorktreeCheck { + landing: Some(nebula_core::WorktreeLanding { + git_op: Some("submodule update".into()), + git_op_live: true, + ..Default::default() + }), + ..Default::default() + }, + }, + ); + } + let screen = screen_text(&draw_tall(&mut busy)); + assert!( + screen.contains("⋯ git submodule update running · p: new session"), + "{screen}" + ); + + let mut old = with_empty_band(); + old.requests.daemon_protocol = nebula_core::BRANCH_LANDING_PROTOCOL - 1; + crate::band_safety::sweep(&mut old, &mut out); + assert!(out.is_empty(), "{out:?}"); + }); + } + /// With **Show all worktrees** on a checkout with nothing running is /// on the grid as an EMPTY BAND — its branch on the rule, and under /// it what can be done there — which `j` walks onto like any band. diff --git a/crates/nebula-tui/src/event_loop/loop_run.rs b/crates/nebula-tui/src/event_loop/loop_run.rs index 2caf02af..ab24d1f9 100644 --- a/crates/nebula-tui/src/event_loop/loop_run.rs +++ b/crates/nebula-tui/src/event_loop/loop_run.rs @@ -169,6 +169,8 @@ macro_rules! main_loop_body { _ = tokio::time::sleep_until(next_git_poll) => { request_git_changes(&mut app, &git_tx); sweep_git_changes(&mut app, &sweep_git_tx); + // And one idle band's DELETE CHECK, for its line. + crate::band_safety::sweep(&mut app, &mut out); // Rides the git tick rather than the repaint, so walking the // worktree list with j/k can't spawn a `gh` per row passed — // only whatever the selection is resting on when it fires, diff --git a/crates/nebula-tui/src/event_loop/overlays.rs b/crates/nebula-tui/src/event_loop/overlays.rs index dcd4ffeb..3cfc0834 100644 --- a/crates/nebula-tui/src/event_loop/overlays.rs +++ b/crates/nebula-tui/src/event_loop/overlays.rs @@ -409,6 +409,12 @@ fn handle_prompt_key(app: &mut App, key: KeyEvent, out: &mut Vec) fn handle_confirm_key(app: &mut App, key: KeyEvent, out: &mut Vec) { let _ = &mut *out; + // The branch keys a band's own delete offers, once its check has said + // where the branch stands. + let branch_keys = match &app.modals.overlay { + Some(Overlay::Confirm(c)) => crate::delete_check::branch_keys(app, &c.action), + _ => None, + }; let Some(overlay) = &mut app.modals.overlay else { return; }; @@ -456,6 +462,38 @@ fn handle_confirm_key(app: &mut App, key: KeyEvent, out: &mut Vec ); } } + // A worktree delete decides the branch — a band's own `d`, or + // a card's delete taking its emptied worktree: `k` keeps a + // landed one the confirm would delete, `D` deletes one it would + // keep. Offered only once the check has said where the branch + // stands, so neither runs ahead of it. + KeyCode::Char(c @ ('k' | 'D')) if branch_keys.is_some() => { + let action = confirm.action.clone(); + let Some(id) = crate::delete_check::branch_decided_by(&action).cloned() else { + unreachable!("branch keys are a worktree delete's"); + }; + let delete_branch = match (c, branch_keys) { + ('k', Some(crate::delete_check::BranchKeys::DeleteByDefault)) => None, + ('D', Some(crate::delete_check::BranchKeys::KeepByDefault { unlanded })) => { + let tip = crate::delete_check::judged_tip(app, &id); + if let (Some(tip), true) = (&tip, unlanded > 0) { + app.chrome.flash = Some(format!( + "deleting branch '{}' too — its {unlanded} unlanded commit(s) go with it", + tip.name + )); + } + tip + } + // The other key of the pair is not on this dialog. + _ => return, + }; + app.modals.overlay = None; + if let PendingAction::ThenDeleteWorktree { first, .. } = action { + // The card goes first, as `Enter` takes it. + run_pending_action(app, *first, out); + } + delete_worktree_and_settle_with(app, id, delete_branch, out); + } KeyCode::Enter | KeyCode::Char('y') => { // A worktree delete waits for its DELETE CHECK: the user // confirms what it found, never ahead of it. diff --git a/crates/nebula-tui/src/event_loop/server_events.rs b/crates/nebula-tui/src/event_loop/server_events.rs index 3f6adaf9..92d26472 100644 --- a/crates/nebula-tui/src/event_loop/server_events.rs +++ b/crates/nebula-tui/src/event_loop/server_events.rs @@ -522,7 +522,11 @@ fn handle_tree_event(app: &mut App, event: ServerEvent, out: &mut Vec { // Answered with WorktreeChecked, not Ack; `land` clears the slot. - crate::delete_check::land(app, req_id, id, check); + // A band's own check, or a confirm's — whose answer the band + // shows too, being the newer. + if !crate::band_safety::land(app, req_id, &id, &check) { + crate::delete_check::land(app, req_id, id, check); + } } ServerEvent::OutputTail { req_id, @@ -564,6 +568,10 @@ fn handle_error_event(app: &mut App, event: ServerEvent, out: &mut Vec (App, u64) { ); crate::delete_check::sync(&mut app, &mut out); let req_id = match out.as_slice() { - [ClientRequest::CheckWorktree { req_id, id }] if id.0 == "w2" => *req_id, + [ClientRequest::CheckWorktree { req_id, id, .. }] if id.0 == "w2" => *req_id, other => panic!("the confirm asks for one check: {other:?}"), }; (app, req_id) @@ -22567,6 +22567,12 @@ fn a_worktree_delete_dialog_opens_immediately_while_checking() { id: WorktreeId("w2".into()), check: nebula_core::WorktreeCheck { changes: 2, + base: Some("origin/main".into()), + landing: Some(nebula_core::WorktreeLanding { + branch_landed: true, + branch: Some(feat_tip()), + ..Default::default() + }), ..Default::default() }, }, @@ -22576,18 +22582,19 @@ fn a_worktree_delete_dialog_opens_immediately_while_checking() { screen.contains("from disk?") && screen.contains("2 uncommitted changes"), "{screen}" ); + assert!(screen.contains("[k] keep branch"), "{screen}"); assert!(!screen.contains("Checking 'feat'"), "{screen}"); assert_eq!(app.chrome.flash, None); }); } -/// Enter and `y` wait for the check; Esc cancels immediately. +/// Enter, `y`, `k` and `D` wait for the check; Esc cancels immediately. #[test] fn a_confirm_waiting_on_its_check_chooses_nothing_but_cancel() { with_default_config(|| { let (mut app, _) = delete_feat_with_check(); let mut out = Vec::new(); - for key in ['x', 'D', 'y'] { + for key in ['k', 'D', 'y'] { press(&mut app, KeyCode::Char(key), KeyModifiers::NONE, &mut out); } press(&mut app, KeyCode::Enter, KeyModifiers::NONE, &mut out); @@ -22691,6 +22698,263 @@ fn an_older_daemon_gets_no_check_and_the_old_confirm() { }); } +/// [`delete_feat_with_check`], answered: the branch has landed, or holds +/// `ahead` commits origin/main lacks. +fn delete_feat_checked(ahead: u32) -> App { + let (mut app, req_id) = delete_feat_with_check(); + hse( + &mut app, + ServerEvent::WorktreeChecked { + req_id, + id: WorktreeId("w2".into()), + check: nebula_core::WorktreeCheck { + ahead, + base: Some("origin/main".into()), + landing: Some(nebula_core::WorktreeLanding { + branch_landed: ahead == 0, + branch: Some(feat_tip()), + ..Default::default() + }), + ..Default::default() + }, + }, + ); + app +} + +/// The branch and tip [`delete_feat_checked`]'s check judged. +fn feat_tip() -> nebula_core::BranchTip { + nebula_core::BranchTip { + name: "feat".into(), + oid: "f00d".into(), + } +} + +/// Whether the one `DeleteWorktree` in `out` takes the branch — always +/// the one the check judged, at its tip. +fn deleted_branch(out: &[ClientRequest]) -> bool { + match out + .iter() + .filter_map(|r| match r { + ClientRequest::DeleteWorktree { + id, delete_branch, .. + } if id.0 == "w2" => Some(delete_branch.clone()), + _ => None, + }) + .collect::>() + .as_slice() + { + [one] => { + if let Some(tip) = one { + assert_eq!(tip, &feat_tip(), "the judged branch and tip"); + } + one.is_some() + } + other => panic!("one worktree delete: {other:?} in {out:?}"), + } +} + +/// A landed branch goes with its worktree: the confirm says so, `Enter` +/// deletes both, and `k` keeps the branch. +#[test] +fn a_landed_branch_goes_with_its_worktree_unless_k_keeps_it() { + with_default_config(|| { + let mut app = delete_feat_checked(0); + let screen = confirm_screen(&mut app); + for words in [ + "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", + ] { + assert!(screen.contains(words), "{words:?} in:\n{screen}"); + } + let mut out = Vec::new(); + press(&mut app, KeyCode::Char('D'), KeyModifiers::NONE, &mut out); + assert!(out.is_empty(), "D is not this dialog's: {out:?}"); + press(&mut app, KeyCode::Enter, KeyModifiers::NONE, &mut out); + assert!(deleted_branch(&out)); + + let mut app = delete_feat_checked(0); + let mut out = Vec::new(); + press(&mut app, KeyCode::Char('k'), KeyModifiers::NONE, &mut out); + assert!(app.modals.overlay.is_none()); + assert!(!deleted_branch(&out)); + }); +} + +/// A branch with unlanded commits is kept by default; `D` discards it, +/// the footer saying how many commits go with it. +#[test] +fn an_unlanded_branch_is_kept_unless_d_discards_it() { + with_default_config(|| { + let mut app = delete_feat_checked(4); + let screen = confirm_screen(&mut app); + for words in [ + "4 commits are on this branch and not on origin/main.", + "[Enter/y] keep branch [D] delete branch too [Esc/n] cancel", + ] { + assert!(screen.contains(words), "{words:?} in:\n{screen}"); + } + assert!( + !screen.contains("lacks (the branch keeps them)"), + "{screen}" + ); + let mut out = Vec::new(); + press(&mut app, KeyCode::Enter, KeyModifiers::NONE, &mut out); + assert!(!deleted_branch(&out)); + + let mut app = delete_feat_checked(4); + let mut out = Vec::new(); + press(&mut app, KeyCode::Char('k'), KeyModifiers::NONE, &mut out); + assert!(out.is_empty(), "k is not this dialog's: {out:?}"); + press(&mut app, KeyCode::Char('D'), KeyModifiers::SHIFT, &mut out); + assert!(deleted_branch(&out)); + assert!(app + .chrome + .flash + .as_deref() + .is_some_and(|f| f.contains("4 unlanded commit(s)"))); + }); +} + +/// **Delete landed branch** off keeps even a landed branch unless `D`. +#[test] +fn delete_landed_branch_off_keeps_a_landed_branch_by_default() { + with_config_json(r#"{"delete_landed_branch": false}"#, || { + let mut app = delete_feat_checked(0); + let screen = confirm_screen(&mut app); + assert!( + screen.contains("Its branch has landed on origin/main.") + && screen.contains("[Enter/y] keep branch [D] delete branch too"), + "{screen}" + ); + let mut out = Vec::new(); + press(&mut app, KeyCode::Enter, KeyModifiers::NONE, &mut out); + assert!(!deleted_branch(&out)); + + let mut app = delete_feat_checked(0); + let mut out = Vec::new(); + press(&mut app, KeyCode::Char('D'), KeyModifiers::SHIFT, &mut out); + assert!(deleted_branch(&out)); + }); +} + +/// A daemon at the DELETE CHECK's protocol but older than the branch's +/// gets the confirm it always did: no branch keys, and no branch in the +/// delete, whatever the check carried. +#[test] +fn a_daemon_before_branch_landing_gets_the_old_confirm() { + with_default_config(|| { + let mut app = parity_tree(); + app.requests.daemon_protocol = nebula_core::BRANCH_LANDING_PROTOCOL - 1; + let mut out = Vec::new(); + let row = app.worktree_row_of(&WorktreeId("w2".into())).unwrap(); + select_worktree_row(&mut app, row, &mut out); + app.nav.focus = Focus::Worktrees; + out.clear(); + press(&mut app, KeyCode::Char('d'), KeyModifiers::NONE, &mut out); + crate::delete_check::sync(&mut app, &mut out); + let req_id = match out.as_slice() { + [ClientRequest::CheckWorktree { + req_id, + git_only: false, + .. + }] => *req_id, + other => panic!("{other:?}"), + }; + out.clear(); + hse( + &mut app, + ServerEvent::WorktreeChecked { + req_id, + id: WorktreeId("w2".into()), + check: nebula_core::WorktreeCheck { + base: Some("origin/main".into()), + landing: Some(nebula_core::WorktreeLanding { + branch_landed: true, + ..Default::default() + }), + ..Default::default() + }, + }, + ); + let screen = confirm_screen(&mut app); + assert!( + screen.contains("[Enter/y] confirm [Esc/n] cancel") && !screen.contains("[k]"), + "{screen}" + ); + press(&mut app, KeyCode::Enter, KeyModifiers::NONE, &mut out); + assert!(!deleted_branch(&out)); + }); +} + +/// A card's delete that takes its emptied worktree decides the branch +/// too: a landed one goes with it by default, and `k` keeps it — after +/// the card's own delete, as `Enter` runs them. +#[test] +fn a_card_delete_taking_its_worktree_keeps_the_branch_on_k() { + with_config_json(r#"{"delete_empty_worktree": true}"#, || { + let mut app = App::new(); + seed_emptiable_tree(&mut app); + upsert_agent(&mut app, "a2", "w2", "agent-2", false); + app.nav.focus = Focus::Sessions; + app.nav.sel_worktree = app + .visible_worktrees() + .iter() + .position(|w| w.id.0 == "w2") + .unwrap(); + app.nav.sel_session = 0; + let mut out = Vec::new(); + press(&mut app, KeyCode::Char('d'), KeyModifiers::NONE, &mut out); + assert!( + matches!(&app.modals.overlay, Some(Overlay::Confirm(c)) + if matches!(c.action, PendingAction::ThenDeleteWorktree { .. })), + "{:?}", + app.modals.overlay + ); + crate::delete_check::sync(&mut app, &mut out); + let req_id = match out.as_slice() { + [ClientRequest::CheckWorktree { req_id, .. }] => *req_id, + other => panic!("{other:?}"), + }; + out.clear(); + hse( + &mut app, + ServerEvent::WorktreeChecked { + req_id, + id: WorktreeId("w2".into()), + check: nebula_core::WorktreeCheck { + base: Some("origin/main".into()), + landing: Some(nebula_core::WorktreeLanding { + branch_landed: true, + branch: Some(feat_tip()), + ..Default::default() + }), + ..Default::default() + }, + }, + ); + let screen = confirm_screen(&mut app); + assert!( + screen.contains("[k] keep branch") + && screen.contains("Its branch has landed on origin/main and is deleted with it."), + "{screen}" + ); + press(&mut app, KeyCode::Char('k'), KeyModifiers::NONE, &mut out); + assert!(app.modals.overlay.is_none()); + let card = out + .iter() + .position(|r| !matches!(r, ClientRequest::DeleteWorktree { .. })) + .expect("the card's own delete"); + let worktree = out + .iter() + .position(|r| matches!(r, ClientRequest::DeleteWorktree { .. })) + .expect("the worktree's"); + assert!(card < worktree, "{out:?}"); + assert!(!deleted_branch(&out)); + }); +} + /// The card's own delete that offers to take the emptied worktree too is /// checked the same way; its "no, the card only" never waits, since it /// deletes no checkout. diff --git a/crates/nebula-tui/src/lib.rs b/crates/nebula-tui/src/lib.rs index 66978a27..da3f34c9 100644 --- a/crates/nebula-tui/src/lib.rs +++ b/crates/nebula-tui/src/lib.rs @@ -1,6 +1,7 @@ pub mod agent_picker; pub mod agent_presets; pub mod app; +pub mod band_safety; pub mod branch_name; pub mod branch_switch; pub mod bundle; diff --git a/crates/nebula-tui/src/ui/launcher_view.rs b/crates/nebula-tui/src/ui/launcher_view.rs index 461ff775..b3b872f1 100644 --- a/crates/nebula-tui/src/ui/launcher_view.rs +++ b/crates/nebula-tui/src/ui/launcher_view.rs @@ -1067,12 +1067,49 @@ fn draw_bands( .push((g.area, HitTarget::PanelBg(Focus::Sessions))); } -/// What an EMPTY BAND says under its rule — that nothing runs there, then -/// its keys — the root's without the delete it refuses. -const EMPTY_BAND_IDLE: &str = "nothing running"; -const EMPTY_BAND_KEYS: &str = "p: new session · t: terminal"; +/// What an EMPTY BAND says under its rule, the root's without the delete +/// it refuses. Once its DELETE CHECK has been read, the verdict +/// (`band_safety`) stands in place of `nothing running`. +pub(super) const EMPTY_BAND_IDLE: &str = "nothing running"; +const EMPTY_BAND_KEYS: &str = " · p: new session · t: terminal"; const EMPTY_BAND_DELETE: &str = " · d: delete worktree"; +/// What an EMPTY BAND's line opens with, and its color: the checkout's +/// DELETE CHECK verdict once one has been read — in the warning color when +/// a delete would destroy something — else `nothing running`. The root's +/// band, never deleted, says nothing of it. `fg` is the rest of the line's. +pub(super) fn empty_band_lead( + app: &App, + band: &crate::launcher::Band, + fg: ratatui::style::Color, + lit: bool, +) -> (String, ratatui::style::Color) { + let th = app.chrome.theme; + let verdict = (!band.is_main) + .then(|| crate::band_safety::band_verdict(app, &band.worktree)) + .flatten(); + match verdict { + Some(v) => { + let color = match v.tone { + crate::band_safety::Tone::Warn => th.warn, + crate::band_safety::Tone::Ahead if !lit => th.text, + _ => fg, + }; + (v.text, color) + } + None => (EMPTY_BAND_IDLE.to_string(), fg), + } +} + +/// The keys an EMPTY BAND's line lists after its lead. +pub(super) fn empty_band_keys(band: &crate::launcher::Band) -> String { + let mut keys = String::from(EMPTY_BAND_KEYS); + if !band.is_main { + keys.push_str(EMPTY_BAND_DELETE); + } + keys +} + /// The line under an EMPTY BAND's rule — a checkout with nothing running /// in it, drawn only with **Show all worktrees** on — in place of the row /// of cards it has none of: what can be done there, dim, and in the @@ -1096,16 +1133,14 @@ fn draw_empty_band( let Some(placed) = crate::launcher::place(window, scroll, row) else { return; }; - let mut text = format!("{EMPTY_BAND_IDLE} · {EMPTY_BAND_KEYS}"); - if !band.is_main { - text.push_str(EMPTY_BAND_DELETE); - } let fg = if lit { th.accent } else { th.dim }; + let (lead, lead_fg) = empty_band_lead(app, band, fg, lit); + let keys = empty_band_keys(band); f.render_widget( - Paragraph::new(Line::from(Span::styled( - format!(" {text}"), - Style::default().fg(fg), - ))), + Paragraph::new(Line::from(vec![ + Span::styled(format!(" {lead}"), Style::default().fg(lead_fg)), + Span::styled(keys, Style::default().fg(fg)), + ])), placed.rect, ); } diff --git a/crates/nebula-tui/src/ui/launcher_view/nested.rs b/crates/nebula-tui/src/ui/launcher_view/nested.rs index 3ed9dd46..d28123d2 100644 --- a/crates/nebula-tui/src/ui/launcher_view/nested.rs +++ b/crates/nebula-tui/src/ui/launcher_view/nested.rs @@ -10,7 +10,7 @@ //! and its pull request are on the DETAIL STRIP pinned under the list //! ([`draw_strip`]). -use super::{draw_panel_edge_marks, session_look, EMPTY_BAND_DELETE, EMPTY_BAND_KEYS}; +use super::{draw_panel_edge_marks, empty_band_keys, empty_band_lead, session_look}; use crate::app::{App, Focus, HitTarget}; use crate::keymap::Action; use crate::launcher::{Band, BandsLayout, Card, CardRef, PanelLayout, RowPr, NESTED_ROW_H}; @@ -273,16 +273,27 @@ fn draw_empty( Span::styled(branch.clone(), name_style), ]; let mut used = lead + branch.chars().count(); - if lit { - // Its keys alone: the DETAIL STRIP under the list already says - // nothing runs there. - let mut hint = String::from(EMPTY_BAND_KEYS); - if !band.is_main { - hint.push_str(EMPTY_BAND_DELETE); + // What a delete would lose, after the branch — always, not only under + // the cursor: it is what tells one empty checkout from the next. + // "Nothing running" alone stays off: the DETAIL STRIP already says it. + let (status, status_fg) = empty_band_lead(app, band, gray::dim(&th), lit); + if status != super::EMPTY_BAND_IDLE { + let avail = room.saturating_sub(used - lead + 2); + let status = clip(&status, avail); + if avail >= MIN_HINT { + spans.push(Span::raw(" ")); + used += 2 + status.chars().count(); + spans.push(Span::styled(status, Style::default().fg(status_fg))); } + } + if lit { + // Its keys: the DETAIL STRIP under the list already says nothing + // runs there. + let keys = empty_band_keys(band); + let hint = keys.trim_start_matches(" · ").to_string(); // Cut short rather than pushing into the columns; too little room // for a few words of it and the branch stands alone. - let avail = room.saturating_sub(branch.chars().count() + 2); + let avail = room.saturating_sub(used - lead + 2); let hint = clip(&hint, avail); if avail >= MIN_HINT { spans.push(Span::raw(" ")); diff --git a/crates/nebula-tui/src/ui/overlay.rs b/crates/nebula-tui/src/ui/overlay.rs index c6028135..27ab623c 100644 --- a/crates/nebula-tui/src/ui/overlay.rs +++ b/crates/nebula-tui/src/ui/overlay.rs @@ -243,21 +243,48 @@ fn draw_confirm_overlay(f: &mut Frame, app: &mut App, confirm: crate::app::Confi confirm.action, crate::app::PendingAction::ThenDeleteWorktree { offered: true, .. } ); - let legend = if three_way { - Line::from(vec![ + // A worktree delete, once its check says where the branch stands, + // names what Enter does with the branch and the key that does the + // other thing: the band's own `d` in its own words, a card's delete + // taking the worktree with the branch key after its usual answers. + let branch_keys = crate::delete_check::branch_keys(app, &confirm.action); + let branch_key = branch_keys.map(|keys| match keys { + crate::delete_check::BranchKeys::DeleteByDefault => "[k] keep branch", + crate::delete_check::BranchKeys::KeepByDefault { .. } => "[D] delete branch too", + }); + let band_delete = matches!(confirm.action, crate::app::PendingAction::DeleteWorktree(_)); + let mut legend = match branch_keys { + Some(keys) if band_delete => { + let enter = match keys { + crate::delete_check::BranchKeys::DeleteByDefault => "[Enter/y] delete both", + crate::delete_check::BranchKeys::KeepByDefault { .. } => "[Enter/y] keep branch", + }; + vec![ + Span::styled(enter, Style::default().fg(th.err)), + Span::raw(" "), + Span::styled(branch_key.unwrap_or_default(), Style::default().fg(th.err)), + Span::raw(" "), + Span::styled("[Esc/n] cancel", Style::default().fg(th.dim)), + ] + } + _ if three_way => vec![ Span::styled("[Enter/y] yes, both", Style::default().fg(th.err)), Span::raw(" "), Span::styled("[n] no, the card only", Style::default().fg(th.err)), Span::raw(" "), Span::styled("[Esc] cancel", Style::default().fg(th.dim)), - ]) - } else { - Line::from(vec![ + ], + _ => vec![ Span::styled("[Enter/y] confirm", Style::default().fg(th.err)), Span::raw(" "), Span::styled("[Esc/n] cancel", Style::default().fg(th.dim)), - ]) + ], }; + if let (Some(key), false) = (branch_key, band_delete) { + legend.push(Span::raw(" ")); + legend.push(Span::styled(key, Style::default().fg(th.err))); + } + let legend = Line::from(legend); let longest = msg_lines .iter() .copied() diff --git a/crates/nebula/tests/e2e_pty.rs b/crates/nebula/tests/e2e_pty.rs index 4fd8f1ad..82af511d 100644 --- a/crates/nebula/tests/e2e_pty.rs +++ b/crates/nebula/tests/e2e_pty.rs @@ -525,6 +525,7 @@ async fn full_crud_attach_and_restart_persistence() { &ClientRequest::CheckWorktree { req_id: 50, id: feature_wt_id.clone(), + git_only: false, }, ) .await @@ -549,14 +550,30 @@ async fn full_crud_attach_and_restart_persistence() { "{check:?}" ); assert!(feature_wt_path.exists(), "a check touches nothing"); + let landing = check.landing.as_ref().expect("this client speaks landing"); + assert!( + landing.branch_landed && landing.untracked == 1, + "no commits of its own: landed — {check:?}" + ); - // ---- DeleteWorktree removes it from disk ---- + // ---- DeleteWorktree removes it from disk, and its landed branch ---- + let branch_exists = || { + std::process::Command::new("git") + .arg("-C") + .arg(&repo) + .args(["rev-parse", "--verify", "--quiet", "refs/heads/feature-x"]) + .status() + .unwrap() + .success() + }; + assert!(branch_exists()); write_frame( &mut c, &ClientRequest::DeleteWorktree { req_id: 5, id: feature_wt_id.clone(), force: true, + delete_branch: Some(check.branch_tip().cloned().expect("on feature-x")), }, ) .await @@ -567,6 +584,7 @@ async fn full_crud_attach_and_restart_persistence() { "DeleteWorktree failed: {events:#?}" ); assert!(!feature_wt_path.exists(), "worktree dir removed from disk"); + assert!(!branch_exists(), "the landed branch went with it"); // ---- restart: tree persists, boot sweep marks nothing (agents fresh) ---- write_frame(&mut c, &ClientRequest::Shutdown).await.unwrap(); diff --git a/docs/configuration.md b/docs/configuration.md index f4932f0d..720cb001 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -71,6 +71,7 @@ behaviors that change how the tree is worked; every switch there is off by defau | `preset_text` | string | `"prefix"` | Sessions | PRESET TEXT: which side of the task a new AGENT PRESET's text goes — `prefix` (one box, sent before the task), `postfix` (one box, sent after it) or `prefix & postfix` (both, the form every preset had through 0.32). The PRESET EDITOR opens a new preset with the box(es) named here, and its **Text** row changes one preset — a side the row leaves out saves blank; editing a stored preset also shows any side that already holds text, so nothing saved is ever hidden. Prefix alone by default: the framing most people reach for, and one box to fill. A hand-edited `both` reads as `prefix & postfix`; anything else as the default. See [Sessions](sessions.md#agent-presets). | | `delete_empty_worktree` | bool | `false` | Sessions | DELETE EMPTIED WORKTREE: what the delete of a linked WORKTREE's last live card asks — the last session's `d`, the last terminal's close, or a `D` that takes them all. Off, the default, that card's own CONFIRM DIALOG carries the question too, before anything is deleted (`Delete agent 'x'? Its session and history go away.` then `Nothing else is left in worktree 'feature': delete it from disk too?`), with three answers: `Enter` or `y` deletes the card and then the worktree, the same forced delete the band's own `d` runs; `n` deletes the card and keeps the empty checkout; `Esc` cancels and keeps the card alive. On, the question is not asked: the dialog is the card's ordinary two-way confirm, its message saying the worktree goes with it, and `Enter` deletes both — unless archived sessions are still filed under the checkout, whose history the delete would take: those always get the three-way question, which says how many go with it. The ROOT WORKTREE is never offered either way, and archiving a session (`a`) never counts as emptying: an archived card is still filed under its checkout. With `show_all_worktrees` on, off asks nothing — the emptied worktree keeps its band — and on still deletes the worktree with its last card. See [Sessions](sessions.md#the-grid). | | `auto_cleanup_merged` | bool | `false` | Sessions | AUTO-CLEAN MERGED WORKTREES: off by default. On, when a linked worktree's branch PR is safely detected as merged, nebula asks the DAEMON to archive idle sessions in that checkout and remove the worktree from disk with `git worktree remove` (not forced). Safety gates: never the ROOT WORKTREE, never the repository default branch, never a branch that is the base of an open PR, only a MERGED PR whose head branch matches and whose head SHA is still the checkout's HEAD, never a checkout with uncommitted or untracked files, never a locked checkout (`git worktree lock`), never a checkout with a running terminal or a session mid-turn. A skipped cleanup flashes why; a cleanup flashes `cleaned up : PR # merged`. | +| `delete_landed_branch` | bool | `true` | Sessions | DELETE LANDED BRANCH: a worktree delete whose DELETE CHECK found its branch landed on its base deletes the branch with the checkout — the branch and tip the check judged, and only while its tip is still that commit (`git update-ref -d`, compare-and-delete), so a commit made while the confirm was open keeps it. Landed counts by content: every commit's patch on the base (`git cherry`), or the branch's whole diff already in it (a squash merge). That squash test misses a squash-merged branch once the base has since changed the same lines (the merge conflicts), which then reads unmerged: safe, but it is kept. The band's own confirm says so — `Its branch has landed on origin/main and is deleted with it.` — and `k` keeps the branch instead. Off, every branch is kept unless `D` in the confirm deletes it. A branch with unlanded commits is kept either way unless `D`, which deletes it and its commits. The confirm is the decision; the DAEMON still never deletes the base, origin's default branch, the root checkout's branch, a branch another checkout has out, or a branch other than the one the confirm named (a checkout that switched branches since keeps both); a detached HEAD deletes nothing, and each refusal is a warning. Needs a DAEMON that speaks protocol 52; an older one keeps every branch. See [Sessions](sessions.md#the-grid). | | `show_all_worktrees` | bool | `true` | Sessions | SHOW ALL WORKTREES: every checkout of the project gets a BAND on the grid, one with nothing running in it too. It is on by default. Off, the grid is only what is running: a checkout with no session or terminal has no band. On, such a checkout is an EMPTY BAND — its rule over one line, `nothing running · p: new session · t: terminal · d: delete worktree` — which `j`/`k` walk onto, where `p`/`n`/`t` start work, and where `d` (or **Delete worktree** in its right-click menu) deletes the worktree behind its own confirm (`Delete worktree 'feature' from disk?`); the ROOT WORKTREE's band has no `d`. On also means deleting a worktree's last card never asks about the worktree, so the emptied band stays until its own `d` — unless `delete_empty_worktree` is on, which still deletes the worktree with its last card. The ARCHIVED VIEW never shows an empty band. See [Sessions](sessions.md#the-grid). | | `appearance` | string | `"auto"` | Appearance | APPEARANCE: the dark or the light palette. `auto` asks the terminal for its background color at startup (OSC 11, with `COLORFGBG` as the fallback for a terminal that doesn't answer) and draws the light palette on a light background, the dark one otherwise; `dark` and `light` force one. In the light palette every THEME keeps its hues at a weight that reads on white, the FOCUSED PANEL TINT and the card washes mix toward white instead of black, and `black_background` paints white. Anything off the list reads as `auto`. | | `theme` | string | `"default"` | Appearance | The THEME: `default`, `ocean`, `forest`, `rose`, `amber`, `lavender`, `coral`, `slate`, `sand`, `mono`. An unknown name falls back to `default`. | diff --git a/docs/keys.md b/docs/keys.md index 214fe9b0..947cb98f 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -43,7 +43,7 @@ The grid isn't the only view. With a session selected: | New session picker (any row) | `?` | open Settings → Agents parked on the highlighted harness's section, where its Enabled switch and default model and effort live (`s` does the same from a pull request row's right-click menu; in the pickers it is a filter letter) | | The grid (a cloud card) | `Enter`, right-click | `Enter` — or a click on the link in the CLOUD SESSION PANEL — opens the session's page in the browser; the right-click menu offers **Open in browser** and **Send to cloud session**, which queues a message on it. No **Attach** or **Restart**: the agent runs in the cloud, not in a terminal here | | The grid (a card) | `Space` | **follow-up prompt.** A small modal opens over the grid and what you type there goes to the agent already running in the selected session as its next turn: `Enter` sends it, `Shift+Enter` / `Option+Enter` / `Ctrl+J` break a line, `Esc` closes without sending. The right-click menu's **Follow-up prompt** row does the same. Only a live local agent has one: an archived session, a Claude Cloud card (whose right-click menu queues a message instead) and a terminal have nothing to follow up on. A session whose CLI is not up is booted first and the box kept as it is, so nothing is typed into a process that is still starting — press `Enter` again once it is. Rebindable as `follow_up` | -| The grid | `r`, `a`, `u`, `d`, `A` | rename, archive, unarchive, delete, toggle archived. `Delete` and `Backspace` are bound alongside `d` and do the same thing, here and everywhere else `d` deletes something — and `a` and the row menu's Archive ask first, in the CONFIRM DIALOG `d` goes behind (`Enter` or `y` archives, `Esc` or `n` keeps the session), so typing aimed at an agent that lands on the grid archives nothing; the dialog says what makes saying yes cheap: `u` brings the session back. **Confirm on archive** (Settings → Sessions, on by default) off archives at once. In the nested layout, `d` and `a` on a thread's root that has children take the whole thread: `d` deletes every prompt and terminal nested under it, `a` archives every prompt and closes its terminals — each behind a confirm that counts and lists them, `a`'s asking even with the setting off; the root checkout's thread included, and a project left with nothing is the welcome. `d` on the last live card of a linked worktree asks about the checkout in the same confirm, before anything is deleted — `Nothing else is left in worktree 'feature': delete it from disk too?` — with three answers: `y` deletes the card and then the checkout as `d` on its band would, `n` deletes the card and keeps the empty checkout, `Esc` keeps the card alive; **Delete emptied worktree** (Settings → Sessions, off by default) skips that question and `Enter` deletes both, except when archived sessions are still filed under the checkout. With **Show all worktrees** on (Settings → Sessions, off by default) the question is never asked: the card goes alone, its checkout stays as an EMPTY BAND, and `d` on that band — a checkout with nothing running — asks to delete the worktree itself (`Delete worktree 'feature' from disk?`), as **Delete worktree** in the band's right-click menu does. Every confirm that deletes a worktree lists what the DELETE CHECK found in it — a `git worktree lock` and its reason, processes working there from outside its sessions, uncommitted changes and how recently they were written, commits its base lacks — and `Enter` waits for that check (up to ten seconds) before it deletes anything. `Shift+R` refreshes the pull requests (above). **A held `a` opens one dialog** and archives nothing by itself: its repeats land on the dialog, where `a` is nothing — with the confirm off, it archives one card, as `u` unarchives one. **A held `u` unarchives one card**, however long it is held: on a terminal with the kitty keyboard protocol (Ghostty, kitty, WezTerm, foot) nebula watches the key until it comes up, swallowing the repeats the terminal sends meanwhile, and the next card needs the key pressed again; a terminal without it (Terminal.app, `nebula browser`, tmux) reports a held key as one press after another, and there a long hold still walks the row | +| The grid | `r`, `a`, `u`, `d`, `A` | rename, archive, unarchive, delete, toggle archived. `Delete` and `Backspace` are bound alongside `d` and do the same thing, here and everywhere else `d` deletes something — and `a` and the row menu's Archive ask first, in the CONFIRM DIALOG `d` goes behind (`Enter` or `y` archives, `Esc` or `n` keeps the session), so typing aimed at an agent that lands on the grid archives nothing; the dialog says what makes saying yes cheap: `u` brings the session back. **Confirm on archive** (Settings → Sessions, on by default) off archives at once. In the nested layout, `d` and `a` on a thread's root that has children take the whole thread: `d` deletes every prompt and terminal nested under it, `a` archives every prompt and closes its terminals — each behind a confirm that counts and lists them, `a`'s asking even with the setting off; the root checkout's thread included, and a project left with nothing is the welcome. `d` on the last live card of a linked worktree asks about the checkout in the same confirm, before anything is deleted — `Nothing else is left in worktree 'feature': delete it from disk too?` — with three answers: `y` deletes the card and then the checkout as `d` on its band would, `n` deletes the card and keeps the empty checkout, `Esc` keeps the card alive; **Delete emptied worktree** (Settings → Sessions, off by default) skips that question and `Enter` deletes both, except when archived sessions are still filed under the checkout. With **Show all worktrees** on (Settings → Sessions, off by default) the question is never asked: the card goes alone, its checkout stays as an EMPTY BAND, and `d` on that band — a checkout with nothing running — asks to delete the worktree itself (`Delete worktree 'feature' from disk?`), as **Delete worktree** in the band's right-click menu does. Every confirm that deletes a worktree lists what the DELETE CHECK found in it — a `git worktree lock` and its reason, processes working there from outside its sessions, uncommitted changes and how recently they were written, commits its base lacks — and `Enter` waits for that check (up to ten seconds) before it deletes anything. On a band's own delete, or a card's delete that takes its emptied worktree, once the check says where the branch stands, the confirm decides the branch too: a landed branch goes with the checkout (`[Enter/y] delete both`, `k` keeps it), and a branch with unlanded commits stays (`[Enter/y] keep branch`, `D` deletes it, its commits with it). `Shift+R` refreshes the pull requests (above). **A held `a` opens one dialog** and archives nothing by itself: its repeats land on the dialog, where `a` is nothing — with the confirm off, it archives one card, as `u` unarchives one. **A held `u` unarchives one card**, however long it is held: on a terminal with the kitty keyboard protocol (Ghostty, kitty, WezTerm, foot) nebula watches the key until it comes up, swallowing the repeats the terminal sends meanwhile, and the next card needs the key pressed again; a terminal without it (Terminal.app, `nebula browser`, tmux) reports a held key as one press after another, and there a long hold still walks the row | | The grid (a card) | `e` | agent presets: saved launch definitions (harness, model, effort, text on the side of the task the form's **Text** row names — a prefix, a postfix or both; a new preset starts on `prefix` alone unless **Preset text** in Settings → Sessions says otherwise — and whether to ask for a task). The launch lands in the checkout under the cursor — or, with `Tab` (or a click on the list's bottom `[ ] new worktree` row), in a fresh worktree of the project cut first, the row turning into a green NEW WORKTREE chip naming the branch; `Tab` again flips back. The same toggle is on the list wherever it opens: in the ISSUES MODAL it flips between the root checkout and a fresh `issue-15-` worktree, and a PR SESSION's row just names the pull request's own checkout, there being nothing to flip. `Enter` asks for an optional task and starts the agent with prefix + task + postfix as its first prompt — sent empty, the prefix and postfix go alone — or, for a preset whose **Task** row is `skip`, launches at once without asking. Type to find a preset by name — letters narrow the rows to the fuzzy matches, the cursor on the best, the query in the title (`Agent presets ⌕ rev`); `↑`/`↓` (or `Ctrl+n`/`Ctrl+p`) move, `Backspace` widens, and `Esc` clears the letters before it closes. Because letters type, the verbs are chords: `Ctrl+a` / `Ctrl+e` / `Ctrl+d` create, edit, delete (`Delete` deletes too). In the PULL REQUESTS MODAL, `Shift+Tab` on a pull request is the same picker, launching the preset as a PR SESSION on it | | The grid | `p` | quick prompt: a wrapped, multi-row task box (`Shift+Enter`, `Option+Enter` or `Ctrl+J` inserts a line and `↑`/`↓` walk the rows as they wrap, as in Claude Code's own prompt — the keys for getting around a long prompt are under *Any multi-row box* below; `Esc` closes the box, `Enter` launches) that starts a new agent in the checkout under the cursor — the worktree whose band the cursor is on — with what you typed as its first prompt — no picker. Which CLI it launches is the `Agent` row under **Quick prompt** in Settings → Agents, run with that harness's own default model and effort; the session titles itself from the prompt. For one launch only, `Tab` picks a different harness (`→` drills into its model and effort, same as the new-session picker) and `Shift+Tab` picks one of your saved agent presets — adopting its harness, model, effort and prefix/postfix wrapping; sent empty with a preset on, the box launches on the prefix and postfix alone, and a `skip`-task preset picked over an empty box launches right away. Either picker hands the box back with your text intact, on `Esc` too. The launch lands the cursor on the new session's card as it goes up in its band, the grid scrolling to it (shown in the pane, the keys still on the grid) — turn off `Follow new` under **Quick prompt** in Settings → Agents to stay out of its way instead, the cursor and the pane staying on the card you were on and the footer naming the branch it went to, or turn on `Focus` there to drop straight into its terminal. The box launches in the checkout under the cursor by default — the worktree whose band the cursor is on, open or collapsed, the new session starting beside the ones already running there (more work in one session's own conversation is its follow-up, `Space`) — and on the project's root branch once `Esc` has let the aim go; turn on `New worktree` under **Quick prompt** in Settings → Agents to have every new box cut a fresh worktree first — a random branch off the fetched `origin/HEAD`, or the **Worktree base branch** setting — its band going up beside the rest, the cursor moving onto it unless `Follow new` is off. `Ctrl+N` inside the box flips between the two for this one launch only — the next box starts from the setting again: the row under the title says which — a quiet `worktree: main` with `[ ] new worktree` unticked, or a filled `NEW WORKTREE` chip naming the branch to be cut with `[✓] new worktree` ticked and the whole frame turned green — and the text and caret stay put across the flip. `Enter` on the box empty starts the session all the same — the harness, model and effort the title names, with no first prompt: you type it in the CLI (a Claude Cloud box, which needs its task, closes instead). Closing the box does not throw the prompt away: `Esc`, a click outside it and `^q` park what you typed, and the next `p` opens on it — aimed the same way, the box comes back whole, the `Tab` harness or `Shift+Tab` preset you had picked included; aimed somewhere else, your text comes back into that box's own launch. One draft is kept, in memory only, and the box that takes it back is the last place it can be lost from: clear it there and close it and it is gone | | The grid | `Shift+D` | delete every session listed for the checkout under the cursor (the confirm lists the casualties) | diff --git a/docs/sessions.md b/docs/sessions.md index 0b7af652..d0160635 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -329,7 +329,40 @@ is one key from there: `p` opens the QUICK PROMPT, focused, so the first thing y confirmed ahead of it; a check that fails or runs out of time says so rather than reading as clean. The dialog itself waits for the check (the footer says `checking 'feat' for work in progress…`), so it opens once, already filled in; only a check still out after five seconds - opens it early, with `Checking…` (`Esc` cancels throughout). Each band is a + opens it early, with `Checking…` (`Esc` cancels throughout), and `Enter`, `k` and `D` wait for + the answer. The check's git half also runs in the background for every empty band, one band per + git tick, so the band says before you press anything what a delete would lose, in place of + `nothing running`: `✓ clean · merged` (nothing destroyed, and the branch has landed), + `✓ clean · detached`, `↑4 unmerged` (the branch holds commits its base lacks — a delete keeps + the branch, and them), and, in the warning color for what a delete destroys, + `⚠ 3 modified · 2 untracked`, `⚠ 6 unpushed in submodules` (submodule commits — checked out, on + a local branch or tag, or stashed — that exist only in this worktree's own submodule store, + which the delete takes) and `⚠ detached · 2 unreachable`. + A git at work in the checkout — `git submodule update` still cloning, a fetch, a rebase or a + merge — or one stopped part-way (a rebase waiting on a conflict) reads `⋯ git submodule update + running` or `⋯ git rebase in progress` instead, dim (a stopped one that also holds what a delete + destroys says both, warned: `⚠ 2 modified · git rebase in progress`): the checkout is in flux, which is no error, + and the band is read again on the next tick; a delete confirm then keeps the branch whatever + it found. Only a read git cannot explain says `? can't read submodule lib/a · retrying` (or + `? can't read git state · retrying`), retried on the next few ticks and then at the usual pace; + the confirm shows git's own words. + A band read before its first answer says `nothing running` as before. **Merged** here counts by + content, not ancestry: a branch landed by a squash merge, a rebase-and-merge or a cherry-pick + reads merged — wider than `git branch --merged`, which knows only ancestry; a squash-merged + branch whose lines the base has changed again since reads `unmerged` (the squash test's merge + conflicts), the safe way round. The confirm then + decides the branch: a landed one is deleted with the checkout (`Its branch has landed on + origin/main and is deleted with it.`, `[Enter/y] delete both [k] keep branch`) unless + **Delete landed branch** (Settings → Sessions, on by default) is off; one with unlanded commits + is kept (`4 commits are on this branch and not on origin/main.`, `[Enter/y] keep branch [D] + delete branch too`); a card's delete that takes its emptied worktree offers the same `k` or `D`. + The delete takes the branch and tip the check judged, and only while the tip is still that + commit, so a commit made while the confirm was open keeps the branch. The base, origin's + default branch, the root's branch and a branch another checkout has out are never deleted, + and a checkout that switched branches since the confirm keeps both. The confirm (not the band) also names + the gitignored files `git worktree remove --force` would take that are not build output — + `⚠ 2 ignored files are deleted too: .env, .claude/settings.local.json` — leaving out `target/`, + `node_modules/`, `dist/` and the like. Each band is a titled rule over one row of cards: the rule names the checkout in its scope color (`↳ feat`, `⌂ main` for the root — the project is the grid's own scope, named once in the header), with that checkout's uncommitted changes right behind the branch in the warning color (`↳ feat +3 From b63e32b39ffcac14a7f6c46020304e64828f9ec1 Mon Sep 17 00:00:00 2001 From: Drew Bradford <16455249+DrewBradfordXYZ@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:23:58 -0400 Subject: [PATCH 3/6] An idle band says when something outside nebula is working in it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/nebula-core/src/protocol.rs | 12 +- crates/nebula-daemon/src/registry.rs | 14 +- crates/nebula-daemon/src/registry/projects.rs | 22 +-- crates/nebula-daemon/src/server.rs | 12 +- crates/nebula-daemon/src/worktree_check.rs | 48 ++---- crates/nebula-tui/src/app.rs | 2 +- crates/nebula-tui/src/band_safety.rs | 137 +++++++++++++++++- crates/nebula-tui/src/delete_check.rs | 2 +- crates/nebula-tui/src/event_loop/launcher.rs | 4 +- crates/nebula-tui/src/event_loop/tests/mod.rs | 2 +- crates/nebula-tui/src/ui/launcher_view.rs | 4 +- crates/nebula/tests/e2e_pty.rs | 2 +- docs/sessions.md | 7 +- 13 files changed, 194 insertions(+), 74 deletions(-) diff --git a/crates/nebula-core/src/protocol.rs b/crates/nebula-core/src/protocol.rs index 7a22ead0..d49254db 100644 --- a/crates/nebula-core/src/protocol.rs +++ b/crates/nebula-core/src/protocol.rs @@ -31,8 +31,8 @@ pub const CHECK_WORKTREE_PROTOCOL: u32 = 51; pub const WORKTREE_LOCK_PROTOCOL: u32 = 53; /// First protocol whose daemon says where a checkout's branch stands -/// (`WorktreeCheck::landing`), answers a `git_only` `CheckWorktree` for a -/// band, and deletes a branch with its worktree (`DeleteWorktree`'s +/// (`WorktreeCheck::landing`), answers a `band` `CheckWorktree` for an +/// idle band's line, and deletes a branch with its worktree (`DeleteWorktree`'s /// `delete_branch`). An older daemon is sent neither field, and its /// confirm and bands read as they did. pub const BRANCH_LANDING_PROTOCOL: u32 = 54; @@ -162,12 +162,12 @@ pub enum ClientRequest { CheckWorktree { req_id: u64, id: WorktreeId, - /// The git half alone, no process sweep: what an idle band's line - /// shows (the sweep stays with the confirm). Omitted from the + /// An idle band's check, asked every rotation: the same check less + /// the gitignored files only a confirm lists. Omitted from the /// frame when false; sent true only to a daemon at /// [`BRANCH_LANDING_PROTOCOL`]. #[serde(default, skip_serializing_if = "std::ops::Not::not")] - git_only: bool, + band: bool, }, /// Opt-in cleanup for a linked worktree whose PR was detected as merged. /// The daemon re-checks the safety invariants before touching disk: @@ -1153,7 +1153,7 @@ mod tests { let new = ClientRequest::CheckWorktree { req_id: 2, id: id.clone(), - git_only: false, + band: false, }; let (_, old): (String, v51::CheckWorktree) = variant_body(&new); assert_eq!(old, v51::CheckWorktree { req_id: 2, id }); diff --git a/crates/nebula-daemon/src/registry.rs b/crates/nebula-daemon/src/registry.rs index 41946949..58056c60 100644 --- a/crates/nebula-daemon/src/registry.rs +++ b/crates/nebula-daemon/src/registry.rs @@ -4515,11 +4515,12 @@ mod tests { ); } - /// A band's check is the git half alone: no process sweep, and the - /// branch's landing — here, a branch with nothing of its own, which - /// has landed, then one with a commit, which has not. + /// A band's check names a process working in the checkout, as a + /// confirm's does, and says where the branch stands — here, a branch + /// with nothing of its own, which has landed, then one with a commit, + /// which has not. #[tokio::test] - async fn a_git_only_check_reads_the_branch_and_skips_the_sweep() { + async fn a_band_check_reads_the_branch_and_names_outside_work() { let tmp = tempfile::tempdir().unwrap(); let root = std::fs::canonicalize(tmp.path()).unwrap(); let repo = init_repo(&root); @@ -4537,7 +4538,10 @@ mod tests { let check = daemon.check_worktree(&id, None, true).await; drop(worker); let check = check.unwrap(); - assert!(check.processes.is_empty(), "{check:?}"); + assert!( + check.processes.iter().any(|p| p.name.contains("sleep")), + "{check:?}" + ); let landing = check.landing.clone().expect("a landing"); assert!(landing.branch_landed, "{check:?}"); diff --git a/crates/nebula-daemon/src/registry/projects.rs b/crates/nebula-daemon/src/registry/projects.rs index 079ffb09..5b164266 100644 --- a/crates/nebula-daemon/src/registry/projects.rs +++ b/crates/nebula-daemon/src/registry/projects.rs @@ -567,12 +567,13 @@ impl Daemon { /// /// `client` is the asking client's pid, when the socket says it: its /// own process tree (the TUI, its background git reads) is spared. - /// `git_only` skips the process sweep — an idle band's line. + /// `band` is an idle band's line: the same check, less the + /// gitignored files a confirm alone lists. pub async fn check_worktree( self: &Arc, id: &WorktreeId, client: Option, - git_only: bool, + band: bool, ) -> Result { let lookup = id.clone(); let worktree = self @@ -584,17 +585,18 @@ impl Daemon { .store_blocking(move |store| store.get_project(&project_lookup)) .await? .context("project not found")?; - let spare = if git_only { - None - } else { - let mut spare = self.own_session_pids(id).await?; - spare.extend(self.nebula_helper_pids(client).await?); - Some(spare) - }; + // An idle band has no sessions of its own, but may hold a + // prewarmed spare; spared the same way, so a band says "in use" + // only for work nebula does not already show. + let mut spare = self.own_session_pids(id).await?; + spare.extend(self.nebula_helper_pids(client).await?); let base = crate::config::Config::load() .worktree_base_branch() .map(str::to_string); - Ok(crate::worktree_check::check(&project.repo_path, &worktree.path, spare, base).await) + Ok( + crate::worktree_check::check(&project.repo_path, &worktree.path, spare, base, band) + .await, + ) } /// Every pid the worktree's own sessions lead or run, prewarmed spares diff --git a/crates/nebula-daemon/src/server.rs b/crates/nebula-daemon/src/server.rs index 4aa3d1d0..0fc4fc70 100644 --- a/crates/nebula-daemon/src/server.rs +++ b/crates/nebula-daemon/src/server.rs @@ -235,12 +235,8 @@ impl ClientConnection { self.spawn_delete_worktree(req_id, id, force, delete_branch); Ok(true) } - ClientRequest::CheckWorktree { - req_id, - id, - git_only, - } => { - self.spawn_check_worktree(req_id, id, git_only); + ClientRequest::CheckWorktree { req_id, id, band } => { + self.spawn_check_worktree(req_id, id, band); Ok(true) } ClientRequest::CleanupMergedWorktree { @@ -928,7 +924,7 @@ impl ClientConnection { }); } - fn spawn_check_worktree(&self, req_id: u64, id: nebula_core::WorktreeId, git_only: bool) { + fn spawn_check_worktree(&self, req_id: u64, id: nebula_core::WorktreeId, band: bool) { // `git status` on a big checkout and an `lsof` of every process are // each a fraction of a second; off the request loop, like a delete. let daemon = self.daemon.clone(); @@ -938,7 +934,7 @@ impl ClientConnection { let reads_lock = self.client_protocol >= nebula_core::WORKTREE_LOCK_PROTOCOL; let landing = self.client_protocol >= nebula_core::BRANCH_LANDING_PROTOCOL; tokio::spawn(async move { - let ev = match daemon.check_worktree(&id, client, git_only).await { + let ev = match daemon.check_worktree(&id, client, band).await { Ok(mut check) => { if !reads_lock { check.locked = None; diff --git a/crates/nebula-daemon/src/worktree_check.rs b/crates/nebula-daemon/src/worktree_check.rs index a06a1a17..c20e058b 100644 --- a/crates/nebula-daemon/src/worktree_check.rs +++ b/crates/nebula-daemon/src/worktree_check.rs @@ -25,50 +25,35 @@ const IGNORED_NAMED: usize = 3; /// Run the DELETE CHECK on `worktree`, a checkout of `repo`. `spare` is /// every pid the worktree's own sessions lead or run — the confirm counts /// those already and they go down with it — and `configured_base` the -/// WORKTREE BASE BRANCH setting. `spare` None is an idle band's check: -/// the git half, with the sweep used only to see whether a git is at -/// work in the checkout, and no process listed. +/// WORKTREE BASE BRANCH setting. `band` is an idle band's check, asked +/// every rotation: it skips the gitignored files, a confirm's question +/// only, and keeps the rest — the processes at work in the checkout +/// included, which is how a band says it is in use. pub async fn check( repo: &Path, worktree: &Path, - spare: Option>, + spare: HashSet, configured_base: Option, + band: bool, ) -> WorktreeCheck { if !worktree.exists() { // Already gone: a delete only drops the row, and loses nothing. return WorktreeCheck::default(); } - let Some(spare) = spare else { - // A band's check: the git half, and whether a git is at work in - // the checkout (a `git submodule update` still cloning, a rebase) - // — then its state is in flux, which the band says instead of - // reading it. The sweep runs first, so this check's own gits are - // not in it (and they only read, which the sweep ignores). - let dir = worktree.to_path_buf(); - let sweep = tokio::task::spawn_blocking(move || processes_in(&dir, &HashSet::new())).await; - let mut check = git_half(repo, worktree, configured_base, false).await; - let live = match sweep { - Ok(Ok(processes)) => live_git_op(&processes).await, - _ => None, - }; - let op = match live { - Some(op) => Some((op, true)), - None => paused_git_op(worktree).map(|op| (op, false)), - }; - note_op(&mut check, op); - return check; - }; // The sweep first, then git: run side by side, the daemon's own // `git -C ` (and the submodule gits under it) would be in // the sweep, working in the checkout. let dir = worktree.to_path_buf(); let sweep = tokio::task::spawn_blocking(move || processes_in(&dir, &spare)).await; - let mut check = git_half(repo, worktree, configured_base, true).await; + let mut check = git_half(repo, worktree, configured_base, !band).await; match sweep { Ok(Ok(processes)) => check.processes = processes, Ok(Err(e)) => check.process_error = Some(format!("{e:#}")), Err(e) => check.process_error = Some(format!("process sweep failed: {e}")), } + // A git at work in the checkout (a `git submodule update` still + // cloning, a rebase) puts its state in flux, which a band says + // instead of reading it. let op = match live_git_op(&check.processes).await { Some(op) => Some((op, true)), None => paused_git_op(worktree).map(|op| (op, false)), @@ -391,8 +376,7 @@ fn process_cwds() -> Result> { /// Every process this user can see, with its working directory: one /// `lsof` asked for the `cwd` of everything, under a tenth of a second /// to a few hundred ms on a busy Mac. It runs once per confirm, and once -/// per idle band's check (at most one each git tick), to see a git at -/// work in the checkout. +/// per idle band's check (at most one each git tick). #[cfg(not(target_os = "linux"))] fn process_cwds() -> Result> { let out = std::process::Command::new("lsof") @@ -556,7 +540,7 @@ mod tests { assert!(run(&["commit", "-q", "-am", "main"])); assert!(run(&["switch", "-q", "feat"])); assert!(!run(&["rebase", "-q", "main"]), "conflicts and stops"); - let check = check(&repo, &repo, None, None).await; + let check = check(&repo, &repo, HashSet::new(), None, true).await; let landing = check.landing.expect("a landing"); assert_eq!(landing.git_op.as_deref(), Some("rebase")); assert!(!landing.git_op_live); @@ -634,14 +618,14 @@ mod tests { .unwrap(); let mut seen = None; for _ in 0..40 { - let band = check(&repo, &wt, None, None).await; + let band = check(&repo, &wt, HashSet::new(), None, true).await; if let Some(op) = band.landing.and_then(|l| l.git_op_live.then_some(l.git_op)) { seen = op; break; } tokio::time::sleep(std::time::Duration::from_millis(100)).await; } - let confirm = check(&repo, &wt, Some(HashSet::new()), None).await; + let confirm = check(&repo, &wt, HashSet::new(), None, false).await; // The update and the clone under it, before the repo goes. let group = nix::unistd::Pid::from_raw(update.id() as i32); let _ = nix::sys::signal::killpg(group, nix::sys::signal::Signal::SIGKILL); @@ -678,7 +662,7 @@ mod tests { .join("half"); std::fs::create_dir_all(broken.join("objects")).unwrap(); std::fs::write(broken.join("HEAD"), "not a ref\n").unwrap(); - let check = check(&repo, &repo, None, None).await; + let check = check(&repo, &repo, HashSet::new(), None, true).await; assert!(check.git_error.is_some(), "{check:?}"); let landing = check.landing.expect("a landing"); assert_eq!(landing.failed_submodule.as_deref(), Some("half")); @@ -688,7 +672,7 @@ mod tests { async fn a_missing_checkout_checks_clean() { let tmp = tempfile::tempdir().unwrap(); let gone = tmp.path().join("gone"); - let check = check(tmp.path(), &gone, Some(HashSet::new()), None).await; + let check = check(tmp.path(), &gone, HashSet::new(), None, false).await; assert!(!check.has_findings(), "{check:?}"); } } diff --git a/crates/nebula-tui/src/app.rs b/crates/nebula-tui/src/app.rs index f9c9acd3..e5ce3f5e 100644 --- a/crates/nebula-tui/src/app.rs +++ b/crates/nebula-tui/src/app.rs @@ -4381,7 +4381,7 @@ pub struct JobState { pub worktree_changes: HashMap, std::time::Instant)>, /// The checkout the sweep is reading right now; its answer clears it. pub worktree_changes_inflight: Option, - /// The DELETE CHECK's git half for each idle band, and when it was + /// The DELETE CHECK for each idle band, and when it was /// read: what its line says a delete would lose (`band_safety`). pub band_checks: crate::band_safety::BandChecks, /// The lines added and removed in each checkout, read beside its diff --git a/crates/nebula-tui/src/band_safety.rs b/crates/nebula-tui/src/band_safety.rs index 924f8857..6ac143aa 100644 --- a/crates/nebula-tui/src/band_safety.rs +++ b/crates/nebula-tui/src/band_safety.rs @@ -3,8 +3,8 @@ //! An EMPTY BAND used to 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` — or a question to an agent — before it could be cleared. Each idle -//! band now carries the DELETE CHECK's git half (`ClientRequest::CheckWorktree` -//! with `git_only`; the process sweep stays with the confirm), asked in the +//! band now carries the DELETE CHECK (`ClientRequest::CheckWorktree` with +//! `band`, which leaves out the gitignored files), asked in the //! background on the git tick: one band per tick, the least recently read //! first, at most one in flight, and none read again sooner than //! [`FRESH_FOR`]. Its verdict takes the place of `nothing running`, in @@ -15,7 +15,12 @@ //! - `↑4 unmerged` — the branch holds commits its base lacks (a delete //! keeps the branch, and them); //! - `⚠ 3 modified · 2 untracked`, `⚠ 6 unpushed in submodules`, -//! `⚠ detached · 2 unreachable` — what a delete destroys. +//! `⚠ detached · 2 unreachable` — what a delete destroys; +//! - `● in use by just, cargo` — a process outside nebula's sessions has +//! its working directory in the checkout (a build, a shell, a tool's +//! scratch run): `0 sessions` alone never says that. It leads the git +//! verdict, `● in use by zsh · clean · detached`, and follows a warning, +//! `⚠ 3 modified · in use by cargo`. //! //! "Merged" is counted by content, not ancestry: a branch landed by squash //! merge, rebase or cherry-pick reads merged. Before the first read a band @@ -95,7 +100,7 @@ pub(crate) fn sweep(app: &mut App, out: &mut Vec) { out.push(ClientRequest::CheckWorktree { req_id, id: id.clone(), - git_only: true, + band: true, }); app.jobs.band_checks.inflight = Some((req_id, id, Instant::now())); } @@ -204,6 +209,9 @@ pub(crate) enum Tone { Ahead, /// What a delete destroys: the warning color. Warn, + /// Something outside nebula's sessions is working in the checkout: + /// the warning color, though a delete would not destroy it. + InUse, } /// What a band's line says about its checkout. @@ -213,10 +221,59 @@ pub(crate) struct Verdict { pub tone: Tone, } +/// Process names a band lists before it says "+N". +const IN_USE_NAMED: usize = 2; + /// The band's verdict on `check`: None when there is nothing to say — a /// daemon that did not say where the branch stands, or a branch that is -/// its base's own. +/// its base's own, with nothing at work in it. pub(crate) fn verdict(check: &WorktreeCheck) -> Option { + let git = git_verdict(check); + if check.landing.is_none() || check.processes.is_empty() || git_running(check) { + // A git at work names itself (`⋯ git submodule update running`). + return git; + } + let in_use = format!("in use by {}", process_names(check)); + Some(match git { + Some(v) if v.tone == Tone::Warn => Verdict { + text: format!("{} · {in_use}", v.text), + tone: Tone::Warn, + }, + Some(v) => Verdict { + text: format!("● {in_use} · {}", v.text.trim_start_matches("✓ ")), + tone: Tone::InUse, + }, + None => Verdict { + text: format!("● {in_use}"), + tone: Tone::InUse, + }, + }) +} + +/// The distinct names of the processes at work in the checkout, in pid +/// order, the first [`IN_USE_NAMED`] of them and how many more. +fn process_names(check: &WorktreeCheck) -> String { + let mut names: Vec<&str> = Vec::new(); + for p in &check.processes { + let name = if p.name.is_empty() { + "?" + } else { + p.name.as_str() + }; + if !names.contains(&name) { + names.push(name); + } + } + let more = names.len().saturating_sub(IN_USE_NAMED); + let mut text = names[..names.len().min(IN_USE_NAMED)].join(", "); + if more > 0 { + text.push_str(&format!(" +{more}")); + } + text +} + +/// What git alone says of the checkout. +fn git_verdict(check: &WorktreeCheck) -> Option { let landing = check.landing.as_ref()?; // A git at work: the checkout is in flux, and that is what there is to // say — not an error, and not a warning. One stopped part-way (a @@ -465,4 +522,74 @@ mod tests { Some(("? can't read git state · retrying".into(), Tone::Clean)) ); } + + fn working(names: &[&str]) -> Vec { + names + .iter() + .enumerate() + .map(|(i, name)| nebula_core::WorktreeProcess { + pid: 100 + i as u32, + name: (*name).into(), + }) + .collect() + } + + #[test] + fn a_process_outside_nebula_says_the_checkout_is_in_use() { + let in_use = WorktreeCheck { + detached: true, + processes: working(&["just"]), + ..WorktreeCheck::default() + }; + assert_eq!( + text(&with(WorktreeLanding::default(), in_use)), + Some(("● in use by just · clean · detached".into(), Tone::InUse)), + "a clean checkout something is working in reads in use, not ✓" + ); + let unlanded = WorktreeCheck { + processes: working(&["zsh"]), + ..WorktreeCheck::default() + }; + assert_eq!( + text(&with(WorktreeLanding::default(), unlanded)), + Some(("● in use by zsh".into(), Tone::InUse)), + "in use even when git has nothing to say" + ); + let dirty = WorktreeCheck { + changes: 3, + processes: working(&["cargo", "rustc", "rustc", "cc", "ld"]), + ..WorktreeCheck::default() + }; + assert_eq!( + text(&with(WorktreeLanding::default(), dirty)), + Some(( + "⚠ 3 modified · in use by cargo, rustc +2".into(), + Tone::Warn + )), + "what a delete destroys leads; names are distinct" + ); + let live = WorktreeLanding { + git_op: Some("submodule update".into()), + git_op_live: true, + ..WorktreeLanding::default() + }; + let cloning = WorktreeCheck { + processes: working(&["git"]), + ..WorktreeCheck::default() + }; + assert_eq!( + text(&with(live, cloning)), + Some(("⋯ git submodule update running".into(), Tone::Clean)), + "a git at work names itself" + ); + let no_landing = WorktreeCheck { + processes: working(&["just"]), + ..WorktreeCheck::default() + }; + assert_eq!( + text(&no_landing), + None, + "an older daemon's check says nothing" + ); + } } diff --git a/crates/nebula-tui/src/delete_check.rs b/crates/nebula-tui/src/delete_check.rs index 72c1cdad..de42b353 100644 --- a/crates/nebula-tui/src/delete_check.rs +++ b/crates/nebula-tui/src/delete_check.rs @@ -131,7 +131,7 @@ pub(crate) fn sync(app: &mut App, out: &mut Vec) { out.push(ClientRequest::CheckWorktree { req_id, id: id.clone(), - git_only: false, + band: false, }); app.requests.delete_checks.entries.insert( id, diff --git a/crates/nebula-tui/src/event_loop/launcher.rs b/crates/nebula-tui/src/event_loop/launcher.rs index abadcdfa..a8563dcd 100644 --- a/crates/nebula-tui/src/event_loop/launcher.rs +++ b/crates/nebula-tui/src/event_loop/launcher.rs @@ -4160,9 +4160,9 @@ mod tests { [ClientRequest::CheckWorktree { req_id, id, - git_only: true, + band: true, }] if id.0 == "w3" => *req_id, - other => panic!("one git-only check of the idle band: {other:?}"), + other => panic!("one band check of the idle band: {other:?}"), }; out.clear(); crate::band_safety::sweep(&mut app, &mut out); diff --git a/crates/nebula-tui/src/event_loop/tests/mod.rs b/crates/nebula-tui/src/event_loop/tests/mod.rs index eb031add..2950c26c 100644 --- a/crates/nebula-tui/src/event_loop/tests/mod.rs +++ b/crates/nebula-tui/src/event_loop/tests/mod.rs @@ -22857,7 +22857,7 @@ fn a_daemon_before_branch_landing_gets_the_old_confirm() { let req_id = match out.as_slice() { [ClientRequest::CheckWorktree { req_id, - git_only: false, + band: false, .. }] => *req_id, other => panic!("{other:?}"), diff --git a/crates/nebula-tui/src/ui/launcher_view.rs b/crates/nebula-tui/src/ui/launcher_view.rs index b3b872f1..f8f5601e 100644 --- a/crates/nebula-tui/src/ui/launcher_view.rs +++ b/crates/nebula-tui/src/ui/launcher_view.rs @@ -1091,7 +1091,9 @@ pub(super) fn empty_band_lead( match verdict { Some(v) => { let color = match v.tone { - crate::band_safety::Tone::Warn => th.warn, + // In use is the warning color too: a band busy with work + // nebula does not show is no band to delete now. + crate::band_safety::Tone::Warn | crate::band_safety::Tone::InUse => th.warn, crate::band_safety::Tone::Ahead if !lit => th.text, _ => fg, }; diff --git a/crates/nebula/tests/e2e_pty.rs b/crates/nebula/tests/e2e_pty.rs index 82af511d..f89890ee 100644 --- a/crates/nebula/tests/e2e_pty.rs +++ b/crates/nebula/tests/e2e_pty.rs @@ -525,7 +525,7 @@ async fn full_crud_attach_and_restart_persistence() { &ClientRequest::CheckWorktree { req_id: 50, id: feature_wt_id.clone(), - git_only: false, + band: false, }, ) .await diff --git a/docs/sessions.md b/docs/sessions.md index d0160635..4f3788da 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -330,7 +330,7 @@ is one key from there: `p` opens the QUICK PROMPT, focused, so the first thing y clean. The dialog itself waits for the check (the footer says `checking 'feat' for work in progress…`), so it opens once, already filled in; only a check still out after five seconds opens it early, with `Checking…` (`Esc` cancels throughout), and `Enter`, `k` and `D` wait for - the answer. The check's git half also runs in the background for every empty band, one band per + the answer. The check also runs in the background for every empty band (less the ignored files), one band per git tick, so the band says before you press anything what a delete would lose, in place of `nothing running`: `✓ clean · merged` (nothing destroyed, and the branch has landed), `✓ clean · detached`, `↑4 unmerged` (the branch holds commits its base lacks — a delete keeps @@ -338,6 +338,11 @@ is one key from there: `p` opens the QUICK PROMPT, focused, so the first thing y `⚠ 3 modified · 2 untracked`, `⚠ 6 unpushed in submodules` (submodule commits — checked out, on a local branch or tag, or stashed — that exist only in this worktree's own submodule store, which the delete takes) and `⚠ detached · 2 unreachable`. + A band's `0 sessions` counts nebula's own sessions only, so a process outside them with its + working directory in the checkout — a build, a shell, a tool's scratch run — puts `● in use by + just, cargo` ahead of the verdict (`● in use by zsh · clean · detached`), or after a warning + (`⚠ 3 modified · in use by cargo`). Like the rest of the line it is read every 30 s or so, so a + process that has just exited may still be named; the confirm checks again. A git at work in the checkout — `git submodule update` still cloning, a fetch, a rebase or a merge — or one stopped part-way (a rebase waiting on a conflict) reads `⋯ git submodule update running` or `⋯ git rebase in progress` instead, dim (a stopped one that also holds what a delete From e490123811c93f93a4d75e510bf5cd67b5143b2f Mon Sep 17 00:00:00 2001 From: Drew Bradford <16455249+DrewBradfordXYZ@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:16:52 -0400 Subject: [PATCH 4/6] A tag counts as unpushed only when no remote has it 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) --- crates/nebula-daemon/src/git.rs | 196 +++++++++++++++++---- crates/nebula-daemon/src/worktree_check.rs | 16 +- 2 files changed, 171 insertions(+), 41 deletions(-) diff --git a/crates/nebula-daemon/src/git.rs b/crates/nebula-daemon/src/git.rs index 65e9a602..1a0785b2 100644 --- a/crates/nebula-daemon/src/git.rs +++ b/crates/nebula-daemon/src/git.rs @@ -886,9 +886,12 @@ pub async fn delete_branch_at(repo: &Path, tip: &nebula_core::BranchTip) -> Resu } /// How many commits in `worktree`'s submodule stores nothing else keeps: -/// commits reachable from a store's HEAD, local branches, tags or stash -/// that no remote-tracking ref of it holds and the ROOT WORKTREE's store -/// for the same submodule (`root`'s) lacks too. A linked worktree's +/// commits reachable from a store's HEAD, local branches or stash that no +/// remote-tracking ref of it holds and the ROOT WORKTREE's store for the +/// same submodule (`root`'s) lacks too. Tags count only when +/// `ask_remotes` (a confirm, never a band): git records no "fetched from" +/// for a tag, so only a remote can say whether a tag is the one copy, and +/// a band does not go to the network every rotation. A linked worktree's /// submodules live in its own git dir (`.git/worktrees//modules/…`), /// which `git worktree remove` deletes — so a superproject commit can pin /// a submodule commit that exists nowhere else. The stores counted are @@ -899,7 +902,7 @@ pub async fn delete_branch_at(repo: &Path, tip: &nebula_core::BranchTip) -> Resu /// git dir. Never the main repository's `.git/modules`, which a worktree /// delete leaves alone. The stores are asked [`SUBMODULE_JOBS`] at a time: /// a repo with two dozen submodules is most of what a confirm waits for. -pub async fn submodule_unpushed(worktree: &Path, root: &Path) -> Result { +pub async fn submodule_unpushed(worktree: &Path, root: &Path, ask_remotes: bool) -> Result { let own_git = PathBuf::from( git_read(worktree, &["rev-parse", "--absolute-git-dir"]) .await? @@ -962,7 +965,7 @@ pub async fn submodule_unpushed(worktree: &Path, root: &Path) -> Result { .and_then(|root_git| kept_store(&store, &own_git, &checkout, root, root_git)); let cwd = checkout.clone(); jobs.spawn(async move { - lost_in_store(cwd, store, kept) + lost_in_store(cwd, store, kept, ask_remotes) .await .map_err(|e| SubmoduleError::wrap(&label, e)) }); @@ -1085,22 +1088,29 @@ fn kept_store( } /// What one store holds that no remote does and `kept` (the root's store -/// for the same submodule) lacks: its HEAD, its local branches and tags (a -/// commit on a branch, then HEAD detached back to the pin, is still lost -/// with the store) and its stash. `--glob` matches nothing when there is -/// no stash; the bracket keeps git from reading it as `refs/stash/*`. -async fn lost_in_store(cwd: PathBuf, store: PathBuf, kept: Option) -> Result { +/// for the same submodule) lacks: its HEAD, its local branches (a commit +/// on a branch, then HEAD detached back to the pin, is still lost with the +/// store), its stash, and with `ask_remotes` its tags no remote has (see +/// [`unpushed_tags`]). `--glob` matches nothing when there is no stash; +/// the bracket keeps git from reading it as `refs/stash/*`. +async fn lost_in_store( + cwd: PathBuf, + store: PathBuf, + kept: Option, + ask_remotes: bool, +) -> Result { let git_dir = format!("--git-dir={}", store.display()); - let refs = [ - "--branches", - "--tags", - "--glob=refs/stas[h]", - "--not", - "--remotes", - ]; + let tags = match ask_remotes { + true => unpushed_tags(&cwd, &git_dir).await?, + false => Vec::new(), + }; + let mut refs = vec!["--branches", "--glob=refs/stas[h]"]; + refs.extend(tags.iter().map(String::as_str)); + refs.extend(["--not", "--remotes"]); + let refs = refs.as_slice(); let with_head: Vec<&str> = [git_dir.as_str(), "rev-list", "HEAD"] .into_iter() - .chain(refs) + .chain(refs.iter().copied()) .collect(); let lost = match git_read(&cwd, &with_head).await { Ok(lost) => lost, @@ -1112,7 +1122,7 @@ async fn lost_in_store(cwd: PathBuf, store: PathBuf, kept: Option) -> R Err(e) if !head_resolves(&cwd, &git_dir).await => { let refs_only: Vec<&str> = [git_dir.as_str(), "rev-list"] .into_iter() - .chain(refs) + .chain(refs.iter().copied()) .collect(); git_read(&cwd, &refs_only).await.map_err(|_| e)? } @@ -1128,6 +1138,62 @@ async fn lost_in_store(cwd: PathBuf, store: PathBuf, kept: Option) -> R Ok(u32::try_from(n).unwrap_or(u32::MAX)) } +/// The store's tags (full ref names) that no remote of it has under the +/// same name at the same object. A tag a fetch brought in names commits +/// the remote keeps, but git records no "fetched from" for a tag, so only +/// the remote can tell it from one made here and never pushed. The remote +/// is asked only when some tag holds a commit no branch, stash or +/// remote-tracking ref does — usually none, and then nothing goes to the +/// network — and timeboxed like a fetch. A remote that does not answer +/// keeps none of the tags: a confirm warns rather than guess. +async fn unpushed_tags(cwd: &Path, git_dir: &str) -> Result> { + let held = git_read( + cwd, + &[ + git_dir, + "rev-list", + "--max-count=1", + "--tags", + "--not", + "--branches", + "--glob=refs/stas[h]", + "--remotes", + ], + ) + .await?; + if held.trim().is_empty() { + return Ok(Vec::new()); + } + let mut on_remotes = HashSet::new(); + let remotes = git_read(cwd, &[git_dir, "remote"]).await?; + for remote in remotes.lines().map(str::trim).filter(|r| !r.is_empty()) { + let Ok(listed) = git_remote(cwd, &[git_dir, "ls-remote", "--tags", remote]).await else { + continue; + }; + for line in listed.lines() { + if let Some((oid, name)) = line.split_once('\t') { + on_remotes.insert((name.to_string(), oid.to_string())); + } + } + } + let local = git_read( + cwd, + &[ + git_dir, + "for-each-ref", + "--format=%(objectname) %(refname)", + "refs/tags", + ], + ) + .await?; + Ok(local + .lines() + .filter_map(|line| line.split_once(' ')) + .filter(|(oid, name)| !on_remotes.contains(&(name.to_string(), oid.to_string()))) + .map(|(_, name)| name.to_string()) + .collect()) +} + /// Does the store at `git_dir` (a `--git-dir=` argument) have a HEAD /// that names a commit? No on an unborn branch, as in a clone not yet /// fetched. @@ -2383,7 +2449,7 @@ mod tests { let mut args = allow.to_vec(); args.extend(["submodule", "update", "--init", "-q"]); git(&wt, &args).await.unwrap(); - assert_eq!(submodule_unpushed(&wt, &repo).await.unwrap(), 0); + assert_eq!(submodule_unpushed(&wt, &repo, true).await.unwrap(), 0); let sub = wt.join("lib"); git(&sub, &["config", "user.email", "t@t"]).await.unwrap(); git(&sub, &["config", "user.name", "t"]).await.unwrap(); @@ -2392,7 +2458,7 @@ mod tests { .await .unwrap(); assert!(!sub.join(".git").exists(), "deinitialised"); - assert_eq!(submodule_unpushed(&wt, &repo).await.unwrap(), 1); + assert_eq!(submodule_unpushed(&wt, &repo, true).await.unwrap(), 1); } #[test] @@ -2414,7 +2480,7 @@ mod tests { git(&wt, &["init", "-q", "--bare", &store.to_string_lossy()]) .await .unwrap(); - assert_eq!(submodule_unpushed(&wt, &repo).await.unwrap(), 0); + assert_eq!(submodule_unpushed(&wt, &repo, true).await.unwrap(), 0); } /// A store git cannot read names its submodule in the error, for the @@ -2428,7 +2494,7 @@ mod tests { let broken = Path::new(own.trim()).join("modules").join("half"); std::fs::create_dir_all(broken.join("objects")).unwrap(); std::fs::write(broken.join("HEAD"), "not a ref\n").unwrap(); - let err = submodule_unpushed(&wt, &repo).await.unwrap_err(); + let err = submodule_unpushed(&wt, &repo, true).await.unwrap_err(); let failed = err .downcast_ref::() .unwrap_or_else(|| panic!("{err:#}")); @@ -2486,7 +2552,7 @@ mod tests { let mut args = allow.to_vec(); args.extend(["submodule", "update", "--init", "--recursive", "-q"]); git(&wt, &args).await.unwrap(); - assert_eq!(submodule_unpushed(&wt, &repo).await.unwrap(), 0); + assert_eq!(submodule_unpushed(&wt, &repo, true).await.unwrap(), 0); let nested = wt.join("lib").join("inner"); git(&nested, &["config", "user.email", "t@t"]) @@ -2494,7 +2560,7 @@ mod tests { .unwrap(); git(&nested, &["config", "user.name", "t"]).await.unwrap(); commit_file(&nested, "n.txt", "n").await; - assert_eq!(submodule_unpushed(&wt, &repo).await.unwrap(), 1); + assert_eq!(submodule_unpushed(&wt, &repo, true).await.unwrap(), 1); } /// A symbolic-ref branch is deleted itself, never what it points at. @@ -2554,11 +2620,11 @@ mod tests { git(&repo, &["commit", "-q", "-m", "add lib"]) .await .unwrap(); - assert_eq!(submodule_unpushed(&repo, &repo).await.unwrap(), 0); + assert_eq!(submodule_unpushed(&repo, &repo, true).await.unwrap(), 0); let wt = add_worktree(&repo, "feat", None).await.unwrap(); assert_eq!( - submodule_unpushed(&wt, &repo).await.unwrap(), + submodule_unpushed(&wt, &repo, true).await.unwrap(), 0, "not checked out in the worktree: nothing in it" ); @@ -2575,7 +2641,7 @@ mod tests { ) .await .unwrap(); - assert_eq!(submodule_unpushed(&wt, &repo).await.unwrap(), 0); + assert_eq!(submodule_unpushed(&wt, &repo, true).await.unwrap(), 0); let sub = wt.join("lib"); git(&sub, &["config", "user.email", "t@t"]).await.unwrap(); git(&sub, &["config", "user.name", "t"]).await.unwrap(); @@ -2585,7 +2651,7 @@ mod tests { .await .unwrap(); assert!(changed_paths(&wt).await.unwrap().is_empty()); - assert_eq!(submodule_unpushed(&wt, &repo).await.unwrap(), 2); + assert_eq!(submodule_unpushed(&wt, &repo, true).await.unwrap(), 2); // Fetched into the root's copy but on none of its refs, they are // dangling there — gone at its next gc — so still lost. @@ -2593,12 +2659,12 @@ mod tests { git(&repo.join("lib"), &["fetch", "-q", &wt_sub, "HEAD"]) .await .unwrap(); - assert_eq!(submodule_unpushed(&wt, &repo).await.unwrap(), 2); + assert_eq!(submodule_unpushed(&wt, &repo, true).await.unwrap(), 2); // A branch there keeps them: they survive the delete. git(&repo.join("lib"), &["branch", "kept", "FETCH_HEAD"]) .await .unwrap(); - assert_eq!(submodule_unpushed(&wt, &repo).await.unwrap(), 0); + assert_eq!(submodule_unpushed(&wt, &repo, true).await.unwrap(), 0); // A commit on a local branch of the worktree's submodule, HEAD // then detached back to the pin: on no remote, in no other store. @@ -2609,13 +2675,75 @@ mod tests { .await .unwrap(); assert!(changed_paths(&wt).await.unwrap().is_empty()); - assert_eq!(submodule_unpushed(&wt, &repo).await.unwrap(), 1); + assert_eq!(submodule_unpushed(&wt, &repo, true).await.unwrap(), 1); // A stash there is lost with the store too. git(&sub, &["branch", "-q", "-D", "side"]).await.unwrap(); - assert_eq!(submodule_unpushed(&wt, &repo).await.unwrap(), 0); + assert_eq!(submodule_unpushed(&wt, &repo, true).await.unwrap(), 0); std::fs::write(sub.join("x.txt"), "edited").unwrap(); git(&sub, &["stash", "-q"]).await.unwrap(); - assert!(submodule_unpushed(&wt, &repo).await.unwrap() > 0); + assert!(submodule_unpushed(&wt, &repo, true).await.unwrap() > 0); + } + + /// A tag is no keeper until a remote says it has it: a band never asks, + /// so a commit only a tag holds is not counted there; a confirm asks, + /// and counts it unless a remote has that tag at that commit. + #[tokio::test] + async fn submodule_tags_count_only_when_no_remote_has_them() { + let tmp = tempfile::tempdir().unwrap(); + let upstream = tmp.path().join("lib"); + std::fs::create_dir(&upstream).unwrap(); + init_repo(&upstream).await; + let repo = tmp.path().join("repo"); + std::fs::create_dir(&repo).unwrap(); + init_repo(&repo).await; + let url = upstream.to_string_lossy().into_owned(); + let allow = ["-c", "protocol.file.allow=always"]; + let mut args = allow.to_vec(); + args.extend(["submodule", "add", "-q", &url, "lib"]); + git(&repo, &args).await.unwrap(); + git(&repo, &["commit", "-q", "-m", "add lib"]) + .await + .unwrap(); + let wt = add_worktree(&repo, "feat", None).await.unwrap(); + let mut args = allow.to_vec(); + args.extend(["submodule", "update", "--init", "-q"]); + git(&wt, &args).await.unwrap(); + let sub = wt.join("lib"); + git(&sub, &["config", "user.email", "t@t"]).await.unwrap(); + git(&sub, &["config", "user.name", "t"]).await.unwrap(); + + // A commit only a tag holds, HEAD back at the pin. + let pin = git(&sub, &["rev-parse", "HEAD"]).await.unwrap(); + commit_file(&sub, "t.txt", "t").await; + git(&sub, &["tag", "v1"]).await.unwrap(); + git(&sub, &["checkout", "-q", "--detach", pin.trim()]) + .await + .unwrap(); + assert_eq!(submodule_unpushed(&wt, &repo, false).await.unwrap(), 0); + assert_eq!(submodule_unpushed(&wt, &repo, true).await.unwrap(), 1); + + // Pushed: the remote has it at that commit, though no + // remote-tracking ref or root store does. + git(&sub, &["push", "-q", "origin", "v1"]).await.unwrap(); + assert_eq!(submodule_unpushed(&wt, &repo, true).await.unwrap(), 0); + + // A remote that does not answer keeps nothing. + git(&sub, &["remote", "set-url", "origin", "/nonexistent/lib"]) + .await + .unwrap(); + assert_eq!(submodule_unpushed(&wt, &repo, true).await.unwrap(), 1); + git(&sub, &["remote", "set-url", "origin", &url]) + .await + .unwrap(); + + // Moved here since: the remote's v1 is another commit, and the + // new one is held by nothing else. + commit_file(&sub, "u.txt", "u").await; + git(&sub, &["tag", "-f", "v1"]).await.unwrap(); + git(&sub, &["checkout", "-q", "--detach", pin.trim()]) + .await + .unwrap(); + assert_eq!(submodule_unpushed(&wt, &repo, true).await.unwrap(), 1); } } diff --git a/crates/nebula-daemon/src/worktree_check.rs b/crates/nebula-daemon/src/worktree_check.rs index c20e058b..abbd8ca4 100644 --- a/crates/nebula-daemon/src/worktree_check.rs +++ b/crates/nebula-daemon/src/worktree_check.rs @@ -26,8 +26,9 @@ const IGNORED_NAMED: usize = 3; /// every pid the worktree's own sessions lead or run — the confirm counts /// those already and they go down with it — and `configured_base` the /// WORKTREE BASE BRANCH setting. `band` is an idle band's check, asked -/// every rotation: it skips the gitignored files, a confirm's question -/// only, and keeps the rest — the processes at work in the checkout +/// every rotation: it skips the gitignored files and the submodules' +/// remote tags, a confirm's questions only, and keeps the rest — the +/// processes at work in the checkout /// included, which is how a band says it is in use. pub async fn check( repo: &Path, @@ -177,13 +178,14 @@ fn git_op_of(args: &str) -> Option { /// recently they were written, its commits the base lacks by content — or, /// on a detached HEAD, the commits nothing else holds — and its /// submodules' commits that live in this checkout's store alone. -/// `ignored` also lists the gitignored files a delete takes: a confirm's -/// question, not a band's, which would pay for it every rotation. +/// `confirm` also lists the gitignored files a delete takes and asks the +/// submodules' remotes which of their tags they have: a confirm's +/// questions, not a band's, which would pay for them every rotation. async fn git_half( repo: &Path, worktree: &Path, configured_base: Option, - ignored: bool, + confirm: bool, ) -> WorktreeCheck { let mut check = WorktreeCheck::default(); let mut landing = WorktreeLanding::default(); @@ -192,9 +194,9 @@ async fn git_half( let (status, commits, submodules, keepsakes) = tokio::join!( git::changed_paths(worktree), commits_half(repo, worktree, configured_base), - git::submodule_unpushed(worktree, repo), + git::submodule_unpushed(worktree, repo, confirm), async { - match ignored { + match confirm { true => Some(git::ignored_keepsakes(worktree).await), false => None, } From 56ec42226050dbbc63c6ca30d19cb829a26ba13a Mon Sep 17 00:00:00 2001 From: Drew Bradford <16455249+DrewBradfordXYZ@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:19:28 -0400 Subject: [PATCH 5/6] A locked worktree's band says so, and why MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/nebula-tui/src/band_safety.rs | 92 ++++++++++++++++++++++- crates/nebula-tui/src/ui/launcher_view.rs | 1 + docs/sessions.md | 4 + 3 files changed, 96 insertions(+), 1 deletion(-) diff --git a/crates/nebula-tui/src/band_safety.rs b/crates/nebula-tui/src/band_safety.rs index 6ac143aa..1251b4f0 100644 --- a/crates/nebula-tui/src/band_safety.rs +++ b/crates/nebula-tui/src/band_safety.rs @@ -20,7 +20,12 @@ //! its working directory in the checkout (a build, a shell, a tool's //! scratch run): `0 sessions` alone never says that. It leads the git //! verdict, `● in use by zsh · clean · detached`, and follows a warning, -//! `⚠ 3 modified · in use by cargo`. +//! `⚠ 3 modified · in use by cargo`; +//! - `🔒 locked: ` — someone locked the checkout (`git worktree +//! lock`) to keep it: a person, an agent, a tool that reuses it. It leads +//! what else the band says, `🔒 locked: nightly build · clean · +//! detached`, in the accent color — after a warning, which keeps its +//! place and color: `⚠ 3 modified · 🔒 locked: nightly build`. //! //! "Merged" is counted by content, not ancestry: a branch landed by squash //! merge, rebase or cherry-pick reads merged. Before the first read a band @@ -212,6 +217,9 @@ pub(crate) enum Tone { /// Something outside nebula's sessions is working in the checkout: /// the warning color, though a delete would not destroy it. InUse, + /// Someone locked the checkout to keep it, and nothing worse is to + /// say: the accent color. + Locked, } /// What a band's line says about its checkout. @@ -224,10 +232,50 @@ pub(crate) struct Verdict { /// Process names a band lists before it says "+N". const IN_USE_NAMED: usize = 2; +/// Characters of a lock's reason a band shows before it cuts it with `…`; +/// the delete confirm shows it whole. +const LOCK_REASON_SHOWN: usize = 40; + /// The band's verdict on `check`: None when there is nothing to say — a /// daemon that did not say where the branch stands, or a branch that is /// its base's own, with nothing at work in it. pub(crate) fn verdict(check: &WorktreeCheck) -> Option { + let rest = work_verdict(check); + let Some(reason) = &check.locked else { + return rest; + }; + let reason = reason.split_whitespace().collect::>().join(" "); + let mut lock = String::from("🔒 locked"); + if !reason.is_empty() { + lock.push_str(": "); + if reason.chars().count() > LOCK_REASON_SHOWN { + let cut: String = reason.chars().take(LOCK_REASON_SHOWN - 1).collect(); + lock.push_str(cut.trim_end()); + lock.push('…'); + } else { + lock.push_str(&reason); + } + } + Some(match rest { + // A warning still leads, in its color: the lock never hides what + // a delete would destroy or interrupt. + Some(v) if matches!(v.tone, Tone::Warn | Tone::InUse) => Verdict { + text: format!("{} · {lock}", v.text), + tone: v.tone, + }, + Some(v) => Verdict { + text: format!("{lock} · {}", v.text.trim_start_matches("✓ ")), + tone: Tone::Locked, + }, + None => Verdict { + text: lock, + tone: Tone::Locked, + }, + }) +} + +/// The band's verdict on what is in the checkout and at work in it. +fn work_verdict(check: &WorktreeCheck) -> Option { let git = git_verdict(check); if check.landing.is_none() || check.processes.is_empty() || git_running(check) { // A git at work names itself (`⋯ git submodule update running`). @@ -444,6 +492,48 @@ mod tests { ); } + #[test] + fn a_locked_checkout_says_so_whoever_locked_it() { + let detached = WorktreeCheck { + detached: true, + locked: Some("BranchKit merge queue:\nreused by every push".into()), + ..WorktreeCheck::default() + }; + assert_eq!( + text(&with(WorktreeLanding::default(), detached)), + Some(( + "🔒 locked: BranchKit merge queue: reused by every… · clean · detached".into(), + Tone::Locked + )) + ); + let bare = WorktreeCheck { + locked: Some(String::new()), + ahead: 2, + ..WorktreeCheck::default() + }; + assert_eq!( + text(&with(WorktreeLanding::default(), bare)), + Some(("🔒 locked · ↑2 unmerged".into(), Tone::Locked)) + ); + let nothing_else = WorktreeCheck { + locked: Some("x".repeat(60)), + ..WorktreeCheck::default() + }; + let (words, tone) = text(&with(WorktreeLanding::default(), nothing_else)).unwrap(); + assert_eq!(tone, Tone::Locked); + assert_eq!(words, format!("🔒 locked: {}…", "x".repeat(39))); + // A warning keeps its place and color ahead of the lock. + let dirty = WorktreeCheck { + changes: 3, + locked: Some("nightly build".into()), + ..WorktreeCheck::default() + }; + assert_eq!( + text(&with(WorktreeLanding::default(), dirty)), + Some(("⚠ 3 modified · 🔒 locked: nightly build".into(), Tone::Warn)) + ); + } + #[test] fn what_a_delete_destroys_leads_and_the_branchs_commits_follow() { let check = WorktreeCheck { diff --git a/crates/nebula-tui/src/ui/launcher_view.rs b/crates/nebula-tui/src/ui/launcher_view.rs index f8f5601e..5848c2a4 100644 --- a/crates/nebula-tui/src/ui/launcher_view.rs +++ b/crates/nebula-tui/src/ui/launcher_view.rs @@ -1094,6 +1094,7 @@ pub(super) fn empty_band_lead( // In use is the warning color too: a band busy with work // nebula does not show is no band to delete now. crate::band_safety::Tone::Warn | crate::band_safety::Tone::InUse => th.warn, + crate::band_safety::Tone::Locked => th.accent, crate::band_safety::Tone::Ahead if !lit => th.text, _ => fg, }; diff --git a/docs/sessions.md b/docs/sessions.md index 4f3788da..85851c11 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -343,6 +343,10 @@ is one key from there: `p` opens the QUICK PROMPT, focused, so the first thing y just, cargo` ahead of the verdict (`● in use by zsh · clean · detached`), or after a warning (`⚠ 3 modified · in use by cargo`). Like the rest of the line it is read every 30 s or so, so a process that has just exited may still be named; the confirm checks again. + A checkout someone locked to keep it (`git worktree lock --reason …`: by hand, by an agent, by a + tool that reuses it) says so first, in the accent color — `🔒 locked: nightly build · clean · + detached` — and after a warning, which keeps its place and color (`⚠ 3 modified · 🔒 locked: + nightly build`). A long reason is cut on the band; the delete confirm shows it whole. A git at work in the checkout — `git submodule update` still cloning, a fetch, a rebase or a merge — or one stopped part-way (a rebase waiting on a conflict) reads `⋯ git submodule update running` or `⋯ git rebase in progress` instead, dim (a stopped one that also holds what a delete From 6b69c93345b94ae6a696e9c6888cbf6d8a28faa9 Mon Sep 17 00:00:00 2001 From: Drew Bradford <16455249+DrewBradfordXYZ@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:46:28 -0400 Subject: [PATCH 6/6] A submodule folded into the repository as a folder is not unpushed work 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) --- crates/nebula-daemon/src/git.rs | 155 +++++++++++++++++++++++++++++--- 1 file changed, 145 insertions(+), 10 deletions(-) diff --git a/crates/nebula-daemon/src/git.rs b/crates/nebula-daemon/src/git.rs index 1a0785b2..b22a8b99 100644 --- a/crates/nebula-daemon/src/git.rs +++ b/crates/nebula-daemon/src/git.rs @@ -964,8 +964,9 @@ pub async fn submodule_unpushed(worktree: &Path, root: &Path, ask_remotes: bool) .as_deref() .and_then(|root_git| kept_store(&store, &own_git, &checkout, root, root_git)); let cwd = checkout.clone(); + let folder = label.clone(); jobs.spawn(async move { - lost_in_store(cwd, store, kept, ask_remotes) + lost_in_store(cwd, store, kept, &folder, ask_remotes) .await .map_err(|e| SubmoduleError::wrap(&label, e)) }); @@ -1097,6 +1098,7 @@ async fn lost_in_store( cwd: PathBuf, store: PathBuf, kept: Option, + folder: &str, ask_remotes: bool, ) -> Result { let git_dir = format!("--git-dir={}", store.display()); @@ -1128,13 +1130,14 @@ async fn lost_in_store( } Err(e) => return Err(e), }; - let n = if lost.trim().is_empty() { - 0 + let lost = if lost.trim().is_empty() { + Vec::new() } else if let Some(kept) = kept { unkept_in(&cwd, &kept, &lost).await? } else { - lost.lines().count() + lost.lines().map(str::to_string).collect() }; + let n = unfolded(&cwd, &git_dir, folder, lost).await?.len(); Ok(u32::try_from(n).unwrap_or(u32::MAX)) } @@ -1206,18 +1209,19 @@ async fn head_resolves(cwd: &Path, git_dir: &str) -> bool { .is_ok() } -/// How many of `commits` (one id per line) the root's store `kept` does +/// Which of `commits` (one id per line) the root's store `kept` does /// not keep: commits it lacks, and commits it has but no ref of it /// reaches — a commit fetched by id and left dangling goes at its next /// gc, so it is no keeper. -async fn unkept_in(cwd: &Path, kept: &Path, commits: &str) -> Result { +async fn unkept_in(cwd: &Path, kept: &Path, commits: &str) -> Result> { let kept_dir = format!("--git-dir={}", kept.display()); let answers = git_read_input(cwd, &[&kept_dir, "cat-file", "--batch-check"], commits).await?; - let mut missing = 0; + let mut missing = Vec::new(); let mut present = String::new(); for line in answers.lines() { match line.split_once(' ') { - Some((_, "missing")) | None => missing += 1, + Some((id, "missing")) => missing.push(id.to_string()), + None => missing.push(line.to_string()), Some((id, _)) => { present.push_str(id); present.push('\n'); @@ -1236,8 +1240,75 @@ async fn unkept_in(cwd: &Path, kept: &Path, commits: &str) -> Result { ) .await?; let asked: HashSet<&str> = present.lines().collect(); - let dangling = unreached.lines().filter(|id| asked.contains(id)).count(); - Ok(missing + dangling) + missing.extend( + unreached + .lines() + .filter(|id| asked.contains(id)) + .map(str::to_string), + ); + Ok(missing) +} + +/// Of `commits` in the store at `git_dir` (a `--git-dir=` argument), the +/// ones the superproject's remotes do not hold as the folder `folder`. +/// A submodule folded into the superproject as a plain folder (`git +/// filter-repo --to-subdirectory-filter`, `git subtree add`) keeps each +/// commit's tree but not its id, so its old store, left in a worktree's +/// git dir, reads as commits on no remote though every one is published +/// as that folder. A commit counts as held when some commit a +/// remote-tracking ref of the superproject reaches has its exact tree at +/// `folder`; a local branch is no keeper, the same as for the store's +/// own commits. The walk runs only when the superproject has one of the +/// trees at all, so a store with no folded past costs one `cat-file`. +async fn unfolded( + cwd: &Path, + git_dir: &str, + folder: &str, + commits: Vec, +) -> Result> { + if commits.is_empty() { + return Ok(commits); + } + let asked: String = commits.iter().map(|c| format!("{c}^{{tree}}\n")).collect(); + let trees = git_read_input( + cwd, + &[git_dir, "cat-file", "--batch-check=%(objectname)"], + &asked, + ) + .await?; + let trees: Vec<&str> = trees.lines().collect(); + if trees.len() != commits.len() { + return Ok(commits); + } + let known: String = trees.iter().map(|t| format!("{t}\n")).collect(); + let here = git_read_input( + cwd, + &["cat-file", "--batch-check=%(objectname) %(objecttype)"], + &known, + ) + .await?; + if !here.lines().any(|l| l.ends_with(" tree")) { + return Ok(commits); + } + let pathspec = format!(":(literal){folder}"); + let touched = git_read(cwd, &["rev-list", "--remotes", "--", &pathspec]).await?; + let at_folder: String = touched.lines().map(|c| format!("{c}:{folder}\n")).collect(); + let held = git_read_input( + cwd, + &["cat-file", "--batch-check=%(objectname) %(objecttype)"], + &at_folder, + ) + .await?; + let held: HashSet<&str> = held + .lines() + .filter_map(|l| l.strip_suffix(" tree")) + .collect(); + Ok(commits + .into_iter() + .zip(trees) + .filter(|(_, tree)| !held.contains(tree)) + .map(|(commit, _)| commit) + .collect()) } /// The submodules checked out in `dir`, relative to it: the gitlinks its @@ -2461,6 +2532,70 @@ mod tests { assert_eq!(submodule_unpushed(&wt, &repo, true).await.unwrap(), 1); } + /// A submodule later folded into the superproject as a plain folder + /// keeps each commit's tree, not its id: its old store's commits stop + /// counting once a remote holds that tree at the folder, and not + /// before (a local branch is no keeper). + #[tokio::test] + async fn a_store_whose_commits_were_folded_and_pushed_counts_nothing() { + let tmp = tempfile::tempdir().unwrap(); + let allow = ["-c", "protocol.file.allow=always"]; + let upstream = tmp.path().join("lib"); + let repo = tmp.path().join("repo"); + for dir in [&upstream, &repo] { + std::fs::create_dir(dir).unwrap(); + init_repo(dir).await; + } + let url = upstream.to_string_lossy().into_owned(); + let mut args = allow.to_vec(); + args.extend(["submodule", "add", "-q", url.as_str(), "lib"]); + git(&repo, &args).await.unwrap(); + git(&repo, &["commit", "-q", "-m", "add lib"]) + .await + .unwrap(); + let wt = add_worktree(&repo, "feat", None).await.unwrap(); + let mut args = allow.to_vec(); + args.extend(["submodule", "update", "--init", "-q"]); + git(&wt, &args).await.unwrap(); + let sub = wt.join("lib"); + git(&sub, &["config", "user.email", "t@t"]).await.unwrap(); + git(&sub, &["config", "user.name", "t"]).await.unwrap(); + commit_file(&sub, "z.txt", "z").await; + let store = git(&sub, &["rev-parse", "--absolute-git-dir"]) + .await + .unwrap(); + assert_eq!(submodule_unpushed(&wt, &repo, true).await.unwrap(), 1); + + // Fold: the store's commit becomes the folder `lib` in the main + // repository, under a new commit id. + let mut args = allow.to_vec(); + args.extend(["fetch", "-q", store.trim(), "HEAD"]); + git(&repo, &args).await.unwrap(); + git(&repo, &["rm", "-q", "--cached", "lib"]).await.unwrap(); + git(&repo, &["rm", "-q", "--cached", ".gitmodules"]) + .await + .unwrap(); + git(&repo, &["read-tree", "--prefix=lib/", "FETCH_HEAD^{tree}"]) + .await + .unwrap(); + git(&repo, &["commit", "-q", "-m", "fold lib"]) + .await + .unwrap(); + assert_eq!( + submodule_unpushed(&wt, &repo, true).await.unwrap(), + 1, + "folded on a local branch only" + ); + git(&repo, &["update-ref", "refs/remotes/origin/main", "HEAD"]) + .await + .unwrap(); + assert_eq!(submodule_unpushed(&wt, &repo, true).await.unwrap(), 0); + + // A commit made after the fold is on no remote in any shape. + commit_file(&sub, "y.txt", "y").await; + assert_eq!(submodule_unpushed(&wt, &repo, true).await.unwrap(), 1); + } + #[test] fn a_module_name_drops_the_stores_own_modules_levels() { assert_eq!(module_name(Path::new("lib")), "lib");