Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 68 additions & 1 deletion crates/nebula-core/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand All @@ -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,
Expand Down Expand Up @@ -647,6 +652,15 @@ pub struct WorktreeCheck {
pub detached: bool,
/// Why git could not be asked, when it could not.
pub git_error: Option<String>,
/// 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<String>,
}

/// One process `WorktreeCheck` found working inside a checkout.
Expand All @@ -666,6 +680,7 @@ impl WorktreeCheck {
|| self.changes > 0
|| self.ahead > 0
|| self.git_error.is_some()
|| self.locked.is_some()
}
}

Expand Down Expand Up @@ -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<WorktreeProcess>,
process_error: Option<String>,
changes: u32,
newest_change_ms: Option<i64>,
ahead: u32,
base: Option<String>,
detached: bool,
git_error: Option<String>,
}
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);
}
}
135 changes: 131 additions & 4 deletions crates/nebula-daemon/src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,78 @@ fn parse_worktree_list(out: &str) -> Vec<WorktreeEntry> {
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<Option<String>> {
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<String> {
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
Expand Down Expand Up @@ -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?;
Expand Down Expand Up @@ -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();
Expand Down
46 changes: 46 additions & 0 deletions crates/nebula-daemon/src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
10 changes: 10 additions & 0 deletions crates/nebula-daemon/src/registry/projects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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| {
Expand Down
13 changes: 12 additions & 1 deletion crates/nebula-daemon/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ struct ClientConnection {
attached: HashMap<SessionRef, tokio::task::JoinHandle<()>>,
subscription: Option<tokio::task::JoinHandle<()>>,
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<u32>,
}
Expand All @@ -99,6 +102,7 @@ impl ClientConnection {
attached: HashMap::new(),
subscription: None,
handshaken: false,
client_protocol: 0,
peer_pid: None,
}
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:#}"),
Expand Down
7 changes: 6 additions & 1 deletion crates/nebula-daemon/src/worktree_check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -81,6 +82,10 @@ async fn git_half(repo: &Path, worktree: &Path, configured_base: Option<String>)
&mut check,
);
}
match git::worktree_lock(repo, worktree).await {
Ok(lock) => check.locked = lock,
Err(e) => note(e, &mut check),
}
check
}

Expand Down
Loading
Loading