--force overrides the kill refusal and nothing else (#81). The guarded worktree is recorded in the retained store with reason force-killed — never removed — and the outcome names what was kept so a client can say where the work went. A tree whose guard is clear never needed the override; force removes it as usual. The store write happens before teardown, so a failed retention leaves the session running and every artifact intact. DELETE /api/sessions/:id takes ?force=true and answers 200 with KillOutcome; the unforced kill keeps its 204.
326 lines
11 KiB
Rust
326 lines
11 KiB
Rust
//! 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<InTheWay, ProbeError> {
|
|
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/<branch>` 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/<branch>`, 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<String> {
|
|
let out = run_git(path, ["rev-parse", "--abbrev-ref", "HEAD"]).ok()?;
|
|
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
|
|
}
|
|
|
|
fn run_git<I, S>(dir: &Path, args: I) -> Result<std::process::Output, ProbeError>
|
|
where
|
|
I: IntoIterator<Item = S>,
|
|
S: AsRef<std::ffi::OsStr>,
|
|
{
|
|
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 `<repo>/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());
|
|
}
|
|
}
|