use std::process::{Command, Stdio};
use tracing::{info, warn};
pub struct Git {
bin: String,
}
impl Git {
pub fn with_bin(bin: impl Into<String>) -> Self {
Self { bin: bin.into() }
}
fn at(&self, repo: &str) -> Command {
let mut cmd = Command::new(&self.bin);
cmd.arg("-C").arg(repo);
cmd
}
pub fn rev_list_count(&self, repo: &str, from: &str, to: &str) -> Option<u64> {
if from.is_empty() || to.is_empty() {
return None;
}
let out = self
.at(repo)
.args([
"rev-list",
"--count",
"--end-of-options",
&format!("{from}..{to}"),
])
.stderr(Stdio::null())
.output()
.ok()?;
if !out.status.success() {
return None;
}
String::from_utf8_lossy(&out.stdout)
.trim()
.parse::<u64>()
.ok()
}
pub fn head_oid(&self, dir: &str) -> Option<String> {
let out = self
.at(dir)
.args(["rev-parse", "HEAD"])
.stderr(Stdio::null())
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!s.is_empty()).then_some(s)
}
pub fn head_branch(&self, dir: &str) -> Option<String> {
let out = self
.at(dir)
.args(["symbolic-ref", "--quiet", "--short", "HEAD"])
.stderr(Stdio::null())
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!s.is_empty()).then_some(s)
}
pub fn is_ancestor(&self, repo: &str, ancestor: &str, descendant: &str) -> bool {
self.at(repo)
.args(["merge-base", "--is-ancestor", ancestor, descendant])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
pub fn merge_tree_clean(&self, repo: &str, source: &str, branch: &str) -> bool {
self.at(repo)
.args(["merge-tree", "--write-tree", source, branch])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
pub fn worktree_status_clean(&self, dir: &str) -> Option<bool> {
match self
.at(dir)
.args(["status", "--porcelain", "--untracked-files=all"])
.stderr(Stdio::null())
.output()
{
Ok(out) if out.status.success() => Some(out.stdout.iter().all(u8::is_ascii_whitespace)),
_ => None,
}
}
pub fn main_worktree(&self, dir: &str) -> Option<String> {
let out = self
.at(dir)
.args(["worktree", "list", "--porcelain"])
.stderr(Stdio::null())
.output()
.ok()?;
if !out.status.success() {
return None;
}
String::from_utf8_lossy(&out.stdout)
.lines()
.find_map(|l| l.strip_prefix("worktree ").map(|s| s.trim().to_string()))
.filter(|s| !s.is_empty())
}
pub fn worktree_remove(&self, repo: &str, worktree_path: &str, force: bool) -> bool {
let mut cmd = self.at(repo);
if force {
cmd.args(["worktree", "remove", "--force", worktree_path]);
run_lenient(cmd, &format!("git worktree remove --force {worktree_path}"))
} else {
cmd.args(["worktree", "remove", worktree_path]);
run_lenient(cmd, &format!("git worktree remove {worktree_path}"))
}
}
pub fn branch_delete(&self, repo: &str, branch: &str, force: bool) -> Option<String> {
let flag = if force { "-D" } else { "-d" };
let mut cmd = self.at(repo);
cmd.args(["branch", flag, "--", branch]);
run_lenient_detail(cmd, &format!("git branch {flag} -- {branch}"))
}
}
fn run_lenient(cmd: Command, label: &str) -> bool {
run_lenient_detail(cmd, label).is_none()
}
fn run_lenient_detail(mut cmd: Command, label: &str) -> Option<String> {
cmd.stdout(Stdio::null()).stderr(Stdio::piped());
match cmd.output() {
Ok(out) if out.status.success() => {
info!(target: "orchestratectl::supervise", step = label, "cleanup step ok");
eprintln!("supervisor cleanup: {label}: ok");
None
}
Ok(out) => {
let detail = String::from_utf8_lossy(&out.stderr).trim().to_string();
warn!(
target: "orchestratectl::supervise",
step = label,
code = out.status.code(),
detail = %detail,
"cleanup step non-zero (treated as already-done/refused; continuing)"
);
eprintln!("supervisor cleanup: {label}: non-zero exit (continuing): {detail}");
Some(detail)
}
Err(e) => {
warn!(
target: "orchestratectl::supervise",
step = label,
error = %e,
"cleanup step could not spawn (continuing)"
);
eprintln!("supervisor cleanup: {label}: spawn failed (continuing): {e}");
Some(format!("spawn failed: {e}"))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt as _;
use tempfile::TempDir;
fn git(cwd: &std::path::Path, args: &[&str]) {
let ok = Command::new("git")
.current_dir(cwd)
.args(args)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap()
.success();
assert!(ok, "git {args:?} failed in {cwd:?}");
}
fn init_repo_with_worktree(tmp: &TempDir) -> (std::path::PathBuf, std::path::PathBuf) {
let repo = tmp.path().join("repo");
std::fs::create_dir_all(&repo).unwrap();
git(&repo, &["init", "-q", "-b", "main"]);
git(&repo, &["config", "user.email", "t@example.com"]);
git(&repo, &["config", "user.name", "t"]);
std::fs::write(repo.join("README"), "x").unwrap();
git(&repo, &["add", "-A"]);
git(&repo, &["commit", "-qm", "init"]);
let wt = tmp.path().join("wt");
git(
&repo,
&[
"worktree",
"add",
"-q",
"-b",
"wt/foo",
wt.to_str().unwrap(),
],
);
(repo, wt)
}
fn branch_exists(repo: &std::path::Path, branch: &str) -> bool {
Command::new("git")
.current_dir(repo)
.args(["rev-parse", "--verify", "--quiet", branch])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap()
.success()
}
fn failing_git(dir: &std::path::Path) -> String {
let p = dir.join("fake-git.sh");
std::fs::write(&p, "#!/bin/sh\nexit 3\n").unwrap();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
p.to_str().unwrap().to_string()
}
#[test]
fn rev_list_count_counts_commits_ahead() {
let tmp = TempDir::new().unwrap();
let (repo, wt) = init_repo_with_worktree(&tmp);
let g = Git::with_bin("git");
let repo_s = repo.to_str().unwrap();
assert_eq!(g.rev_list_count(repo_s, "main", "wt/foo"), Some(0));
std::fs::write(wt.join("f"), "y").unwrap();
git(&wt, &["add", "-A"]);
git(&wt, &["commit", "-qm", "work"]);
assert_eq!(g.rev_list_count(repo_s, "main", "wt/foo"), Some(1));
}
#[test]
fn rev_list_count_none_on_git_error() {
let tmp = TempDir::new().unwrap();
let g = Git::with_bin(failing_git(tmp.path()));
assert_eq!(g.rev_list_count("/nonexistent", "main", "wt/foo"), None);
}
#[test]
fn rev_list_count_rejects_empty_endpoint() {
let tmp = TempDir::new().unwrap();
let (repo, _wt) = init_repo_with_worktree(&tmp);
let g = Git::with_bin("git");
let repo_s = repo.to_str().unwrap();
assert_eq!(g.rev_list_count(repo_s, "", "wt/foo"), None);
assert_eq!(g.rev_list_count(repo_s, "main", ""), None);
assert_eq!(g.rev_list_count(repo_s, "", ""), None);
}
#[test]
fn head_oid_and_branch_track_the_worktree() {
let tmp = TempDir::new().unwrap();
let (_repo, wt) = init_repo_with_worktree(&tmp);
let g = Git::with_bin("git");
let wt_s = wt.to_str().unwrap();
assert_eq!(g.head_branch(wt_s).as_deref(), Some("wt/foo"));
let on_branch = g.head_oid(wt_s).expect("HEAD resolves on a branch");
assert!(
(on_branch.len() == 40 || on_branch.len() == 64)
&& on_branch.bytes().all(|b| b.is_ascii_hexdigit()),
"a full object id is 40 or 64 hex chars, got {on_branch:?}"
);
git(&wt, &["checkout", "--detach", "-q"]);
assert_eq!(
g.head_branch(wt_s),
None,
"a detached HEAD has no symbolic branch"
);
assert_eq!(
g.head_oid(wt_s).as_deref(),
Some(on_branch.as_str()),
"detach does not move the commit"
);
}
#[test]
fn head_oid_and_branch_none_on_git_error() {
let tmp = TempDir::new().unwrap();
let g = Git::with_bin("git");
let bare = tmp.path().join("bare");
std::fs::create_dir_all(&bare).unwrap();
let bare_s = bare.to_str().unwrap();
assert_eq!(g.head_oid(bare_s), None);
assert_eq!(g.head_branch(bare_s), None);
let fg = Git::with_bin(failing_git(tmp.path()));
assert_eq!(fg.head_oid("/nonexistent"), None);
assert_eq!(fg.head_branch("/nonexistent"), None);
}
#[test]
fn is_ancestor_reflects_topology() {
let tmp = TempDir::new().unwrap();
let (repo, wt) = init_repo_with_worktree(&tmp);
let g = Git::with_bin("git");
let repo_s = repo.to_str().unwrap();
assert!(g.is_ancestor(repo_s, "main", "wt/foo"));
assert!(g.is_ancestor(repo_s, "wt/foo", "main"));
std::fs::write(wt.join("f"), "y").unwrap();
git(&wt, &["add", "-A"]);
git(&wt, &["commit", "-qm", "work"]);
assert!(g.is_ancestor(repo_s, "main", "wt/foo"));
assert!(!g.is_ancestor(repo_s, "wt/foo", "main"));
}
#[test]
fn is_ancestor_false_on_unknown_ref() {
let tmp = TempDir::new().unwrap();
let (repo, _wt) = init_repo_with_worktree(&tmp);
let g = Git::with_bin("git");
assert!(!g.is_ancestor(repo.to_str().unwrap(), "main", "does/not/exist"));
}
#[test]
fn main_worktree_resolves_from_linked() {
let tmp = TempDir::new().unwrap();
let (repo, wt) = init_repo_with_worktree(&tmp);
let g = Git::with_bin("git");
let main = g.main_worktree(wt.to_str().unwrap()).unwrap();
assert_eq!(
std::fs::canonicalize(&main).unwrap(),
std::fs::canonicalize(&repo).unwrap()
);
}
#[test]
fn worktree_remove_removes_and_is_lenient() {
let tmp = TempDir::new().unwrap();
let (repo, wt) = init_repo_with_worktree(&tmp);
let g = Git::with_bin("git");
let repo_s = repo.to_str().unwrap();
assert!(g.worktree_remove(repo_s, wt.to_str().unwrap(), true));
assert!(!wt.exists());
assert!(!g.worktree_remove(repo_s, wt.to_str().unwrap(), true));
}
#[test]
fn worktree_remove_nonforce_refuses_dirty_but_removes_clean() {
let tmp = TempDir::new().unwrap();
let (repo, wt) = init_repo_with_worktree(&tmp);
let g = Git::with_bin("git");
let repo_s = repo.to_str().unwrap();
std::fs::write(wt.join("scratch"), "dirt").unwrap();
assert!(!g.worktree_remove(repo_s, wt.to_str().unwrap(), false));
assert!(wt.exists(), "non-force must not delete a dirty worktree");
std::fs::remove_file(wt.join("scratch")).unwrap();
assert!(g.worktree_remove(repo_s, wt.to_str().unwrap(), false));
assert!(!wt.exists());
}
#[test]
fn worktree_status_clean_tristate_and_uall_defeats_showuntrackedfiles_no() {
let tmp = TempDir::new().unwrap();
let (_repo, wt) = init_repo_with_worktree(&tmp);
let g = Git::with_bin("git");
let wt_s = wt.to_str().unwrap();
assert_eq!(g.worktree_status_clean(wt_s), Some(true), "clean tree");
git(&wt, &["config", "status.showUntrackedFiles", "no"]);
std::fs::write(wt.join("agent-new.rs"), "work").unwrap();
assert_eq!(
g.worktree_status_clean(wt_s),
Some(false),
"untracked file must count as dirty despite showUntrackedFiles=no"
);
assert_eq!(g.worktree_status_clean(tmp.path().to_str().unwrap()), None);
}
#[test]
fn branch_delete_d_refuses_unmerged_but_big_d_forces() {
let tmp = TempDir::new().unwrap();
let (repo, wt) = init_repo_with_worktree(&tmp);
let g = Git::with_bin("git");
let repo_s = repo.to_str().unwrap();
std::fs::write(wt.join("f"), "y").unwrap();
git(&wt, &["add", "-A"]);
git(&wt, &["commit", "-qm", "unmerged work"]);
assert!(g.worktree_remove(repo_s, wt.to_str().unwrap(), true));
assert!(branch_exists(&repo, "wt/foo"));
let detail = g.branch_delete(repo_s, "wt/foo", false);
assert!(detail.is_some(), "-d must refuse an unmerged branch");
assert!(
branch_exists(&repo, "wt/foo"),
"branch preserved by -d refusal"
);
assert_eq!(g.branch_delete(repo_s, "wt/foo", true), None);
assert!(!branch_exists(&repo, "wt/foo"));
}
#[test]
fn branch_delete_d_succeeds_on_merged_branch() {
let tmp = TempDir::new().unwrap();
let (repo, wt) = init_repo_with_worktree(&tmp);
let g = Git::with_bin("git");
let repo_s = repo.to_str().unwrap();
assert!(g.worktree_remove(repo_s, wt.to_str().unwrap(), true));
assert_eq!(g.branch_delete(repo_s, "wt/foo", false), None);
assert!(!branch_exists(&repo, "wt/foo"));
}
}