Removes the retained-worktree store end to end: the daemon's state-dir store, GET/DELETE /api/worktrees, `maestro worktrees list|reclaim`, the TUI overlay behind `w`, and the web `#/worktrees` view and header link. The kill guard stays. A forced kill and a dirty natural exit now leave the worktree on disk untouched instead of parking it in a store, so removing one is an ordinary `git worktree remove`. A pristine tree is still removed on exit. DELETE /api/sessions/:id always answers 204. The header's debug button also goes; Ctrl+Shift+D and `?hud` still toggle the HUD. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
239 lines
8.2 KiB
Rust
239 lines
8.2 KiB
Rust
//! The one git probe for what a worktree holds that would be lost (#79).
|
|
//!
|
|
//! [`in_the_way`] is the single predicate behind both paths: the kill refusal
|
|
//! (#79) and the natural-exit classifier (#82). It lives here rather than in
|
|
//! `worktree.rs` so that neither can answer the question differently.
|
|
|
|
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.
|
|
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)
|
|
}
|
|
|
|
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());
|
|
}
|
|
}
|