use std::path::Path;
use tracing::{info, warn};
#[derive(Debug, Clone, PartialEq, Eq)]
enum ResetDecision {
Reset,
Skip(String),
}
fn decide_reset(ahead_count: Option<usize>, dirty: Option<bool>) -> ResetDecision {
let Some(ahead) = ahead_count else {
return ResetDecision::Skip(
"branch ahead-count unknown (detached HEAD, no upstream, or git error); \
refusing to discard local work"
.to_string(),
);
};
let Some(is_dirty) = dirty else {
return ResetDecision::Skip(
"working tree status unknown (git error); refusing hard reset".to_string(),
);
};
if ahead > 0 {
return ResetDecision::Skip(format!(
"{ahead} commit(s) ahead of origin (unpushed); refusing to discard local work"
));
}
if is_dirty {
return ResetDecision::Skip("uncommitted changes present; refusing hard reset".to_string());
}
ResetDecision::Reset
}
fn current_branch(base_path: &Path) -> Option<String> {
let out = std::process::Command::new("git")
.arg("-C")
.arg(base_path)
.args(["symbolic-ref", "--short", "HEAD"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let branch = String::from_utf8_lossy(&out.stdout).trim().to_string();
if branch.is_empty() {
None
} else {
Some(branch)
}
}
fn ahead_count(base_path: &Path, branch: &str) -> Option<usize> {
let range = format!("origin/{branch}..{branch}");
let out = std::process::Command::new("git")
.arg("-C")
.arg(base_path)
.args(["rev-list", "--count", &range])
.output()
.ok()?;
if !out.status.success() {
return None;
}
String::from_utf8_lossy(&out.stdout).trim().parse().ok()
}
fn is_dirty(base_path: &Path) -> Option<bool> {
let out = std::process::Command::new("git")
.arg("-C")
.arg(base_path)
.args(["status", "--porcelain"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
Some(!out.stdout.is_empty())
}
fn write_recovery_ref(base_path: &Path, branch: &str) {
let head = std::process::Command::new("git")
.arg("-C")
.arg(base_path)
.args(["rev-parse", "HEAD"])
.output();
let sha = match head {
Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_string(),
Ok(out) => {
warn!(
path = %base_path.display(),
"inproject-hygiene: recovery-ref rev-parse failed ({}): {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
);
return;
}
Err(e) => {
warn!(path = %base_path.display(), "inproject-hygiene: recovery-ref rev-parse error: {e}");
return;
}
};
if sha.is_empty() {
return;
}
let refname = format!("refs/trusty-mpm/pre-hygiene/{branch}");
let update = std::process::Command::new("git")
.arg("-C")
.arg(base_path)
.args(["update-ref", &refname, &sha])
.output();
match update {
Ok(out) if out.status.success() => {
info!(path = %base_path.display(), refname = %refname, sha = %sha, "inproject-hygiene: recovery ref written");
}
Ok(out) => {
warn!(
path = %base_path.display(),
"inproject-hygiene: recovery-ref update-ref failed ({}): {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
);
}
Err(e) => {
warn!(path = %base_path.display(), "inproject-hygiene: recovery-ref update-ref error: {e}");
}
}
}
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 default_branch = get_default_branch(base_path).unwrap_or_else(|| "main".to_string());
let checked_out = current_branch(base_path);
let ahead = checked_out
.as_deref()
.and_then(|b| ahead_count(base_path, b));
let dirty = is_dirty(base_path);
match decide_reset(ahead, dirty) {
ResetDecision::Skip(reason) => {
warn!(
path = %base_path.display(),
branch = %checked_out.as_deref().unwrap_or("<unknown/detached>"),
"inproject-hygiene: SKIP reset — {reason}"
);
}
ResetDecision::Reset => {
if let Some(branch) = checked_out.as_deref() {
write_recovery_ref(base_path, branch);
}
let target = format!("origin/{default_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 = %default_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); }
#[test]
fn decide_reset_ahead_skips() {
match decide_reset(Some(1), Some(false)) {
ResetDecision::Skip(reason) => assert!(reason.contains("ahead")),
ResetDecision::Reset => panic!("an ahead branch must never be reset"),
}
}
#[test]
fn decide_reset_dirty_skips() {
match decide_reset(Some(0), Some(true)) {
ResetDecision::Skip(reason) => assert!(reason.contains("uncommitted")),
ResetDecision::Reset => panic!("a dirty tree must never be reset"),
}
}
#[test]
fn decide_reset_unknown_ahead_skips() {
match decide_reset(None, Some(false)) {
ResetDecision::Skip(_) => {}
ResetDecision::Reset => panic!("unknown ahead-count must never be reset"),
}
}
#[test]
fn decide_reset_unknown_dirty_skips() {
match decide_reset(Some(0), None) {
ResetDecision::Skip(_) => {}
ResetDecision::Reset => panic!("unknown dirty-state must never be reset"),
}
}
#[test]
fn decide_reset_clean_and_even_resets() {
assert_eq!(decide_reset(Some(0), Some(false)), ResetDecision::Reset);
}
}