use std::path::{Path, PathBuf};
use amont_runtime::hookfile::Tracked;
use serde::Serialize;
use crate::scan::{AgentsMdState, Repo};
use crate::shim::{self, DISPATCHERS};
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "refusal", rename_all = "snake_case")]
pub enum Refusal {
Unmanaged,
UnreadableHooks,
Tracked { path: PathBuf },
TrackedUnknown { path: PathBuf, why: String },
ForeignHook { names: Vec<String> },
AgentsMdMalformed { path: PathBuf },
UnbakeableBinary { binary: String },
HooksDirOutsideRepo { path: PathBuf },
HooksDirUnknown { why: String },
}
impl Refusal {
pub fn explain(&self) -> String {
match self {
Refusal::Unmanaged => "no shim of ours here — not adopting it".to_string(),
Refusal::UnreadableHooks => "the hooks directory could not be read".to_string(),
Refusal::Tracked { path } => format!(
"{} is TRACKED by git — that is somebody's source, not our hook",
path.display()
),
Refusal::TrackedUnknown { path, why } => format!(
"cannot tell whether {} is tracked ({why})\n \
If this is a repository you own: \
git config --global --add safe.directory {}",
path.display(),
path.parent().unwrap_or(path).display()
),
Refusal::ForeignHook { names } => format!(
"{} was written by somebody else — activating would overwrite it",
names.join(", ")
),
Refusal::AgentsMdMalformed { path } => {
format!("{} carries an unpaired marker", path.display())
}
Refusal::UnbakeableBinary { binary } => {
format!("{binary} is not a path a shim will accept")
}
Refusal::HooksDirOutsideRepo { path } => format!(
"core.hooksPath resolves to {}, OUTSIDE the repository — \
not creating or writing there",
path.display()
),
Refusal::HooksDirUnknown { why } => {
format!("git would not say where the hooks are ({why})")
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum RemovalReason {
StaleOurs,
ForeignSubHook,
VestigialPackageJson,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Removal {
pub path: PathBuf,
pub reason: RemovalReason,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct WriteShim {
pub path: PathBuf,
pub baked: String,
pub changes: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct WriteAgentsMd {
pub path: PathBuf,
pub changes: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "warning", rename_all = "snake_case")]
pub enum Warning {
UnrecognizedSubHook { path: PathBuf },
HooksDirOutsideRepo { path: PathBuf },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct FixPlan {
pub repo: PathBuf,
pub repo_abs: PathBuf,
pub intent: Intent,
pub hooks: crate::scan::HooksDir,
pub refuse: Vec<Refusal>,
pub warn: Vec<Warning>,
pub remove: Vec<Removal>,
pub write: Vec<WriteShim>,
pub write_agents_md: Option<WriteAgentsMd>,
}
impl FixPlan {
pub fn is_noop(&self) -> bool {
self.remove.is_empty()
&& self.write.iter().all(|w| !w.changes)
&& self.write_agents_md.is_none()
}
pub fn refused(&self) -> bool {
!self.refuse.is_empty()
}
}
pub fn tracked_refusal(path: &Path) -> Option<Refusal> {
match amont_runtime::hookfile::tracked(path) {
Tracked::No => None,
Tracked::Yes => Some(Refusal::Tracked {
path: path.to_path_buf(),
}),
Tracked::Unknown { why } => Some(Refusal::TrackedUnknown {
path: path.to_path_buf(),
why,
}),
}
}
pub fn is_absent_or_ours(path: &Path) -> bool {
matches!(
amont_runtime::hookfile::classify(path),
amont_runtime::hookfile::HookFile::Absent | amont_runtime::hookfile::HookFile::Ours
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Intent {
Repair,
Activate,
}
pub fn plan(
repo: &Repo,
repo_abs: &Path,
binary: &str,
intent: Intent,
agents_md: bool,
remove_unrecognized: bool,
) -> FixPlan {
let hooks_dir = crate::scan::hooks_dir_for(repo_abs);
let mut p = FixPlan {
repo: repo.path.clone(),
repo_abs: repo_abs.to_path_buf(),
intent,
hooks: hooks_dir.clone(),
refuse: Vec::new(),
warn: Vec::new(),
remove: Vec::new(),
write: Vec::new(),
write_agents_md: None,
};
if !amont_runtime::install::is_bakeable(binary) {
p.refuse.push(Refusal::UnbakeableBinary {
binary: binary.to_string(),
});
return p;
}
if repo.shares_hooks_with.is_some() {
return p;
}
if !repo.managed && intent == Intent::Repair {
p.refuse.push(Refusal::Unmanaged);
return p;
}
let hooks = match &hooks_dir {
crate::scan::HooksDir::In { path } => path.clone(),
crate::scan::HooksDir::Outside { path } => {
p.warn
.push(Warning::HooksDirOutsideRepo { path: path.clone() });
p.refuse
.push(Refusal::HooksDirOutsideRepo { path: path.clone() });
return p;
}
crate::scan::HooksDir::Unknown { why } => {
p.refuse.push(Refusal::HooksDirUnknown { why: why.clone() });
return p;
}
};
if intent == Intent::Activate && !hooks.is_dir() {
let _ = std::fs::create_dir_all(&hooks);
}
if !hooks.is_dir() {
p.refuse.push(Refusal::UnreadableHooks);
return p;
}
for name in &repo.stale_ours {
p.remove.push(Removal {
path: hooks.join(name),
reason: RemovalReason::StaleOurs,
});
}
for name in &repo.foreign_subs {
let path = hooks.join(name);
if remove_unrecognized {
p.remove.push(Removal {
path,
reason: RemovalReason::ForeignSubHook,
});
} else {
p.warn.push(Warning::UnrecognizedSubHook { path });
}
}
if repo.hook_pkgjson {
p.remove.push(Removal {
path: hooks.join("package.json"),
reason: RemovalReason::VestigialPackageJson,
});
}
p.remove.sort_by(|a, b| a.path.cmp(&b.path));
if intent == Intent::Activate {
let foreign: Vec<String> = DISPATCHERS
.into_iter()
.filter(|name| !is_absent_or_ours(&hooks.join(name)))
.map(str::to_owned)
.collect();
if !foreign.is_empty() {
p.refuse.push(Refusal::ForeignHook { names: foreign });
return p;
}
}
let rendered = shim::render(binary);
for name in DISPATCHERS {
let path = hooks.join(name);
let changes = std::fs::read_to_string(&path)
.map(|c| c != rendered)
.unwrap_or(true);
p.write.push(WriteShim {
path,
baked: binary.to_string(),
changes,
});
}
let refusals: Vec<Refusal> = p
.remove
.iter()
.map(|r| &r.path)
.chain(p.write.iter().map(|w| &w.path))
.filter_map(|path| tracked_refusal(path))
.collect();
if !refusals.is_empty() {
p.refuse.extend(refusals);
p.remove.clear();
p.write.clear();
}
if agents_md {
let path = repo_abs.join("AGENTS.md");
match repo.agents_md {
AgentsMdState::Missing | AgentsMdState::Drifted => {
p.write_agents_md = Some(WriteAgentsMd {
path,
changes: true,
});
}
AgentsMdState::UpToDate => {}
AgentsMdState::Malformed => {
p.refuse.push(Refusal::AgentsMdMalformed { path });
}
}
}
p
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shim::{BakeState, ShimState};
fn repo(managed: bool) -> Repo {
Repo {
path: PathBuf::from("r"),
managed,
shims: vec![ShimState::Missing; 4],
baked: BakeState::None,
stale_ours: Vec::new(),
foreign_subs: Vec::new(),
hook_pkgjson: false,
languages: Vec::new(),
applicable: Vec::new(),
skips: Vec::new(),
severities: Vec::new(),
declared: Vec::new(),
trusted: None,
agents_md: AgentsMdState::Missing,
hooks_dir: crate::scan::HooksDir::In {
path: std::path::PathBuf::from(".git/hooks"),
},
shares_hooks_with: None,
}
}
#[test]
fn an_unmanaged_repo_is_refused_not_fixed() {
let p = plan(
&repo(false),
Path::new("/nowhere"),
"/bin/gh",
Intent::Repair,
false,
false,
);
assert_eq!(p.refuse, vec![Refusal::Unmanaged]);
assert!(
p.remove.is_empty() && p.write.is_empty(),
"never adopt a repo"
);
}
#[test]
fn a_missing_hooks_dir_is_refused() {
let dir = std::env::temp_dir().join(format!("fixplan-nohooks-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join(".git")).unwrap();
let p = plan(&repo(true), &dir, "/bin/gh", Intent::Repair, false, false);
assert_eq!(p.refuse, vec![Refusal::UnreadableHooks]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_repo_git_cannot_reach_at_all_refuses_as_unknown() {
let p = plan(
&repo(true),
Path::new("/definitely/not/here"),
"/bin/gh",
Intent::Repair,
false,
false,
);
assert!(
matches!(p.refuse.as_slice(), [Refusal::HooksDirUnknown { why }] if !why.is_empty()),
"{:?}",
p.refuse
);
assert!(p.remove.is_empty() && p.write.is_empty());
}
#[test]
fn removals_are_classified_and_sorted() {
let dir = std::env::temp_dir().join(format!("fixplan-{}", std::process::id()));
let hooks = dir.join(".git/hooks");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&hooks).unwrap();
let mut r = repo(true);
r.stale_ours = vec!["pre-commit-ruff".into()];
r.foreign_subs = vec!["pre-push-mine.sh".into()];
r.hook_pkgjson = true;
let p = plan(&r, &dir, "/bin/gh", Intent::Repair, false, false);
assert_eq!(p.remove.len(), 2, "{:?}", p.remove);
let reasons: Vec<_> = p.remove.iter().map(|r| r.reason).collect();
assert!(reasons.contains(&RemovalReason::StaleOurs));
assert!(reasons.contains(&RemovalReason::VestigialPackageJson));
assert!(!reasons.contains(&RemovalReason::ForeignSubHook));
assert_eq!(
p.warn,
vec![Warning::UnrecognizedSubHook {
path: hooks.join("pre-push-mine.sh")
}],
"and it must be NAMED, not silently skipped"
);
assert_eq!(
p.write.len(),
4,
"all four are written, as propagate.sh does"
);
assert!(p.write.iter().all(|w| w.changes), "none exist yet");
let opted_in = plan(&r, &dir, "/bin/gh", Intent::Repair, false, true);
assert_eq!(opted_in.remove.len(), 3, "{:?}", opted_in.remove);
assert!(opted_in
.remove
.iter()
.any(|r| r.reason == RemovalReason::ForeignSubHook));
assert!(opted_in.warn.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_stranger_s_hook_warns_without_suppressing_the_repair() {
let dir = std::env::temp_dir().join(format!("fixplan-warn-{}", std::process::id()));
let hooks = dir.join(".git/hooks");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&hooks).unwrap();
let mut r = repo(true);
r.foreign_subs = vec!["pre-push-mine.sh".into()];
let p = plan(&r, &dir, "/bin/gh", Intent::Repair, false, false);
assert!(!p.refused(), "{:?}", p.refuse);
assert_eq!(p.write.len(), 4, "all four dispatchers still planned");
assert!(p.remove.is_empty());
assert_eq!(p.warn.len(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_already_correct_repo_plans_no_changes() {
let dir = std::env::temp_dir().join(format!("fixplan-ok-{}", std::process::id()));
let hooks = dir.join(".git/hooks");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&hooks).unwrap();
for n in DISPATCHERS {
std::fs::write(hooks.join(n), shim::render("/bin/gh")).unwrap();
}
let p = plan(&repo(true), &dir, "/bin/gh", Intent::Repair, false, false);
assert!(p.is_noop(), "{p:?}");
assert_eq!(p.write.len(), 4);
let _ = std::fs::remove_dir_all(&dir);
}
fn healthy_repo_dir(name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("fixplan-agents-md-{name}-{}", std::process::id()));
let hooks = dir.join(".git/hooks");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&hooks).unwrap();
for n in DISPATCHERS {
std::fs::write(hooks.join(n), shim::render("/bin/gh")).unwrap();
}
dir
}
#[test]
fn agents_md_is_never_planned_without_opting_in() {
let dir = healthy_repo_dir("optout");
let mut r = repo(true);
r.agents_md = AgentsMdState::Missing;
let p = plan(&r, &dir, "/bin/gh", Intent::Repair, false, false);
assert!(p.write_agents_md.is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_missing_or_drifted_agents_md_is_planned_when_opted_in() {
for state in [AgentsMdState::Missing, AgentsMdState::Drifted] {
let dir = healthy_repo_dir("plan");
let mut r = repo(true);
r.agents_md = state;
let p = plan(&r, &dir, "/bin/gh", Intent::Repair, true, false);
assert!(!p.is_noop(), "{p:?}");
let w = p.write_agents_md.expect("must plan a write");
assert!(w.changes);
assert_eq!(w.path, dir.join("AGENTS.md"));
let _ = std::fs::remove_dir_all(&dir);
}
}
#[test]
fn an_up_to_date_agents_md_is_not_replanned() {
let dir = healthy_repo_dir("uptodate");
let mut r = repo(true);
r.agents_md = AgentsMdState::UpToDate;
let p = plan(&r, &dir, "/bin/gh", Intent::Repair, true, false);
assert!(p.write_agents_md.is_none());
assert!(p.is_noop(), "{p:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_malformed_agents_md_refuses_the_repo() {
let dir = healthy_repo_dir("malformed");
let mut r = repo(true);
r.agents_md = AgentsMdState::Malformed;
let p = plan(&r, &dir, "/bin/gh", Intent::Repair, true, false);
assert_eq!(
p.refuse,
vec![Refusal::AgentsMdMalformed {
path: dir.join("AGENTS.md")
}]
);
assert!(p.write_agents_md.is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_tracked_path_refuses_and_an_untracked_one_does_not() {
let dir = std::env::temp_dir().join(format!("fixplan-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"]);
std::fs::write(dir.join("tracked.txt"), "x").unwrap();
std::fs::write(dir.join("untracked.txt"), "x").unwrap();
git(&["add", "tracked.txt"]);
git(&["commit", "-q", "--no-verify", "-m", "chore: seed"]);
assert_eq!(
tracked_refusal(&dir.join("tracked.txt")),
Some(Refusal::Tracked {
path: dir.join("tracked.txt")
})
);
assert_eq!(tracked_refusal(&dir.join("untracked.txt")), None);
assert_eq!(tracked_refusal(&dir.join("does-not-exist.txt")), None);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn plan_finds_shims_at_a_redirected_hooks_path() {
let dir = std::env::temp_dir().join(format!("fixplan-hookspath-{}", 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", "core.hooksPath", "tooling/hooks"]);
let hooks = dir.join("tooling/hooks");
std::fs::create_dir_all(&hooks).unwrap();
std::fs::write(
hooks.join("pre-commit-ruff"),
"#!/bin/sh\nexec x --hooks-dir y pre-commit-ruff\n",
)
.unwrap();
let mut r = repo(true);
r.stale_ours = vec!["pre-commit-ruff".into()];
let p = plan(&r, &dir, "/bin/gh", Intent::Repair, false, false);
assert!(!p.refused(), "{:?}", p.refuse);
assert_eq!(
p.remove,
vec![Removal {
path: hooks.join("pre-commit-ruff"),
reason: RemovalReason::StaleOurs,
}]
);
assert!(
p.write.iter().all(|w| w.path.starts_with(&hooks)),
"shims must be planned at the redirected path, not .git/hooks: {:?}",
p.write
);
assert!(
!dir.join(".git/hooks").exists(),
"fixture: the default location was never created"
);
let _ = std::fs::remove_dir_all(&dir);
}
}