//! The one git probe for what a worktree holds that would be lost, plus //! guard-free reclaim (#79, #80). //! //! [`in_the_way`] is the single predicate behind all three paths: the kill //! refusal (#79), the natural-exit classifier (#82), and the retained list //! (#80). It lives here rather than in `worktree.rs` so that none of them can //! answer the question differently — #79 and #80 were built in parallel and //! each grew its own copy, which is exactly the drift this collapses. //! //! Reclaim never consults it: reclaim is the deliberate destructive act, and //! the operator chose it while looking at what was in the way. 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) -> Result { let mut breakdown = InTheWay::default(); // `symlink_metadata` rather than `exists`: a broken symlink is gone for // our purposes, and following it would probe the wrong tree. if std::fs::symlink_metadata(path).is_err() { 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; } } // Resolved inside the worktree so a detached HEAD still has a starting // point; asking the base repo for `refs/heads/` fails there. let head = run_git(path, ["rev-parse", "--abbrev-ref", "HEAD"])?; let branch = String::from_utf8_lossy(&head.stdout).trim().to_string(); let tip: &str = if branch == "HEAD" { "HEAD" } else { &branch }; // Commits reachable from the tip 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(path, ["rev-list", "--count", tip, "--not", "--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(|_| ()) } /// Branch checked out at `path`. Sessions adopted from a sidecar carry no /// branch on record, so a forced kill (#81) asks the tree itself when it /// needs one for the retained store. `None` when git cannot answer. pub fn current_branch(path: &Path) -> Option { let out = run_git(path, ["rev-parse", "--abbrev-ref", "HEAD"]).ok()?; Some(String::from_utf8_lossy(&out.stdout).trim().to_string()) } 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()).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()).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()).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()).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()).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()).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()).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()).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).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()); } }