use std::path::{Path, PathBuf};
use std::process::Command;
use crate::hookfile::{self, HookFile, Refuse, Staged, SwapFailure};
use crate::ui::{error_sign, highlight, valid_sign, warning_sign};
pub const PLACEHOLDER: &str = "__AMONT_BIN__";
pub const SHIM: &str = include_str!("../templates/hooks/pre-commit");
pub use crate::hookfile::{is_our_shim, SHIM_MARKER};
pub const DISPATCHERS: [&str; 4] = ["commit-msg", "pre-commit", "pre-push", "prepare-commit-msg"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TemplateDir {
Unresolvable,
NoGit,
IsCheckout,
InsideCheckout,
Safe,
}
fn git_ok(dir: &Path, args: &[&str]) -> bool {
Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
pub fn classify_dir(dir: &Path) -> TemplateDir {
let Ok(real) = dir.canonicalize() else {
return TemplateDir::Unresolvable;
};
if Command::new("git").arg("--version").output().is_err() {
return TemplateDir::NoGit;
}
if git_ok(&real, &["ls-files", "--error-unmatch", "."]) {
return TemplateDir::IsCheckout;
}
if git_ok(&real, &["rev-parse", "--git-dir"]) {
return TemplateDir::InsideCheckout;
}
TemplateDir::Safe
}
pub fn bake(shim: &str, bin: &str) -> String {
shim.replace(PLACEHOLDER, bin)
}
pub fn is_bakeable(bin: &str) -> bool {
if bin.is_empty() || bin == PLACEHOLDER {
return false;
}
let b = bin.as_bytes();
if b[0] == b'/' {
return true;
}
b.len() > 2 && b[0].is_ascii_alphabetic() && b[1] == b':' && (b[2] == b'/' || b[2] == b'\\')
}
fn absolute(p: &Path) -> PathBuf {
if p.is_absolute() {
return p.to_path_buf();
}
match std::env::current_dir() {
Ok(cwd) => cwd.join(p),
Err(_) => p.to_path_buf(),
}
}
fn unbaked_lookup_dir() -> PathBuf {
home().join(".local").join("bin")
}
fn warn_if_unbaked_cannot_resolve(binary: &str) {
let looked = unbaked_lookup_dir();
let placed = Path::new(binary).parent();
let reachable = placed.is_some_and(|p| {
matches!(
(p.canonicalize(), looked.canonicalize()),
(Ok(a), Ok(b)) if a == b
)
});
if reachable {
return;
}
println!();
println!(
"{} the binary is at {}, which an unbaked shim will not find.",
warning_sign(),
highlight(binary)
);
println!(
" Shims here keep the placeholder, and they look only in {}.",
looked.display()
);
println!(" Either link it where they look:");
println!(" ln -s {} {}", binary, looked.join("amont").display());
println!(" or set GIT_HOOKS_BIN in the environment git runs hooks with:");
println!(" export GIT_HOOKS_BIN={binary}");
}
pub fn bin_dir() -> PathBuf {
if let Some(d) = std::env::var_os("AMONT_BIN_DIR") {
return PathBuf::from(d);
}
home().join(".local").join("bin")
}
pub fn template_hooks_dir() -> PathBuf {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| home().join(".config"));
base.join("git")
.join("git-templates")
.join("templates")
.join("hooks")
}
fn home() -> PathBuf {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
}
fn installed_name() -> String {
match std::env::current_exe() {
Ok(p) => name_for(&p),
Err(_) => "amont".to_string(),
}
}
fn name_for(exe: &Path) -> String {
match exe.extension().and_then(|e| e.to_str()) {
Some(e) if !e.is_empty() => format!("amont.{e}"),
_ => "amont".to_string(),
}
}
fn foreign_hooks(dir: &Path) -> Vec<(&'static str, HookFile)> {
DISPATCHERS
.into_iter()
.map(|name| (name, hookfile::classify(&dir.join(name))))
.filter(|(_, what)| !matches!(what, HookFile::Absent | HookFile::Ours))
.collect()
}
#[derive(Debug)]
pub struct Written {
pub path: PathBuf,
pub replaced: HookFile,
}
#[derive(Debug)]
pub enum ShimWriteError {
Refused(Vec<Refuse>),
Preflight { at: PathBuf, error: std::io::Error },
Swap(SwapFailure),
}
impl std::fmt::Display for ShimWriteError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ShimWriteError::Refused(refusals) => {
writeln!(f, "refusing to write {} hooks:", refusals.len())?;
for (i, r) in refusals.iter().enumerate() {
if i > 0 {
writeln!(f)?;
}
write!(f, " {}", r.explain())?;
}
Ok(())
}
ShimWriteError::Preflight { at, error } => {
write!(f, "cannot prepare {}: {error}", at.display())
}
ShimWriteError::Swap(s) => write!(f, "{s}"),
}
}
}
fn write_shims(dir: &Path, bin: &str, force: bool) -> Result<Vec<Written>, ShimWriteError> {
if !is_bakeable(bin) {
return Err(ShimWriteError::Preflight {
at: dir.to_path_buf(),
error: std::io::Error::other(format!(
"refusing to bake {bin:?}: the shim takes an absolute path only"
)),
});
}
let baked = bake(SHIM, bin);
let mut allowed: Vec<(PathBuf, HookFile)> = Vec::new();
let mut refusals: Vec<Refuse> = Vec::new();
for name in DISPATCHERS {
let path = dir.join(name);
match hookfile::guard_write(&path, force) {
Ok(what) => allowed.push((path, what)),
Err(r) => refusals.push(r),
}
}
if !refusals.is_empty() {
return Err(ShimWriteError::Refused(refusals));
}
let mut staged: Vec<Staged> = Vec::new();
for (path, _) in &allowed {
match hookfile::stage(path, &baked, true) {
Ok(s) => staged.push(s),
Err(error) => {
return Err(ShimWriteError::Preflight {
at: path.clone(),
error,
});
}
}
}
hookfile::commit_all(staged).map_err(ShimWriteError::Swap)?;
Ok(allowed
.into_iter()
.map(|(path, replaced)| Written { path, replaced })
.collect())
}
#[cfg(unix)]
fn make_executable(p: &Path) -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o755))
}
#[cfg(not(unix))]
fn make_executable(_p: &Path) -> std::io::Result<()> {
Ok(()) }
pub fn run(force: bool) -> Result<(), String> {
let binary = install_binary()?;
populate_template_dir(&binary, force)?;
bake_repo_hooks(&binary, force)?;
offer_trust();
offer_agents_md();
point_at_setup();
Ok(())
}
fn point_at_setup() {
let s = crate::commit_style::Style::resolve();
println!(
" commit style: gitmoji {}, subject ≤{}, description ≤{} — `amont setup` to change",
s.gitmoji.as_str(),
s.subject_max,
s.description_max
);
}
fn offer_trust() {
let Ok(root) = crate::hooks::common::repo_root_checked() else {
return;
};
let root = Path::new(&root);
let state = crate::trust::state(root);
if matches!(
state,
crate::trust::State::NoManifest | crate::trust::State::Trusted
) {
return;
}
println!();
println!(
"{} {} declares checks that would run on your commits:",
warning_sign(),
crate::manifest::MANIFEST
);
let manifest = root.join(crate::manifest::MANIFEST);
let Ok(source) = std::fs::read(&manifest) else {
println!(
"{} could not read {}",
warning_sign(),
crate::manifest::MANIFEST
);
return;
};
print!(
"{}",
crate::trust::describe_source(&String::from_utf8_lossy(&source))
);
let Some(fp) = crate::trust::fingerprint_bytes(root, &source) else {
println!(
"{} could not hash {}",
warning_sign(),
crate::manifest::MANIFEST
);
return;
};
if crate::trust::confirm(" Trust them? (y/N) ") {
match crate::trust::record_verified(root, &fp) {
Ok(()) => println!("{} trusted ({fp})", valid_sign()),
Err(e) => println!("{} {e}", warning_sign()),
}
} else {
println!(" Left untrusted. The built-ins still run; these do not.");
println!(" Change your mind with `amont trust`.");
}
}
fn offer_agents_md() {
let Ok(root) = crate::hooks::common::repo_root_checked() else {
return;
};
let path = Path::new(&root).join("AGENTS.md");
match crate::agents_md::check(&path) {
Ok(crate::agents_md::CheckResult::MatchesGenerated) => return,
Ok(_) => {}
Err(_) => return,
}
println!();
println!(
"{} AGENTS.md can point coding agents at `amont list --json` \
instead of leaving them to discover these checks the hard way:",
warning_sign()
);
if crate::trust::confirm(" Add it? (y/N) ") {
match crate::agents_md::write(&path) {
Ok(()) => println!("{} wrote {}", valid_sign(), path.display()),
Err(e) => println!("{} {e}", warning_sign()),
}
} else {
println!(" Left as-is. Change your mind with `amont agents-md`.");
}
}
fn on_path_already(me: &Path) -> Option<PathBuf> {
let me_real = me.canonicalize().ok()?;
let name = installed_name();
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path)
.map(|dir| dir.join(&name))
.filter(|cand| !in_a_build_dir(cand))
.find(|cand| cand.canonicalize().is_ok_and(|real| real == me_real))
.map(|cand| absolute(&cand))
.filter(|abs| is_bakeable(&abs.to_string_lossy()))
}
fn in_a_build_dir(p: &Path) -> bool {
p.ancestors()
.skip(1)
.take(4)
.any(|dir| dir.join("CACHEDIR.TAG").is_file())
}
fn install_binary() -> Result<String, String> {
let me =
std::env::current_exe().map_err(|e| format!("cannot locate the running binary: {e}"))?;
if std::env::var_os("AMONT_BIN_DIR").is_none() {
if let Some(stable) = on_path_already(&me) {
let shown = stable.to_string_lossy().into_owned();
println!("{} using {}", valid_sign(), highlight(&shown));
println!(" already on PATH, so nothing was copied — an upgrade there");
println!(" reaches every repository without reinstalling.");
return Ok(shown);
}
}
let dir = bin_dir();
std::fs::create_dir_all(&dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
let target = absolute(&dir.join(installed_name()));
let already_there = matches!(
(me.canonicalize(), target.canonicalize()),
(Ok(a), Ok(b)) if a == b
);
if !already_there {
std::fs::copy(&me, &target)
.map_err(|e| format!("cannot install to {}: {e}", target.display()))?;
make_executable(&target).map_err(|e| format!("cannot chmod {}: {e}", target.display()))?;
}
let installed = target.to_string_lossy().into_owned();
println!("{} installed {}", valid_sign(), highlight(&installed));
Ok(installed)
}
fn populate_template_dir(binary: &str, force: bool) -> Result<(), String> {
let dir = template_hooks_dir();
let _ = std::fs::create_dir_all(&dir);
let shown = dir.canonicalize().unwrap_or_else(|_| dir.clone());
let shown = shown.display();
match classify_dir(&dir) {
TemplateDir::IsCheckout => {
println!(
"{} template dir IS the checkout ({shown}) — nothing to install.",
warning_sign()
);
println!(" Its shims keep the placeholder deliberately and resolve");
println!(" {binary} at run time. This is the intended setup.");
warn_if_unbaked_cannot_resolve(binary);
}
TemplateDir::InsideCheckout => {
println!(
"{} {shown} is inside a git checkout — leaving it alone.",
warning_sign()
);
warn_if_unbaked_cannot_resolve(binary);
}
TemplateDir::NoGit => println!(
"{} git is not on PATH — refusing to delete anything.",
warning_sign()
),
TemplateDir::Unresolvable => {
println!("{} cannot resolve {shown} — skipping.", warning_sign())
}
TemplateDir::Safe => {
let written = write_shims(&dir, binary, force)
.map_err(|e| format!("cannot write shims to {shown}: {e}"))?;
println!("{} wrote {} shims to {shown}", valid_sign(), written.len());
report_overwrites(&written);
}
}
Ok(())
}
fn report_overwrites(written: &[Written]) {
for w in written {
if matches!(w.replaced, HookFile::Absent | HookFile::Ours) {
continue;
}
println!(
"{} overwrote {} — it was {}",
warning_sign(),
w.path.display(),
w.replaced.describe()
);
}
}
fn repo_hooks_dir() -> Option<PathBuf> {
crate::git::stdout(&["rev-parse", "--path-format=absolute", "--git-path", "hooks"])
.map(PathBuf::from)
}
fn bake_repo_hooks(binary: &str, force: bool) -> Result<(), String> {
let Some(hooks) = repo_hooks_dir() else {
println!(
"{} not inside a git repository — no repo hooks written.",
warning_sign()
);
return Ok(());
};
let _ = std::fs::create_dir_all(&hooks);
let foreign = foreign_hooks(&hooks);
if !foreign.is_empty() && !force {
let mut msg = format!(
"{} {} already has hooks that are not ours:",
error_sign(),
hooks.display()
);
for (name, what) in &foreign {
msg.push_str(&format!("\n {name} — {}", what.describe()));
}
msg.push_str("\n Look at them first, then `amont install --force`.");
return Err(msg);
}
let written = write_shims(&hooks, binary, force)
.map_err(|e| format!("cannot write shims to {}: {e}", hooks.display()))?;
println!(
"{} baked {} shims into {}",
valid_sign(),
written.len(),
hooks.display()
);
report_overwrites(&written);
Ok(())
}
pub fn uninstall(remove_binary: bool) -> Result<(), String> {
uninstall_template_dir()?;
uninstall_repo_hooks()?;
if remove_binary {
let target = bin_dir().join(installed_name());
match std::fs::remove_file(&target) {
Ok(()) => println!(
"{} removed {}",
valid_sign(),
highlight(&target.to_string_lossy())
),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(format!("cannot remove {}: {e}", target.display())),
}
}
report_global_template_dir();
println!(" hook.skip and amont.severity were not touched.");
Ok(())
}
fn uninstall_repo_hooks() -> Result<(), String> {
let Some(hooks) = repo_hooks_dir() else {
println!(
"{} not inside a git repository — no repo hooks removed.",
warning_sign()
);
return Ok(());
};
let mut removed = 0usize;
let mut left: Vec<String> = Vec::new();
for name in DISPATCHERS {
let path = hooks.join(name);
match hookfile::classify(&path) {
HookFile::Absent => {}
HookFile::Ours => match hookfile::guard_remove(&path, true) {
Ok(()) => {
hookfile::remove_regular(&path)
.map_err(|e| format!("cannot remove {}: {e}", path.display()))?;
removed += 1;
}
Err(r) => left.push(r.explain()),
},
what => left.push(format!("{name} — {}", what.describe())),
}
}
println!(
"{} removed {removed} shims from {}",
valid_sign(),
hooks.display()
);
for reason in &left {
println!("{} left alone: {reason}", warning_sign());
}
Ok(())
}
fn uninstall_template_dir() -> Result<(), String> {
let dir = template_hooks_dir();
let shown = dir.canonicalize().unwrap_or_else(|_| dir.clone());
let shown = shown.display();
match classify_dir(&dir) {
TemplateDir::IsCheckout | TemplateDir::InsideCheckout => {
println!(
"{} template dir is a git checkout ({shown}) — deleting NOTHING there.",
warning_sign()
);
println!(" Those shims are tracked files belonging to that checkout,");
println!(" not something this install put there. Remove them with git,");
println!(" or point init.templateDir somewhere else.");
}
TemplateDir::NoGit => println!(
"{} git is not on PATH — cannot tell whether {shown} is a checkout, deleting nothing.",
warning_sign()
),
TemplateDir::Unresolvable => println!(
"{} no template dir at {shown} — nothing to remove.",
warning_sign()
),
TemplateDir::Safe => {
let mut removed = 0usize;
let mut left: Vec<String> = Vec::new();
for name in DISPATCHERS {
let path = dir.join(name);
match hookfile::classify(&path) {
HookFile::Absent => {}
HookFile::Ours => match hookfile::guard_remove(&path, true) {
Ok(()) => {
hookfile::remove_regular(&path)
.map_err(|e| format!("cannot remove {}: {e}", path.display()))?;
removed += 1;
}
Err(r) => left.push(r.explain()),
},
what => left.push(format!("{name} — {}", what.describe())),
}
}
println!("{} removed {removed} shims from {shown}", valid_sign());
for reason in &left {
println!("{} left alone: {reason}", warning_sign());
}
}
}
Ok(())
}
fn report_global_template_dir() {
let Some(configured) = crate::git::stdout(&["config", "--global", "--get", "init.templateDir"])
.filter(|s| !s.is_empty())
else {
return;
};
println!();
println!(
"{} init.templateDir is still set: {}",
warning_sign(),
highlight(&configured)
);
println!(" Every `git clone` and `git init` still copies hooks from there");
println!(" into the new repository. Uninstalling this repo did not change that.");
println!(" Undo it with:");
println!(
" {}",
highlight("git config --global --unset init.templateDir")
);
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("gh-install-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).expect("mkdir");
d
}
fn git(dir: &Path, args: &[&str]) {
Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.expect("git");
}
#[test]
fn shims_on_disk_match_the_embedded_one() {
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../templates/hooks");
if !Path::new(dir).is_dir() {
println!(
"! no repository checkout here — nothing to compare the embedded shim against"
);
return;
}
for name in DISPATCHERS {
let disk = std::fs::read_to_string(Path::new(dir).join(name))
.unwrap_or_else(|e| panic!("read {name}: {e}"));
assert_eq!(disk, SHIM, "{name} differs from the embedded shim");
}
}
#[test]
fn a_directory_holding_tracked_files_is_never_safe() {
let d = tmp("tracked");
git(&d, &["init", "-q", "--template=", "."]);
git(&d, &["config", "user.email", "t@t.test"]);
git(&d, &["config", "user.name", "t"]);
std::fs::write(d.join("kept.txt"), "precious\n").expect("write");
git(&d, &["add", "-A"]);
git(&d, &["commit", "-qm", "seed"]);
assert_eq!(classify_dir(&d), TemplateDir::IsCheckout);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_worktree_is_recognised_even_though_its_path_differs() {
let d = tmp("wt-main");
git(&d, &["init", "-q", "--template=", "."]);
git(&d, &["config", "user.email", "t@t.test"]);
git(&d, &["config", "user.name", "t"]);
std::fs::write(d.join("kept.txt"), "precious\n").expect("write");
git(&d, &["add", "-A"]);
git(&d, &["commit", "-qm", "seed"]);
let wt = d.with_extension("wt");
let _ = std::fs::remove_dir_all(&wt);
git(&d, &["worktree", "add", "-q", wt.to_str().unwrap()]);
assert!(
wt.join("kept.txt").is_file(),
"worktree did not materialise"
);
assert_ne!(d.canonicalize().ok(), wt.canonicalize().ok());
assert_eq!(
classify_dir(&wt),
TemplateDir::IsCheckout,
"a worktree must be refused exactly like the main checkout"
);
let _ = std::fs::remove_dir_all(&wt);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn an_untracked_directory_inside_a_checkout_is_refused() {
let d = tmp("inside");
git(&d, &["init", "-q", "--template=", "."]);
let sub = d.join("scratch");
std::fs::create_dir_all(&sub).expect("mkdir");
assert_eq!(classify_dir(&sub), TemplateDir::InsideCheckout);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn an_ordinary_directory_is_safe() {
let d = tmp("plain");
assert_eq!(classify_dir(&d), TemplateDir::Safe);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_missing_directory_is_unresolvable_not_safe() {
assert_eq!(
classify_dir(Path::new("/nonexistent-install-c8f2/hooks")),
TemplateDir::Unresolvable
);
}
#[test]
fn baking_is_total_and_idempotent() {
let once = bake(SHIM, "/opt/amont");
assert!(!once.contains(PLACEHOLDER), "a token survived baking");
assert!(once.contains("/opt/amont"));
assert_eq!(bake(&once, "/other"), once, "re-baking must be a no-op");
}
#[test]
fn baking_does_not_rewrite_the_comment_explaining_it() {
for line in bake(SHIM, "/opt/amont").lines() {
if line.trim_start().starts_with('#') {
assert!(
!line.contains("/opt/amont"),
"baking rewrote a comment: {line}"
);
}
}
}
#[test]
fn only_an_absolute_path_is_bakeable() {
for good in [
"/opt/amont",
"/home/u/.local/bin/amont",
"C:/Users/u/amont.exe",
"C:\\Users\\u\\amont.exe",
] {
assert!(is_bakeable(good), "{good} should be bakeable");
}
for bad in [
"",
PLACEHOLDER,
"amont",
"./amont",
"../amont",
"target/debug/amont",
"C:amont.exe",
] {
assert!(!is_bakeable(bad), "{bad:?} must not be bakeable");
}
}
#[test]
fn the_shim_never_tests_the_placeholder_as_a_path() {
assert!(
!SHIM.contains(&format!("[ -x \"{PLACEHOLDER}\" ]")),
"the shim tests the raw token as a path"
);
assert!(
SHIM.contains("case \"$BAKED\" in"),
"the shim lost its absoluteness guard"
);
}
#[test]
fn write_shims_refuses_a_relative_binary_path() {
let d = tmp("relative");
let err = write_shims(&d, "target/debug/amont", false).expect_err("must refuse");
assert!(err.to_string().contains("absolute"), "{err}");
for name in DISPATCHERS {
assert!(!d.join(name).exists(), "{name} was written anyway");
}
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn writing_shims_covers_every_dispatcher() {
let d = tmp("write");
let written = write_shims(&d, "/opt/amont", false).expect("write");
assert_eq!(written.len(), DISPATCHERS.len());
for name in DISPATCHERS {
let got = std::fs::read_to_string(d.join(name)).expect("read");
assert!(!got.contains(PLACEHOLDER), "{name} was written unbaked");
assert!(got.contains("/opt/amont"), "{name} has no path");
}
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn one_refusal_writes_nothing_at_all() {
let d = tmp("all-or-nothing");
let theirs = d.join("prepare-commit-msg");
std::fs::write(&theirs, "#!/bin/sh\necho MINE\n").expect("write");
let err = write_shims(&d, "/opt/amont", false).expect_err("must refuse");
assert!(
matches!(err, ShimWriteError::Refused(ref rs) if rs.len() == 1),
"{err}"
);
for name in ["commit-msg", "pre-commit", "pre-push"] {
assert!(
!d.join(name).exists(),
"{name} was written despite a refusal elsewhere"
);
}
assert_eq!(
std::fs::read_to_string(&theirs).expect("read"),
"#!/bin/sh\necho MINE\n"
);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_refusal_names_the_reason_for_each_hook() {
let d = tmp("named");
std::fs::write(d.join("commit-msg"), [0x7f, b'E', b'L', b'F', 0xff]).expect("write");
std::fs::write(d.join("pre-commit"), "#!/bin/sh\necho mine\n").expect("write");
let err = write_shims(&d, "/opt/amont", false).expect_err("must refuse");
let text = err.to_string();
assert!(text.contains("not valid UTF-8"), "{text}");
assert!(text.contains("commit-msg"), "{text}");
assert!(text.contains("pre-commit"), "{text}");
let foreign = foreign_hooks(&d);
assert_eq!(foreign.len(), 2, "{foreign:?}");
assert!(foreign
.iter()
.any(|(n, w)| *n == "commit-msg" && matches!(w, HookFile::Foreign(_))));
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn force_reports_what_each_write_replaced() {
let d = tmp("force-report");
std::fs::write(d.join("commit-msg"), "#!/bin/sh\necho mine\n").expect("write");
let written = write_shims(&d, "/opt/amont", true).expect("force must write");
let replaced: Vec<_> = written
.iter()
.filter(|w| !matches!(w.replaced, HookFile::Absent))
.collect();
assert_eq!(replaced.len(), 1, "{written:?}");
assert!(replaced[0].path.ends_with("commit-msg"));
assert!(matches!(replaced[0].replaced, HookFile::Foreign(_)));
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn the_installed_name_keeps_the_platform_suffix() {
assert_eq!(
name_for(Path::new("/w/target/release/amont.exe")),
"amont.exe"
);
assert_eq!(name_for(Path::new("/u/target/release/amont")), "amont");
assert_eq!(name_for(Path::new("/some.dir/amont")), "amont");
}
}