use std::collections::HashSet;
use std::path::Path;
use tracing::{info, warn};
pub const HYGIENE_OPT_OUT_MARKER: &str = ".trusty-mpm-no-hygiene";
#[derive(Debug, Clone, PartialEq, Eq)]
enum UpdateDecision {
Update,
Skip(String),
}
fn decide_update(
checked_out: Option<&str>,
default_branch: &str,
ahead_count: Option<usize>,
dirty: Option<bool>,
) -> UpdateDecision {
let Some(branch) = checked_out else {
return UpdateDecision::Skip(
"HEAD is detached (no branch checked out); refusing to move any ref".to_string(),
);
};
if branch != default_branch {
return UpdateDecision::Skip(format!(
"checked-out branch `{branch}` is not the default branch `{default_branch}`; \
refusing to move it to a different ref"
));
}
let Some(ahead) = ahead_count else {
return UpdateDecision::Skip(
"branch ahead-count unknown (no upstream, or git error); \
refusing to discard local work"
.to_string(),
);
};
let Some(is_dirty) = dirty else {
return UpdateDecision::Skip(
"working tree status unknown (git error); refusing to update".to_string(),
);
};
if ahead > 0 {
return UpdateDecision::Skip(format!(
"{ahead} commit(s) ahead of origin (unpushed); refusing to discard local work"
));
}
if is_dirty {
return UpdateDecision::Skip("uncommitted changes present; refusing to update".to_string());
}
UpdateDecision::Update
}
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> {
porcelain_status(base_path).ok().map(|e| !e.is_empty())
}
pub(crate) fn porcelain_status(base_path: &Path) -> Result<Vec<String>, String> {
let out = std::process::Command::new("git")
.arg("-C")
.arg(base_path)
.args(["status", "--porcelain"])
.output()
.map_err(|e| format!("git status --porcelain failed to spawn: {e}"))?;
if !out.status.success() {
return Err(format!(
"git status --porcelain failed ({}): {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
));
}
Ok(String::from_utf8_lossy(&out.stdout)
.lines()
.filter(|l| !l.trim().is_empty())
.map(str::to_string)
.collect())
}
fn git_z_lines(base_path: &Path, args: &[&str]) -> Result<Vec<String>, String> {
let out = std::process::Command::new("git")
.arg("-C")
.arg(base_path)
.args(args)
.output()
.map_err(|e| format!("git {args:?} error: {e}"))?;
if !out.status.success() {
return Err(format!(
"git {args:?} failed ({}): {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
));
}
Ok(String::from_utf8_lossy(&out.stdout)
.split('\0')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect())
}
fn colliding_untracked_paths(base_path: &Path, target: &str) -> Result<Vec<String>, String> {
let incoming = git_z_lines(base_path, &["diff", "--name-only", "-z", "HEAD", target])?;
if incoming.is_empty() {
return Ok(Vec::new());
}
let tracked: HashSet<String> = git_z_lines(base_path, &["ls-files", "-z"])?
.into_iter()
.collect();
Ok(incoming
.into_iter()
.filter(|p| !tracked.contains(p) && base_path.join(p).symlink_metadata().is_ok())
.collect())
}
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)
}
}
fn update_to_origin(base_path: &Path) {
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);
if let UpdateDecision::Skip(reason) =
decide_update(checked_out.as_deref(), &default_branch, ahead, dirty)
{
warn!(
path = %base_path.display(),
branch = %checked_out.as_deref().unwrap_or("<unknown/detached>"),
"inproject-hygiene: SKIP update — {reason}"
);
return;
}
let target = format!("origin/{default_branch}");
match colliding_untracked_paths(base_path, &target) {
Ok(collisions) if !collisions.is_empty() => {
warn!(
path = %base_path.display(),
paths = %collisions.join(", "),
"inproject-hygiene: SKIP update — {} untracked path(s) on disk would be \
overwritten by the update; refusing to discard them",
collisions.len()
);
return;
}
Ok(_) => {}
Err(e) => {
warn!(path = %base_path.display(), "inproject-hygiene: SKIP update — collision check failed: {e}");
return;
}
}
if let Some(branch) = checked_out.as_deref() {
write_recovery_ref(base_path, branch);
}
let merge = std::process::Command::new("git")
.arg("-C")
.arg(base_path)
.args(["merge", "--ff-only", &target])
.output();
match merge {
Ok(out) if out.status.success() => {
info!(path = %base_path.display(), branch = %default_branch, "inproject-hygiene: fast-forward OK");
}
Ok(out) => {
warn!(
path = %base_path.display(),
"inproject-hygiene: fast-forward declined ({}): {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
);
}
Err(e) => {
warn!(path = %base_path.display(), "inproject-hygiene: fast-forward error: {e}");
}
}
}
pub fn run_hygiene_for_base(base_path: &Path) -> Result<(), String> {
if !base_path.join(".git").exists() {
return Ok(());
}
if base_path.join(HYGIENE_OPT_OUT_MARKER).exists() {
info!(path = %base_path.display(), marker = %HYGIENE_OPT_OUT_MARKER, "inproject-hygiene: opted out, skipping");
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}");
}
}
update_to_origin(base_path);
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)]
#[path = "inproject_hygiene_tests.rs"]
mod inproject_hygiene_tests;