use serde::Serialize;
use crate::fix::{tracked_refusal, FixPlan, Intent};
use crate::scan::HooksDir;
use crate::shim;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "outcome", rename_all = "snake_case")]
pub enum Outcome {
Applied { removed: usize, written: usize },
Unchanged,
Refused,
Failed { error: String, at: String },
}
#[derive(Debug, Clone, Serialize)]
pub struct ApplyReport {
pub repo: std::path::PathBuf,
pub outcome: Outcome,
}
pub fn apply(plan: &FixPlan) -> Outcome {
if plan.refused() {
return Outcome::Refused;
}
if let Some(outcome) = reverify(plan) {
return outcome;
}
if plan.is_noop() {
return Outcome::Unchanged;
}
for path in plan
.remove
.iter()
.map(|r| &r.path)
.chain(plan.write.iter().map(|w| &w.path))
{
if let Some(refusal) = tracked_refusal(path) {
return Outcome::Failed {
error: match refusal {
crate::fix::Refusal::TrackedUnknown { why, .. } => {
format!("cannot tell whether the path is tracked by git ({why})")
}
_ => "path is tracked by git".to_string(),
},
at: path.display().to_string(),
};
}
}
let mut removed = 0;
for r in &plan.remove {
if amont_runtime::hookfile::classify(&r.path) == amont_runtime::hookfile::HookFile::Absent {
continue;
}
if let Err(refuse) = amont_runtime::hookfile::guard_remove(&r.path, false) {
return Outcome::Failed {
error: refuse.explain(),
at: refuse.path().display().to_string(),
};
}
if let Err(e) = amont_runtime::hookfile::remove_regular(&r.path) {
return Outcome::Failed {
error: e.to_string(),
at: r.path.display().to_string(),
};
}
removed += 1;
}
let mut staged = Vec::new();
for write in plan.write.iter().filter(|write| write.changes) {
if let Err(refuse) = amont_runtime::hookfile::guard_write(&write.path, false) {
return Outcome::Failed {
error: refuse.explain(),
at: refuse.path().display().to_string(),
};
}
match amont_runtime::hookfile::stage(&write.path, &shim::render(&write.baked), true) {
Ok(s) => staged.push(s),
Err(e) => {
return Outcome::Failed {
error: e.to_string(),
at: write.path.display().to_string(),
}
}
}
}
let mut written = match amont_runtime::hookfile::commit_all(staged) {
Ok(landed) => landed.len(),
Err(failure) => {
return Outcome::Failed {
error: failure.to_string(),
at: failure.at.display().to_string(),
}
}
};
if let Some(w) = &plan.write_agents_md {
match amont_runtime::agents_md::check(&w.path) {
Ok(amont_runtime::agents_md::CheckResult::MatchesGenerated) => {}
Ok(_) => match amont_runtime::agents_md::write(&w.path) {
Ok(()) => written += 1,
Err(e) => {
return Outcome::Failed {
error: e,
at: w.path.display().to_string(),
}
}
},
Err(e) => {
return Outcome::Failed {
error: e,
at: w.path.display().to_string(),
}
}
}
}
Outcome::Applied { removed, written }
}
fn reverify(plan: &FixPlan) -> Option<Outcome> {
let hooks = match crate::scan::hooks_dir_for(&plan.repo_abs) {
HooksDir::In { path } => path,
_ => return Some(Outcome::Refused),
};
if plan.hooks.inside() != Some(hooks.as_path()) {
return Some(Outcome::Refused);
}
if plan.intent == Intent::Repair && !crate::scan::is_managed(&hooks) {
return Some(Outcome::Refused);
}
if plan.intent == Intent::Activate
&& !shim::DISPATCHERS
.iter()
.all(|n| crate::fix::is_absent_or_ours(&hooks.join(n)))
{
return Some(Outcome::Refused);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fix::Intent;
use crate::fix::{plan, FixPlan, Refusal, WriteShim};
use crate::scan;
use std::path::{Path, PathBuf};
fn fixture(name: &str) -> (PathBuf, PathBuf) {
let root = std::env::temp_dir().join(format!("apply-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let hooks = root.join("r/.git/hooks");
std::fs::create_dir_all(&hooks).unwrap();
(root, hooks)
}
fn scan_one(root: &Path, binary: &str) -> (scan::Repo, PathBuf) {
let s = scan::scan(root, 3, binary, &mut |_| {});
let r = s.repos.into_iter().next().expect("one repo");
let abs = root.join(&r.path);
(r, abs)
}
const STALE_OURS: &str =
"#!/bin/sh\n# git-templates hook shim.\nexec x --hooks-dir y pre-commit-ruff\n";
fn healthy_shims(hooks: &Path, binary: &str) {
for n in shim::DISPATCHERS {
std::fs::write(hooks.join(n), shim::render(binary)).unwrap();
}
}
#[test]
fn removes_the_stale_and_writes_the_shims() {
let (root, hooks) = fixture("basic");
healthy_shims(&hooks, "/bin/gh");
std::fs::write(hooks.join("pre-commit-ruff"), STALE_OURS).unwrap();
std::fs::write(hooks.join("package.json"), "{\"//\":\"Forces Node\"}").unwrap();
let (repo, abs) = scan_one(&root, "/bin/gh");
let p = plan(&repo, &abs, "/bin/gh", Intent::Repair, false, false);
let out = apply(&p);
assert!(
matches!(out, Outcome::Applied { removed: 2, .. }),
"{out:?}"
);
assert!(!hooks.join("pre-commit-ruff").exists());
assert!(!hooks.join("package.json").exists());
for n in shim::DISPATCHERS {
assert!(hooks.join(n).exists(), "{n} must survive");
}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn applying_twice_is_a_no_op_the_second_time() {
let (root, hooks) = fixture("idempotent");
healthy_shims(&hooks, "/bin/gh");
std::fs::write(hooks.join("pre-commit-ruff"), STALE_OURS).unwrap();
let (repo, abs) = scan_one(&root, "/bin/gh");
assert!(matches!(
apply(&plan(&repo, &abs, "/bin/gh", Intent::Repair, false, false)),
Outcome::Applied { .. }
));
let (repo2, abs2) = scan_one(&root, "/bin/gh");
let p2 = plan(&repo2, &abs2, "/bin/gh", Intent::Repair, false, false);
assert!(p2.is_noop(), "second plan should be empty: {p2:?}");
assert_eq!(apply(&p2), Outcome::Unchanged);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn writes_missing_dispatchers() {
let (root, hooks) = fixture("missing");
healthy_shims(&hooks, "/bin/gh");
std::fs::remove_file(hooks.join("pre-push")).unwrap();
let (repo, abs) = scan_one(&root, "/bin/gh");
let out = apply(&plan(&repo, &abs, "/bin/gh", Intent::Repair, false, false));
assert!(
matches!(out, Outcome::Applied { written: 1, .. }),
"{out:?}"
);
assert_eq!(
std::fs::read_to_string(hooks.join("pre-push")).unwrap(),
shim::render("/bin/gh")
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_planned_agents_md_write_is_a_plain_file_not_a_shim() {
let (root, hooks) = fixture("agents-md-write");
healthy_shims(&hooks, "/bin/gh");
let (repo, abs) = scan_one(&root, "/bin/gh");
let p = plan(&repo, &abs, "/bin/gh", Intent::Repair, true, false);
let out = apply(&p);
assert!(matches!(out, Outcome::Applied { .. }), "{out:?}");
let agents_md = abs.join("AGENTS.md");
assert_eq!(
std::fs::read_to_string(&agents_md).unwrap(),
amont_runtime::agents_md::generate_block()
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&agents_md).unwrap().permissions().mode();
assert_eq!(mode & 0o111, 0, "AGENTS.md must not be made executable");
}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn an_agents_md_that_became_current_between_scan_and_apply_is_left_alone() {
let (root, hooks) = fixture("agents-md-race");
healthy_shims(&hooks, "/bin/gh");
let (repo, abs) = scan_one(&root, "/bin/gh");
let p = plan(&repo, &abs, "/bin/gh", Intent::Repair, true, false);
assert!(p.write_agents_md.is_some(), "plan expected a write");
std::fs::write(
abs.join("AGENTS.md"),
amont_runtime::agents_md::generate_block(),
)
.unwrap();
let out = apply(&p);
assert!(
matches!(out, Outcome::Applied { written: 0, .. }),
"must not count a write it skipped: {out:?}"
);
assert_eq!(
std::fs::read_to_string(abs.join("AGENTS.md")).unwrap(),
amont_runtime::agents_md::generate_block(),
"must still be exactly the generated block, not doubled or corrupted"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_refused_plan_touches_nothing() {
let (root, hooks) = fixture("refused");
std::fs::write(hooks.join("pre-commit"), "#!/bin/zsh\necho legacy\n").unwrap();
let before = std::fs::read_to_string(hooks.join("pre-commit")).unwrap();
let (repo, abs) = scan_one(&root, "/bin/gh");
let p = plan(&repo, &abs, "/bin/gh", Intent::Repair, false, false);
assert_eq!(p.refuse, vec![Refusal::Unmanaged]);
assert_eq!(apply(&p), Outcome::Refused);
assert_eq!(
std::fs::read_to_string(hooks.join("pre-commit")).unwrap(),
before,
"an unmanaged repo must be left exactly as found"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn removals_precede_writes() {
let (root, hooks) = fixture("order");
healthy_shims(&hooks, "/bin/gh");
std::fs::write(hooks.join("pre-push-old"), STALE_OURS).unwrap();
let (repo, abs) = scan_one(&root, "/bin/gh");
let p = plan(&repo, &abs, "/bin/gh", Intent::Repair, false, false);
let removals: Vec<_> = p.remove.iter().map(|r| r.path.clone()).collect();
assert!(!removals.is_empty());
apply(&p);
for r in removals {
assert!(!r.exists(), "{} should be gone", r.display());
}
let _ = std::fs::remove_dir_all(&root);
}
#[cfg(unix)]
#[test]
fn written_shims_are_executable() {
use std::os::unix::fs::PermissionsExt;
let (root, hooks) = fixture("mode");
healthy_shims(&hooks, "/bin/gh");
std::fs::remove_file(hooks.join("commit-msg")).unwrap();
let (repo, abs) = scan_one(&root, "/bin/gh");
apply(&plan(&repo, &abs, "/bin/gh", Intent::Repair, false, false));
let mode = std::fs::metadata(hooks.join("commit-msg"))
.unwrap()
.permissions()
.mode();
assert_eq!(
mode & 0o111,
0o111,
"git will not run a non-executable hook"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_write_that_became_tracked_is_refused_not_overwritten() {
let dir = std::env::temp_dir().join(format!("apply-tracked-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let git = |args: &[&str]| {
std::process::Command::new("git")
.args(args)
.current_dir(&dir)
.output()
.expect("git");
};
git(&["init", "-q", "--template=", "."]);
git(&["config", "user.email", "t@t"]);
git(&["config", "user.name", "t"]);
let target = dir.join("tracked-shim");
std::fs::write(&target, "original\n").unwrap();
git(&["add", "tracked-shim"]);
git(&["commit", "-q", "--no-verify", "-m", "chore: seed"]);
let hooks = dir.join(".git/hooks");
std::fs::create_dir_all(&hooks).unwrap();
std::fs::write(hooks.join("pre-commit"), shim::render("/bin/gh")).unwrap();
let p = FixPlan {
repo: PathBuf::from("r"),
repo_abs: dir.clone(),
intent: Intent::Repair,
hooks: crate::scan::HooksDir::In {
path: hooks.clone(),
},
refuse: Vec::new(),
warn: Vec::new(),
remove: Vec::new(),
write: vec![WriteShim {
path: target.clone(),
baked: "/bin/gh".to_string(),
changes: true,
}],
write_agents_md: None,
};
assert_eq!(
apply(&p),
Outcome::Failed {
error: "path is tracked by git".into(),
at: target.display().to_string(),
}
);
assert_eq!(
std::fs::read_to_string(&target).unwrap(),
"original\n",
"a tracked file must never be overwritten by apply"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_repo_that_became_unmanaged_between_plan_and_apply_is_refused() {
let (root, hooks) = fixture("unmanaged-race");
healthy_shims(&hooks, "/bin/gh");
std::fs::remove_file(hooks.join("pre-push")).unwrap();
let (repo, abs) = scan_one(&root, "/bin/gh");
let p = plan(&repo, &abs, "/bin/gh", Intent::Repair, false, false);
assert!(!p.is_noop() && !p.refused(), "fixture: {p:?}");
for n in shim::DISPATCHERS {
let _ = std::fs::remove_file(hooks.join(n));
}
std::fs::write(hooks.join("pre-commit"), "#!/bin/sh\necho mine\n").unwrap();
let before = std::fs::read_to_string(hooks.join("pre-commit")).unwrap();
assert_eq!(apply(&p), Outcome::Refused);
assert_eq!(
std::fs::read_to_string(hooks.join("pre-commit")).unwrap(),
before,
"a repo that stopped being ours must be left exactly as found"
);
assert!(
!hooks.join("pre-push").exists(),
"and nothing may be written into it"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_dispatcher_that_became_foreign_between_plan_and_apply_is_refused() {
let (root, hooks) = fixture("foreign-race");
let (repo, abs) = scan_one(&root, "/bin/gh");
let p = plan(&repo, &abs, "/bin/gh", Intent::Activate, false, false);
assert!(!p.is_noop() && !p.refused(), "fixture: {p:?}");
let theirs = "#!/bin/sh\n# my own commit-msg, thanks\nexec my-linter \"$@\"\n";
std::fs::write(hooks.join("commit-msg"), theirs).unwrap();
assert_eq!(apply(&p), Outcome::Refused);
assert_eq!(
std::fs::read_to_string(hooks.join("commit-msg")).unwrap(),
theirs,
"activation must never overwrite a hook somebody wrote"
);
assert!(
!hooks.join("pre-commit").exists(),
"and one foreign dispatcher suppresses the WHOLE repo, not just itself"
);
let _ = std::fs::remove_dir_all(&root);
}
}