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> {
let out = self
.at(repo)
.args(["rev-list", "--count", &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 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 tip_committer_time(&self, repo: &str, branch: &str) -> Option<i64> {
let out = self
.at(repo)
.args(["log", "-1", "--format=%ct", "--end-of-options", branch])
.stderr(Stdio::null())
.output()
.ok()?;
if !out.status.success() {
return None;
}
String::from_utf8_lossy(&out.stdout)
.trim()
.parse::<i64>()
.ok()
}
pub fn worktree_is_clean(&self, dir: &str) -> bool {
match self
.at(dir)
.args(["status", "--porcelain"])
.stderr(Stdio::null())
.output()
{
Ok(out) if out.status.success() => out.stdout.iter().all(u8::is_ascii_whitespace),
_ => false,
}
}
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) -> bool {
let mut cmd = self.at(repo);
cmd.args(["worktree", "remove", "--force", worktree_path]);
run_lenient(cmd, &format!("git worktree remove --force {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 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 tip_committer_time_reads_commit_time() {
let tmp = TempDir::new().unwrap();
let (repo, _wt) = init_repo_with_worktree(&tmp);
let g = Git::with_bin("git");
let t = g.tip_committer_time(repo.to_str().unwrap(), "main");
assert!(t.is_some_and(|v| v > 0));
assert_eq!(
g.tip_committer_time(repo.to_str().unwrap(), "nope/nope"),
None
);
}
#[test]
fn worktree_is_clean_tracks_dirtiness() {
let tmp = TempDir::new().unwrap();
let (_repo, wt) = init_repo_with_worktree(&tmp);
let g = Git::with_bin("git");
assert!(g.worktree_is_clean(wt.to_str().unwrap()));
std::fs::write(wt.join("scratch"), "dirt").unwrap();
assert!(!g.worktree_is_clean(wt.to_str().unwrap()));
}
#[test]
fn worktree_is_clean_false_on_git_error() {
let tmp = TempDir::new().unwrap();
let g = Git::with_bin("git");
assert!(!g.worktree_is_clean(tmp.path().to_str().unwrap()));
}
#[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()));
assert!(!wt.exists());
assert!(!g.worktree_remove(repo_s, wt.to_str().unwrap()));
}
#[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()));
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()));
assert_eq!(g.branch_delete(repo_s, "wt/foo", false), None);
assert!(!branch_exists(&repo, "wt/foo"));
}
}