use std::path::Path;
use tracing::{info, warn};
pub fn get_default_branch(base_path: &Path) -> Option<String> {
let out = std::process::Command::new("git")
.arg("-C")
.arg(base_path)
.args(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let branch = String::from_utf8_lossy(&out.stdout).trim().to_string();
let branch = branch
.strip_prefix("origin/")
.unwrap_or(&branch)
.to_string();
if branch.is_empty() {
None
} else {
Some(branch)
}
}
pub fn run_hygiene_for_base(base_path: &Path) -> Result<(), String> {
if !base_path.join(".git").exists() {
return Ok(());
}
info!(path = %base_path.display(), "inproject-hygiene: running for base clone");
let fetch = std::process::Command::new("git")
.arg("-C")
.arg(base_path)
.args(["fetch", "origin"])
.output();
match fetch {
Ok(out) if out.status.success() => {
info!(path = %base_path.display(), "inproject-hygiene: fetch OK");
}
Ok(out) => {
warn!(
path = %base_path.display(),
"inproject-hygiene: fetch failed ({}): {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
);
}
Err(e) => {
warn!(path = %base_path.display(), "inproject-hygiene: fetch error: {e}");
}
}
let branch = get_default_branch(base_path).unwrap_or_else(|| "main".to_string());
let target = format!("origin/{branch}");
let reset = std::process::Command::new("git")
.arg("-C")
.arg(base_path)
.args(["reset", "--hard", &target])
.output();
match reset {
Ok(out) if out.status.success() => {
info!(path = %base_path.display(), branch = %branch, "inproject-hygiene: reset OK");
}
Ok(out) => {
warn!(
path = %base_path.display(),
"inproject-hygiene: reset failed ({}): {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
);
}
Err(e) => {
warn!(path = %base_path.display(), "inproject-hygiene: reset error: {e}");
}
}
let prune = std::process::Command::new("git")
.arg("-C")
.arg(base_path)
.args(["worktree", "prune"])
.output();
match prune {
Ok(out) if out.status.success() => {
info!(path = %base_path.display(), "inproject-hygiene: worktree prune OK");
}
Ok(out) => {
warn!(
path = %base_path.display(),
"inproject-hygiene: worktree prune failed ({}): {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
);
}
Err(e) => {
warn!(path = %base_path.display(), "inproject-hygiene: worktree prune error: {e}");
}
}
Ok(())
}
pub fn run_hygiene_for_all_bases(repos_root: &Path) {
if !repos_root.is_dir() {
return;
}
info!(root = %repos_root.display(), "inproject-hygiene: starting startup sweep");
let owner_dirs = match std::fs::read_dir(repos_root) {
Ok(d) => d,
Err(e) => {
warn!(root = %repos_root.display(), "inproject-hygiene: cannot read repos root: {e}");
return;
}
};
for owner_entry in owner_dirs.flatten() {
let owner_path = owner_entry.path();
if !owner_path.is_dir() {
continue;
}
let repo_dirs = match std::fs::read_dir(&owner_path) {
Ok(d) => d,
Err(e) => {
warn!(path = %owner_path.display(), "inproject-hygiene: cannot read owner dir: {e}");
continue;
}
};
for repo_entry in repo_dirs.flatten() {
let base_path = repo_entry.path();
if !base_path.is_dir() {
continue;
}
if let Err(e) = run_hygiene_for_base(&base_path) {
warn!(path = %base_path.display(), "inproject-hygiene: error: {e}");
}
}
}
info!(root = %repos_root.display(), "inproject-hygiene: startup sweep complete");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn get_default_branch_returns_none_for_non_git() {
let tmp = std::env::temp_dir();
assert!(get_default_branch(&tmp).is_none());
}
#[test]
fn run_hygiene_skips_missing_dir() {
let tmp = std::env::temp_dir();
let result = run_hygiene_for_base(&tmp);
assert!(
result.is_ok(),
"should skip non-git dir cleanly: {result:?}"
);
}
#[test]
fn run_hygiene_for_all_bases_skips_missing_root() {
let missing = std::path::Path::new("/tmp/trusty-nonexistent-repos-root-hygiene-test");
run_hygiene_for_all_bases(missing); }
}