From 44b9745e8c31e0a02d87ff6feea656b9c745daa0 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 10:51:52 +0100 Subject: [PATCH] feat(maestro): read-side in-the-way probe and guard-free reclaim --- crates/maestro-daemon/src/lib.rs | 1 + crates/maestro-daemon/src/worktree_probe.rs | 316 ++++++++++++++++++++ 2 files changed, 317 insertions(+) create mode 100644 crates/maestro-daemon/src/worktree_probe.rs diff --git a/crates/maestro-daemon/src/lib.rs b/crates/maestro-daemon/src/lib.rs index 35e623b..8f85980 100644 --- a/crates/maestro-daemon/src/lib.rs +++ b/crates/maestro-daemon/src/lib.rs @@ -25,6 +25,7 @@ pub mod sidecar; pub mod spawn_history; pub mod tmux; pub mod worktree; +pub mod worktree_probe; pub use config::{default_config_path, default_state_dir, load_config, DaemonConfig, EnvOverrides}; pub use session::{SessionError, SessionManager}; diff --git a/crates/maestro-daemon/src/worktree_probe.rs b/crates/maestro-daemon/src/worktree_probe.rs new file mode 100644 index 0000000..c7cb8d6 --- /dev/null +++ b/crates/maestro-daemon/src/worktree_probe.rs @@ -0,0 +1,316 @@ +//! Read-side git probes for retained worktrees (#80): what is currently in +//! the way, and guard-free reclaim. +//! +//! The kill-refusal breakdown lives in `worktree.rs`, which this module must +//! not touch (#77 owns it) and must not consult for reclaim — reclaim is the +//! deliberate destructive act and never asks the guard. Issue #79 will factor +//! the shared git plumbing out of `worktree.rs`; until then these probes are +//! duplicated here on purpose. + +use std::path::Path; + +use maestro_protocol::InTheWay; + +#[derive(Debug, thiserror::Error)] +pub enum ProbeError { + #[error("failed to spawn git: {0}")] + Spawn(#[from] std::io::Error), + #[error("git exited with code {code}: {stderr}")] + NonZero { code: i32, stderr: String }, +} + +/// What a kill refusal would report about the worktree at `path`, right now. +/// +/// Ignored files never count (`--ignored=no`) — every Rust worktree has a +/// `target/`, and counting those would put every tree permanently in the way. +/// A directory that no longer exists reports nothing in the way: there is +/// nothing left to lose, and the store's own pruning drops the entry. +pub fn in_the_way(path: &Path, base_repo: &Path, branch: &str) -> Result { + let mut breakdown = InTheWay::default(); + if !path.exists() { + return Ok(breakdown); + } + let status = run_git( + path, + [ + "status", + "--porcelain", + "--ignored=no", + "--untracked-files=normal", + ], + )?; + for line in String::from_utf8_lossy(&status.stdout).lines() { + if line.starts_with("??") { + breakdown.untracked_files += 1; + } else if !line.is_empty() { + breakdown.uncommitted_files += 1; + } + } + // Commits reachable from the branch but from no remote-tracking ref. A + // branch pushed anywhere counts as pushed; there is no "which remote" + // question to get wrong. + let unpushed = run_git( + base_repo, + [ + "rev-list", + "--count", + &format!("refs/heads/{branch}"), + "--not", + "--glob=refs/remotes", + ], + )?; + breakdown.unpushed_commits = String::from_utf8_lossy(&unpushed.stdout) + .trim() + .parse() + .unwrap_or(0); + Ok(breakdown) +} + +/// Remove a retained worktree's directory without consulting any guard, per +/// the reclaim contract: the operator chose this while looking at what was in +/// the way. `git worktree remove` keeps `refs/heads/`, so commits on +/// the branch survive even when they were never pushed. +/// +/// A directory that is already gone only leaves a stale registration behind; +/// `worktree prune` clears it so the branch can be spawned again. +pub fn reclaim(base_repo: &Path, path: &Path) -> Result<(), ProbeError> { + if !path.exists() { + return run_git(base_repo, ["worktree", "prune"]).map(|_| ()); + } + run_git( + base_repo, + [ + "worktree", + "remove", + "--force", + path.to_str().expect("worktree path is valid utf-8"), + ], + ) + .map(|_| ()) +} + +fn run_git(dir: &Path, args: I) -> Result +where + I: IntoIterator, + S: AsRef, +{ + let mut cmd = std::process::Command::new("git"); + cmd.arg("-C").arg(dir).args(args); + let out = cmd.output()?; + if !out.status.success() { + return Err(ProbeError::NonZero { + code: out.status.code().unwrap_or(-1), + stderr: String::from_utf8_lossy(&out.stderr).into_owned(), + }); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + use std::process::Command; + + struct Repo { + root: tempfile::TempDir, + repo: PathBuf, + } + + fn git(dir: &Path, args: &[&str]) { + let out = Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .output() + .expect("git spawn"); + assert!( + out.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + } + + /// A repo with a remote, one commit on `main`, and a linked worktree for + /// branch `feature` at `/worktrees/feature`. + fn repo_with_worktree() -> Repo { + let root = tempfile::tempdir().expect("tempdir"); + let repo = root.path().join("repo"); + let remote = root.path().join("remote.git"); + std::fs::create_dir_all(&repo).unwrap(); + std::fs::create_dir_all(&remote).unwrap(); + git(&repo, &["init", "-q", "-b", "main"]); + git(&repo, &["config", "user.email", "t@t"]); + git(&repo, &["config", "user.name", "T"]); + std::fs::write(repo.join("README"), b"hello\n").unwrap(); + git(&repo, &["add", "."]); + git(&repo, &["commit", "-q", "-m", "init"]); + git(&remote, &["init", "-q", "--bare"]); + git(&repo, &["remote", "add", "origin", &path_str(&remote)]); + git(&repo, &["push", "-q", "-u", "origin", "main"]); + git( + &repo, + &[ + "worktree", + "add", + "-q", + "-b", + "feature", + "worktrees/feature", + ], + ); + Repo { root, repo } + } + + fn path_str(path: &Path) -> String { + path.to_string_lossy().into_owned() + } + + impl Repo { + fn wt(&self) -> PathBuf { + self.repo.join("worktrees").join("feature") + } + + fn commit_in_wt(&self, msg: &str) { + std::fs::write(self.wt().join("README"), msg.as_bytes()).unwrap(); + git(&self.wt(), &["add", "-A"]); + git(&self.wt(), &["commit", "-qm", msg]); + } + } + + #[test] + fn a_clean_pushed_worktree_has_nothing_in_the_way() { + let r = repo_with_worktree(); + git(&r.wt(), &["push", "-q", "-u", "origin", "feature"]); + let breakdown = in_the_way(&r.wt(), &r.repo, "feature").expect("probe"); + assert_eq!(breakdown, InTheWay::default()); + assert!(breakdown.is_clear()); + } + + #[test] + fn uncommitted_untracked_and_unpushed_are_counted_separately() { + let r = repo_with_worktree(); + std::fs::write(r.wt().join("README"), b"edited\n").unwrap(); + std::fs::write(r.wt().join("scratch.txt"), b"untracked\n").unwrap(); + + let dirty = in_the_way(&r.wt(), &r.repo, "feature").expect("probe"); + assert_eq!(dirty.uncommitted_files, 1); + assert_eq!(dirty.untracked_files, 1); + assert_eq!(dirty.unpushed_commits, 0); + assert!(!dirty.is_clear()); + + // Committing clears exactly one dimension and creates another. + git(&r.wt(), &["add", "README"]); + git(&r.wt(), &["commit", "-qm", "wip"]); + + let breakdown = in_the_way(&r.wt(), &r.repo, "feature").expect("probe"); + assert_eq!(breakdown.uncommitted_files, 0); + assert_eq!(breakdown.untracked_files, 1); + assert_eq!(breakdown.unpushed_commits, 1); + } + + #[test] + fn tracked_edits_count_as_uncommitted() { + let r = repo_with_worktree(); + std::fs::write(r.wt().join("README"), b"edited\n").unwrap(); + let breakdown = in_the_way(&r.wt(), &r.repo, "feature").expect("probe"); + assert_eq!(breakdown.uncommitted_files, 1); + assert_eq!(breakdown.untracked_files, 0); + } + + #[test] + fn pushing_clears_unpushed_but_keeps_other_entries() { + let r = repo_with_worktree(); + std::fs::write(r.wt().join("new.txt"), b"x\n").unwrap(); + r.commit_in_wt("wip"); + + let before = in_the_way(&r.wt(), &r.repo, "feature").expect("probe"); + assert_eq!(before.unpushed_commits, 1); + + git(&r.wt(), &["push", "-q", "-u", "origin", "feature"]); + // Same stored entry, different tree: the breakdown must move with it. + let after = in_the_way(&r.wt(), &r.repo, "feature").expect("probe"); + assert_eq!(after.unpushed_commits, 0); + assert_eq!(after.untracked_files, 0); + + std::fs::write(r.wt().join("loose.txt"), b"x\n").unwrap(); + let dirty = in_the_way(&r.wt(), &r.repo, "feature").expect("probe"); + assert_eq!(dirty.untracked_files, 1); + } + + #[test] + fn ignored_files_never_count() { + let r = repo_with_worktree(); + std::fs::write(r.wt().join(".gitignore"), b"target/\n").unwrap(); + std::fs::create_dir_all(r.wt().join("target")).unwrap(); + std::fs::write(r.wt().join("target/artifact"), b"x").unwrap(); + + let breakdown = in_the_way(&r.wt(), &r.repo, "feature").expect("probe"); + assert_eq!( + breakdown, + InTheWay { + untracked_files: 1, + ..InTheWay::default() + }, + "the .gitignore itself is untracked; target/ is not" + ); + } + + #[test] + fn a_missing_directory_reports_nothing_in_the_way() { + let r = repo_with_worktree(); + let gone = r.root.path().join("nowhere"); + let breakdown = in_the_way(&gone, &r.repo, "feature").expect("probe"); + assert!(breakdown.is_clear()); + } + + #[test] + fn reclaim_removes_the_directory_and_prunes_a_gone_one() { + let r = repo_with_worktree(); + r.commit_in_wt("precious"); + reclaim(&r.repo, &r.wt()).expect("reclaim"); + assert!(!r.wt().exists()); + + // A second reclaim over the now-missing path just prunes. + reclaim(&r.repo, &r.wt()).expect("reclaim again"); + } + + #[test] + fn reclaim_keeps_the_branch_and_its_commits() { + let r = repo_with_worktree(); + r.commit_in_wt("precious"); + let sha = { + let out = Command::new("git") + .arg("-C") + .arg(r.wt()) + .args(["rev-parse", "HEAD"]) + .output() + .unwrap(); + String::from_utf8_lossy(&out.stdout).trim().to_string() + }; + + reclaim(&r.repo, &r.wt()).expect("reclaim"); + + let out = Command::new("git") + .arg("-C") + .arg(&r.repo) + .args(["rev-parse", "--verify", "refs/heads/feature"]) + .output() + .unwrap(); + assert!( + out.status.success(), + "branch must survive reclaim: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), sha); + } + + #[test] + fn reclaim_is_guard_free_on_a_dirty_tree() { + let r = repo_with_worktree(); + std::fs::write(r.wt().join("README"), b"unsaved\n").unwrap(); + std::fs::write(r.wt().join("notes.txt"), b"unsaved\n").unwrap(); + reclaim(&r.repo, &r.wt()).expect("reclaim ignores the guard"); + assert!(!r.wt().exists()); + } +}