Skip to main content

amont_runtime/
install.rs

1//! `amont install` — put the binary somewhere stable and wire up the shims.
2//!
3//! This was a Makefile recipe. It moved here for one reason: the guard below
4//! decides whether a directory is safe to delete, it has been got wrong TWICE —
5//! both times overwriting tracked source files with machine-specific paths — and
6//! shell that runs on one platform cannot be tested on three.
7//!
8//! Everything here is `std`. The commit path's dependency posture
9//! (`scripts/check-no-deps.sh`) is unchanged: this adds code, not crates.
10//!
11//! ## Why it is a subcommand and not a script
12//!
13//! A `.ps1` for Windows plus a Makefile for Unix would be two implementations of
14//! that guard, in two languages, one of them untested — for a routine whose
15//! failure mode is deleting your work. And `make` is not the Unix-only detail it
16//! looks like: Git for Windows ships `bash`, `sh` and coreutils but NOT `make`,
17//! so the dependency was the problem rather than the shell.
18//!
19//! The shim text is embedded with `include_str!`, so an installed binary carries
20//! its own shims and can install from any directory.
21
22use std::path::{Path, PathBuf};
23use std::process::Command;
24
25use crate::hookfile::{self, HookFile, Refuse, Staged, SwapFailure};
26use crate::ui::{error_sign, highlight, valid_sign, warning_sign};
27
28/// The token every shim carries until it is baked.
29pub const PLACEHOLDER: &str = "__AMONT_BIN__";
30
31/// The one shim. All four git-invoked hooks are the same file — it passes its
32/// own filename through — and `shims_on_disk_match_the_embedded_one` keeps the
33/// repository's `templates/hooks/` honest against this copy.
34///
35/// The canonical text lives INSIDE this crate, and that is a packaging
36/// constraint rather than a preference. It used to be
37/// `include_str!("../../../templates/hooks/pre-commit")`, reaching up to the
38/// repository root — which works in a checkout and cannot work in a published
39/// crate, because `cargo package` tars up this directory and nothing above it.
40/// The tarball compiled nowhere: `couldn't read src/../../../templates/hooks/
41/// pre-commit`. crates.io is immutable, so that would have been a broken
42/// release that could only be yanked, never fixed in place.
43///
44/// The repository's `templates/hooks/` still holds the four installable copies
45/// — that directory IS the product for anyone pointing `init.templateDir` at a
46/// clone, and it has to be real files rather than symlinks because Git for
47/// Windows materialises those as text files containing a path.
48pub const SHIM: &str = include_str!("../templates/hooks/pre-commit");
49
50/// The ownership question, and every other "may we touch this file?" answer,
51/// now live in [`crate::hookfile`] — one implementation that fails closed,
52/// rather than the three `unwrap_or(false)` one-liners that used to answer it
53/// here, in `fleet::scan` and in `fleet::fix`.
54///
55/// Re-exported rather than moved outright because both fleet call sites and the
56/// dashboard's `shim` module name them through this path, and a rename would be
57/// churn in files this change has no business editing.
58pub use crate::hookfile::{is_our_shim, SHIM_MARKER};
59
60/// The hook names git actually invokes, and so the only files we install.
61pub const DISPATCHERS: [&str; 6] = [
62    "commit-msg",
63    "post-commit",
64    "post-rewrite",
65    "pre-commit",
66    "pre-push",
67    "prepare-commit-msg",
68];
69
70/// What may be done with a candidate template directory.
71///
72/// `~/.config/git/git-templates` is commonly a SYMLINK to a checkout of this
73/// repository, in which case "installing" there means deleting and overwriting
74/// TRACKED files.
75///
76/// Comparing the path against the source tree is NOT enough, and that is the
77/// mistake that caused both incidents: run the install from a git worktree and
78/// the two resolve to different paths — a different checkout of the same repo —
79/// so a path comparison says "not the source" and clobbers the main checkout.
80/// Asking git is the reliable test whatever route the symlink took.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub enum TemplateDir {
83    /// The path does not exist and could not be created.
84    Unresolvable,
85    /// No git to ask. Refuse rather than guess.
86    NoGit,
87    /// It holds tracked files: it IS a checkout. Nothing to install — `git init`
88    /// already reads its templates from there, and the shims keep their
89    /// placeholder and resolve the binary at run time.
90    IsCheckout,
91    /// Inside a checkout but tracking nothing here. Still not ours to empty.
92    InsideCheckout,
93    /// An ordinary directory. Safe to populate.
94    Safe,
95}
96
97fn git_ok(dir: &Path, args: &[&str]) -> bool {
98    Command::new("git")
99        .arg("-C")
100        .arg(dir)
101        .args(args)
102        .stdout(std::process::Stdio::null())
103        .stderr(std::process::Stdio::null())
104        .status()
105        .map(|s| s.success())
106        .unwrap_or(false)
107}
108
109/// Decide what may be done with `dir`. Never mutates anything.
110pub fn classify_dir(dir: &Path) -> TemplateDir {
111    let Ok(real) = dir.canonicalize() else {
112        return TemplateDir::Unresolvable;
113    };
114    if Command::new("git").arg("--version").output().is_err() {
115        return TemplateDir::NoGit;
116    }
117    // `ls-files --error-unmatch .` is the question that matters: does git track
118    // anything HERE? A directory can be inside a checkout and still be
119    // untracked scratch space, which the next test separates.
120    if git_ok(&real, &["ls-files", "--error-unmatch", "."]) {
121        return TemplateDir::IsCheckout;
122    }
123    if git_ok(&real, &["rev-parse", "--git-dir"]) {
124        return TemplateDir::InsideCheckout;
125    }
126    TemplateDir::Safe
127}
128
129/// Write the absolute binary path into a shim.
130///
131/// A plain global replace, which is why the shim's own comment must not spell
132/// the token out — it did, and every baked shim carried an "explanation" whose
133/// text was a machine path. Idempotent: re-baking a baked shim is a no-op
134/// because the token is gone.
135pub fn bake(shim: &str, bin: &str) -> String {
136    shim.replace(PLACEHOLDER, bin)
137}
138
139/// Whether the shim will accept `bin` as its baked path.
140///
141/// The shim rejects anything relative before it ever touches the filesystem,
142/// and this is the same rule stated where the value is produced. Git runs a
143/// hook with the working tree as the current directory, so a relative baked
144/// path is a question asked of the REPOSITORY — and a clone that ships a file
145/// by that name gets to answer it. Baking one would install a hook that either
146/// cannot resolve its binary or resolves it to somebody else's, so refuse here
147/// too, where the message can say which path was wrong.
148///
149/// Absolute means POSIX `/…` or a Windows drive path (`C:\…`, `C:/…`).
150pub fn is_bakeable(bin: &str) -> bool {
151    if bin.is_empty() || bin == PLACEHOLDER {
152        return false;
153    }
154    let b = bin.as_bytes();
155    if b[0] == b'/' {
156        return true;
157    }
158    b.len() > 2 && b[0].is_ascii_alphabetic() && b[1] == b':' && (b[2] == b'/' || b[2] == b'\\')
159}
160
161/// An absolute form of `p`, for baking.
162///
163/// NOT `canonicalize`: that returns an extended-length path (`\\?\C:\…`) on
164/// Windows, which `sh` cannot test, and it fails outright on a path that does
165/// not exist yet. `$AMONT_BIN_DIR` may be relative, which is the route by
166/// which a relative path could reach a shim at all.
167fn absolute(p: &Path) -> PathBuf {
168    if p.is_absolute() {
169        return p.to_path_buf();
170    }
171    match std::env::current_dir() {
172        Ok(cwd) => cwd.join(p),
173        Err(_) => p.to_path_buf(),
174    }
175}
176
177/// The one directory an UNBAKED shim looks in, hardcoded in the shim itself.
178///
179/// Deliberately NOT `bin_dir()`, and the difference is the whole point of
180/// `warn_if_unbaked_cannot_resolve`. `bin_dir()` answers "where should install
181/// PUT the binary?" and honours `$AMONT_BIN_DIR`. This answers "where will a
182/// shim that never got a path baked into it LOOK?", which is a constant in a
183/// POSIX sh file and cannot be configured at all.
184///
185/// `$AMONT_BIN_DIR` is deliberately not wired into the shim to close that
186/// gap. It is an install-time question answered in the shell where `amont
187/// install` ran; the shim runs inside git's environment during a commit, where
188/// that variable is almost never set — so honouring it there would ship a knob
189/// that looks like it works and silently does not. The runtime override already
190/// exists and is `$GIT_HOOKS_BIN`; a second variable able to redirect which
191/// binary executes on every commit would double that surface for nothing.
192///
193/// Not "XDG", either: the XDG Base Directory spec defines no binary directory.
194/// `~/.local/bin` is simply the widely-observed convention.
195fn unbaked_lookup_dir() -> PathBuf {
196    home().join(".local").join("bin")
197}
198
199/// Say so when the binary went somewhere an unbaked shim will never look.
200///
201/// Two supported choices stop composing when made together. A template dir that
202/// IS the checkout keeps the placeholder on purpose — those shims resolve the
203/// binary at run time from [`unbaked_lookup_dir`]. Install to a custom
204/// `$AMONT_BIN_DIR` as well and nothing baked a path, while the one path the
205/// shim knows is not where the binary went.
206///
207/// Nothing is silently skipped — the shim prints what it looked at and exits 1,
208/// which fails the commit loudly. But it fails at somebody's next commit, in a
209/// repository they have not thought about since, and the cause is a decision
210/// made here. So it is said here.
211fn warn_if_unbaked_cannot_resolve(binary: &str) {
212    let looked = unbaked_lookup_dir();
213    let placed = Path::new(binary).parent();
214
215    // Compare RESOLVED paths, and require both to resolve. `a.ok() == b.ok()`
216    // would read `None == None` as "the same directory" — the shape of a bug
217    // this module has already had once, in `already_there`.
218    let reachable = placed.is_some_and(|p| {
219        matches!(
220            (p.canonicalize(), looked.canonicalize()),
221            (Ok(a), Ok(b)) if a == b
222        )
223    });
224    if reachable {
225        return;
226    }
227
228    println!();
229    println!(
230        "{} the binary is at {}, which an unbaked shim will not find.",
231        warning_sign(),
232        highlight(binary)
233    );
234    println!(
235        "    Shims here keep the placeholder, and they look only in {}.",
236        looked.display()
237    );
238    println!("    Either link it where they look:");
239    println!("      ln -s {} {}", binary, looked.join("amont").display());
240    println!("    or set GIT_HOOKS_BIN in the environment git runs hooks with:");
241    println!("      export GIT_HOOKS_BIN={binary}");
242}
243
244/// `~/.local/bin`, or `$AMONT_BIN_DIR`.
245pub fn bin_dir() -> PathBuf {
246    if let Some(d) = std::env::var_os("AMONT_BIN_DIR") {
247        return PathBuf::from(d);
248    }
249    home().join(".local").join("bin")
250}
251
252/// `$XDG_CONFIG_HOME/git/git-templates/templates/hooks`.
253pub fn template_hooks_dir() -> PathBuf {
254    let base = std::env::var_os("XDG_CONFIG_HOME")
255        .map(PathBuf::from)
256        .unwrap_or_else(|| home().join(".config"));
257    base.join("git")
258        .join("git-templates")
259        .join("templates")
260        .join("hooks")
261}
262
263fn home() -> PathBuf {
264    std::env::var_os("HOME")
265        .or_else(|| std::env::var_os("USERPROFILE"))
266        .map(PathBuf::from)
267        .unwrap_or_else(|| PathBuf::from("."))
268}
269
270/// The name to install under. Windows builds `amont.exe`, and a shim testing
271/// `[ -x .../amont ]` is false for it.
272fn installed_name() -> String {
273    match std::env::current_exe() {
274        Ok(p) => name_for(&p),
275        Err(_) => "amont".to_string(),
276    }
277}
278
279/// Split from `installed_name` so it can be tested on every platform rather
280/// than only the one that produces a `.exe`. A `cfg!(windows)` assertion is
281/// vacuous on the machine most of this is written on.
282fn name_for(exe: &Path) -> String {
283    match exe.extension().and_then(|e| e.to_str()) {
284        Some(e) if !e.is_empty() => format!("amont.{e}"),
285        _ => "amont".to_string(),
286    }
287}
288
289/// Hook files in `dir` that exist and are NOT ours, each with its reason.
290///
291/// `install` used to write all four unconditionally, which silently destroyed a
292/// `commit-msg` somebody had written themselves. That is the same failure as the
293/// two that overwrote tracked files, one directory along, and it had no guard at
294/// all — the fleet's `fix` planner has one and the per-repo installer never did.
295///
296/// It carries the [`HookFile`] rather than only the name because "commit-msg is
297/// not ours" sends somebody to diff a file against a shim they have never seen,
298/// while "commit-msg is not valid UTF-8 — a compiled hook, probably" ends the
299/// question. The old version could not have said either: it read the file as a
300/// string, and a file it could not read came back as NOT foreign.
301fn foreign_hooks(dir: &Path) -> Vec<(&'static str, HookFile)> {
302    DISPATCHERS
303        .into_iter()
304        .map(|name| (name, hookfile::classify(&dir.join(name))))
305        .filter(|(_, what)| !matches!(what, HookFile::Absent | HookFile::Ours))
306        .collect()
307}
308
309/// One dispatcher that landed, and what stood at that path before it.
310///
311/// `replaced` exists so `--force` can say what it took. It printed
312/// "baked 4 shims" and nothing else, which is a receipt for an act whose whole
313/// point is that it destroys something — the user typed `--force` precisely
314/// because there was a file there, and the one thing the output never said was
315/// which files or what they were.
316#[derive(Debug)]
317pub struct Written {
318    pub path: PathBuf,
319    pub replaced: HookFile,
320}
321
322/// Why a set of shims was not written.
323#[derive(Debug)]
324pub enum ShimWriteError {
325    /// One or more paths are not ours to write. Every refusal is carried, not
326    /// just the first: somebody about to run `--force` should see all four.
327    Refused(Vec<Refuse>),
328    /// A failure before any destination was touched — an unbakeable path, or a
329    /// staging write that could not happen (no space, no permission).
330    Preflight { at: PathBuf, error: std::io::Error },
331    /// Staging succeeded and a rename did not. The only failure mode that can
332    /// leave a directory partly written, which is why it carries the lists.
333    Swap(SwapFailure),
334}
335
336impl std::fmt::Display for ShimWriteError {
337    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
338        match self {
339            ShimWriteError::Refused(refusals) => {
340                writeln!(f, "refusing to write {} hooks:", refusals.len())?;
341                for (i, r) in refusals.iter().enumerate() {
342                    if i > 0 {
343                        writeln!(f)?;
344                    }
345                    write!(f, "    {}", r.explain())?;
346                }
347                Ok(())
348            }
349            ShimWriteError::Preflight { at, error } => {
350                write!(f, "cannot prepare {}: {error}", at.display())
351            }
352            ShimWriteError::Swap(s) => write!(f, "{s}"),
353        }
354    }
355}
356
357/// Write the four dispatchers into `dir`: guard ALL, stage ALL, then swap.
358///
359/// Three phases, in that order, because the posture `bake_repo_hooks` has
360/// claimed since it was written — "fail closed, and for the whole repository
361/// rather than per file: a partial install is how a repo ends up with two of
362/// four hooks and no way to tell" — was a comment over a loop that checked one
363/// file and then wrote it, four times. A refusal on the third hook came after
364/// two had already been overwritten.
365///
366/// Now: every path is guarded before any body is written, and every body is
367/// written before any destination is touched. A refusal anywhere means nothing
368/// at all was written; a staging failure likewise. Only the swap can leave a
369/// directory partly done, and that is reported as exactly which files landed
370/// and which did not (see [`SwapFailure`]) rather than as a count.
371///
372/// Returns what was written and what each write replaced, so `--force` can name
373/// what it took.
374fn write_shims(dir: &Path, bin: &str, force: bool) -> Result<Vec<Written>, ShimWriteError> {
375    // Fail closed rather than write four hooks that resolve to nothing — or, if
376    // the repository happens to hold a file by that name, to something.
377    if !is_bakeable(bin) {
378        return Err(ShimWriteError::Preflight {
379            at: dir.to_path_buf(),
380            error: std::io::Error::other(format!(
381                "refusing to bake {bin:?}: the shim takes an absolute path only"
382            )),
383        });
384    }
385    let baked = bake(SHIM, bin);
386
387    // Phase 1 — guard every path. Nothing has been written and nothing will be
388    // if a single one of these refuses.
389    let mut allowed: Vec<(PathBuf, HookFile)> = Vec::new();
390    let mut refusals: Vec<Refuse> = Vec::new();
391    for name in DISPATCHERS {
392        let path = dir.join(name);
393        match hookfile::guard_write(&path, force) {
394            Ok(what) => allowed.push((path, what)),
395            Err(r) => refusals.push(r),
396        }
397    }
398    if !refusals.is_empty() {
399        return Err(ShimWriteError::Refused(refusals));
400    }
401
402    // Phase 2 — stage every body. A `Staged` that never lands removes its own
403    // temporary on drop, so an error here leaves the directory as it was.
404    let mut staged: Vec<Staged> = Vec::new();
405    for (path, _) in &allowed {
406        match hookfile::stage(path, &baked, true) {
407            Ok(s) => staged.push(s),
408            Err(error) => {
409                return Err(ShimWriteError::Preflight {
410                    at: path.clone(),
411                    error,
412                });
413            }
414        }
415    }
416
417    // Phase 3 — swap. Renames, so a symlinked destination is REPLACED rather
418    // than written through.
419    hookfile::commit_all(staged).map_err(ShimWriteError::Swap)?;
420    Ok(allowed
421        .into_iter()
422        .map(|(path, replaced)| Written { path, replaced })
423        .collect())
424}
425
426/// For the installed BINARY only — shims get their mode from `hookfile::stage`,
427/// before they are anywhere a hook could be dispatched from.
428#[cfg(unix)]
429fn make_executable(p: &Path) -> std::io::Result<()> {
430    use std::os::unix::fs::PermissionsExt;
431    std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o755))
432}
433
434#[cfg(not(unix))]
435fn make_executable(_p: &Path) -> std::io::Result<()> {
436    Ok(()) // Windows has no execute bit; git runs the shim through sh regardless.
437}
438
439/// Install: copy this binary somewhere stable, populate the template directory
440/// if that is safe, and bake the current repository's hooks.
441///
442/// Three steps, three functions. This was one 88-line body whose own comments
443/// numbered its sections — which is the tell that the sections wanted to be
444/// functions.
445pub fn run(settings: &crate::config::Settings, force: bool) -> Result<(), String> {
446    let binary = install_binary()?;
447    populate_template_dir(&binary, force)?;
448    bake_repo_hooks(&binary, force)?;
449    offer_trust();
450    offer_agents_md(settings);
451    point_at_setup(settings);
452    Ok(())
453}
454
455/// `amont enroll` — the machine-level standing grant, made one command.
456///
457/// The team-rollout problem in one sentence: hooks only protect the machines
458/// that installed them, and only npm repositories can self-install on clone.
459/// `enroll` is the other half — run ONCE per machine, it arranges for every
460/// FUTURE `git clone` and `git init` to arrive with the shims already baked:
461///
462///   1. the binary lands somewhere stable (`install_binary`'s rules);
463///   2. the template dir is populated (`populate_template_dir`'s guards);
464///   3. `init.templateDir` is pointed at it — the one write `install` always
465///      left to the user, because a standing grant should be typed, not
466///      inherited. `enroll` IS that typing.
467///
468/// With `--conventions declared` it also writes `amont.conventions`, so the
469/// grant is safe on a machine that clones other people's projects: those get
470/// the safety net only, and the house rules wait for a committed
471/// `amont.conf`. See `dispatch::conventions_apply`.
472///
473/// Existing clones are deliberately untouched — a standing grant reaches
474/// forward, not backward. The output names the two remedies.
475pub fn enroll(conventions: Option<&str>) -> Result<(), String> {
476    // Validate the flag BEFORE writing anything: an enroll that half-runs
477    // and then rejects its own argument has still changed global state.
478    match conventions {
479        None | Some("declared") | Some("everywhere") => {}
480        Some(other) => {
481            return Err(format!(
482                "amont enroll --conventions takes `declared` or `everywhere`, not {other:?}"
483            ));
484        }
485    }
486    let binary = install_binary()?;
487    populate_template_dir(&binary, false)?;
488    let hooks = template_hooks_dir();
489    let templates = hooks
490        .parent()
491        .ok_or_else(|| "template hooks dir has no parent".to_string())?
492        .to_path_buf();
493    point_template_dir_at(&templates)?;
494
495    match conventions {
496        Some(word) => {
497            if !crate::git::succeeds(&["config", "--global", "amont.conventions", word]) {
498                return Err("could not write amont.conventions to the global git config".into());
499            }
500            println!(
501                "{} amont.conventions = {} (global)",
502                valid_sign(),
503                highlight(word)
504            );
505            if word == "declared" {
506                println!("    House rules run only in repositories that commit an amont.conf;");
507                println!("    the safety net (conflicts, secrets, size, debug leftovers) runs");
508                println!("    everywhere.");
509            }
510        }
511        None => {
512            println!(
513                "{} conventions currently apply everywhere. On a machine that also",
514                warning_sign()
515            );
516            println!("    clones other people's projects, consider:");
517            println!(
518                "        {}",
519                highlight("amont enroll --conventions declared")
520            );
521        }
522    }
523
524    println!();
525    println!("Enrolled. Every future `git clone` and `git init` gets the hooks.");
526    println!("Repositories cloned before now are untouched — wire them with");
527    println!("    {}   (one repository)", highlight("amont init"));
528    println!(
529        "    {}   (every repository under a root)",
530        highlight("amont-fleet install --root <dir>")
531    );
532    println!(
533        "Undo with {}.",
534        highlight("git config --global --unset init.templateDir")
535    );
536    Ok(())
537}
538
539/// Point `init.templateDir` at `templates` — or refuse, loudly, when it
540/// already points somewhere else. Overwriting it would silently disable
541/// whatever the user's OTHER template dir was installing, which is the exact
542/// shape of failure the husky refusal exists to prevent, one level up.
543fn point_template_dir_at(templates: &std::path::Path) -> Result<(), String> {
544    let want = templates.to_string_lossy().into_owned();
545    let current = crate::git::stdout(&["config", "--global", "--get", "init.templateDir"])
546        .filter(|s| !s.is_empty());
547    match current {
548        None => {
549            if !crate::git::succeeds(&["config", "--global", "init.templateDir", &want]) {
550                return Err("could not write init.templateDir to the global git config".into());
551            }
552            println!(
553                "{} init.templateDir = {} (global)",
554                valid_sign(),
555                highlight(&want)
556            );
557            Ok(())
558        }
559        Some(existing) if same_dir(&existing, &want) => {
560            println!(
561                "{} init.templateDir already points here ({})",
562                valid_sign(),
563                highlight(&existing)
564            );
565            Ok(())
566        }
567        Some(existing) => Err(format!(
568            "init.templateDir is already set to {existing} — something else installs
569             hooks on this machine. Overwriting it would silently disable that.
570             Decide which one wins, then either unset it
571             (git config --global --unset init.templateDir) and re-run
572             `amont enroll`, or leave enrollment to it."
573        )),
574    }
575}
576
577/// The same directory, spelled two ways: canonicalize both when possible so
578/// a symlinked config dir still counts as "already ours".
579fn same_dir(a: &str, b: &str) -> bool {
580    if a == b {
581        return true;
582    }
583    match (
584        std::path::Path::new(a).canonicalize(),
585        std::path::Path::new(b).canonicalize(),
586    ) {
587        (Ok(x), Ok(y)) => x == y,
588        _ => false,
589    }
590}
591
592/// `amont init` — wire up THIS repository, and touch nothing else.
593///
594/// The verb a package manager can call. `"prepare": "amont init"` in a
595/// `package.json` means a teammate who clones and runs `npm install` gets the
596/// hooks, which is the ergonomic husky has and this project did not.
597///
598/// ## Why `install` could not be that verb
599///
600/// Every one of its three extra steps is wrong for something that runs on every
601/// teammate's install, and the third is a hang:
602///
603///   * `install_binary` copies into `~/.local/bin` — a machine-level write from
604///     `npm install`;
605///   * `populate_template_dir` writes `~/.config/git/git-templates`, so a
606///     package manager would be arranging for every FUTURE clone to get hooks;
607///   * `offer_trust` calls `trust::confirm`, which opens `/dev/tty`. In a
608///     terminal that succeeds and BLOCKS, so `npm install` would stop dead on a
609///     prompt about a manifest the user has not read.
610///
611/// So this does one thing: bake four shims into the repository's own hooks
612/// directory.
613///
614/// ## What it bakes, and why not the PATH hit
615///
616/// `current_exe()`, always — never [`install_binary`]'s "is it already on
617/// `PATH`?" branch. Under npm the answer to that question is
618/// `node_modules/.bin/amont`, which is the **JS wrapper**: baking it would put a
619/// node process in front of every single commit, on a tool whose start-up cost
620/// is a stated feature. `current_exe()` is the native binary inside the platform
621/// package, which is what should run.
622///
623/// ## Silence, and its limits
624///
625/// Outside a git repository this reports nothing and exits 0. `npm install`
626/// legitimately runs where there is no `.git` — from a tarball, inside a Docker
627/// build, in CI — and failing there would make the package uninstallable in all
628/// three. It stays LOUD about everything else: a redirect, an unwritable
629/// directory, a foreign hook, and a repository git REFUSES to answer about
630/// (dubious ownership in a container bind mount, an unreadable `.git/config`)
631/// all still fail, because those are repositories where somebody believes they
632/// have hooks and does not. The silence is git's verdict, never git's absence.
633///
634/// ## Inside a push snapshot, it stands down
635///
636/// A push snapshot is a linked worktree, so it SHARES this repository's hooks
637/// directory — and its dependency install runs `prepare`, which runs this.
638/// Baking there pointed every hook at a binary inside a temp directory that
639/// is deleted minutes later. The snapshot marks itself in its own git admin
640/// dir ([`crate::pushed_tree::in_push_snapshot`]); when the mark is there,
641/// nothing is written. When it cannot be TOLD whether the mark is there, it
642/// fails rather than guess: a wrong guess is exactly the bake this prevents.
643pub fn init() -> Result<(), String> {
644    init_with(
645        std::env::current_exe(),
646        Path::new("."),
647        &crate::pushed_tree::in_push_snapshot,
648    )
649}
650
651/// [`init`] with what it would otherwise read from the process — the binary
652/// to bake, the directory to ask about, and the snapshot probe — passed in,
653/// so a test can reach every branch without `set_current_dir`.
654fn init_with(
655    me: std::io::Result<PathBuf>,
656    dir: &Path,
657    in_snapshot: &dyn Fn(&Path) -> std::io::Result<bool>,
658) -> Result<(), String> {
659    let rh = repo_hooks_in(dir);
660    // The one silent exit, and the only one: git itself said this is not a
661    // repository.
662    if matches!(rh, RepoHooks::Nowhere) {
663        return Ok(());
664    }
665    // BEFORE the redirect and `Unanswerable` arms: a snapshot's install must
666    // never fail `prepare` over hook config it is not going to touch anyway.
667    match in_snapshot(dir) {
668        Ok(true) => {
669            eprintln!(
670                "amont init: inside an amont push snapshot — the repository's hooks are left as they are"
671            );
672            return Ok(());
673        }
674        Ok(false) => {}
675        // git would not answer either question: its own reason, below, says
676        // more than ours would. Still a failure — nothing is written.
677        Err(_) if matches!(rh, RepoHooks::Unanswerable { .. }) => {}
678        Err(e) => {
679            return Err(format!(
680                "{} cannot tell whether this is an amont push snapshot — {e}\n    \
681                 Hooks were NOT installed.",
682                error_sign()
683            ))
684        }
685    }
686    let hooks = match rh {
687        RepoHooks::Own(dir) => dir,
688        RepoHooks::Redirected { to, own } if redirect_is_hostile(&to, &own) => {
689            return Err(redirected_message(&to, &own))
690        }
691        RepoHooks::Redirected { to, .. } => to,
692        RepoHooks::Nowhere => return Ok(()),
693        RepoHooks::Unanswerable { why } => {
694            return Err(format!(
695                "{} git would not say where this repository's hooks live —\n    {}\n    \
696                 Hooks were NOT installed.",
697                error_sign(),
698                crate::ui::sanitize(&why)
699            ))
700        }
701    };
702
703    let me = me.map_err(|e| format!("cannot locate the running binary: {e}"))?;
704    // `absolute` rather than `canonicalize`: the shim needs an absolute path
705    // and nothing more, so there is no reason to touch the filesystem again.
706    //
707    // It does NOT keep us off pnpm's `.pnpm/<pkg>@<version>/…` store path, and
708    // an earlier version of this comment claimed it did. By the time this runs,
709    // the JS wrapper has already resolved the binary through `require.resolve`,
710    // which returns the REAL path — and `current_exe()` resolves symlinks
711    // besides. A pnpm install bakes the versioned store path, verified.
712    //
713    // Which is fine, and worth saying why rather than leaving the next reader
714    // to worry about it: `prepare` runs on every install, so a version bump
715    // re-bakes before anything can dispatch against the old path — and if one
716    // ever does go missing, the shim's resolution order falls through to
717    // `~/.local/bin` and then `PATH`, and fails LOUDLY rather than skipping a
718    // check, which is the property that actually matters.
719    let binary = absolute(&me);
720    let binary = binary.to_string_lossy().into_owned();
721    if !is_bakeable(&binary) {
722        return Err(format!(
723            "{} cannot bake {binary:?} — the shim takes an absolute path only",
724            error_sign()
725        ));
726    }
727
728    std::fs::create_dir_all(&hooks)
729        .map_err(|e| format!("cannot create {}: {e}", hooks.display()))?;
730
731    // `force: false`. A hook somebody else wrote is theirs, and `init` runs
732    // unattended — there is no one at the keyboard to have decided otherwise.
733    let written = write_shims(&hooks, &binary, false)
734        .map_err(|e| format!("cannot write shims to {}: {e}", hooks.display()))?;
735    println!(
736        "{} amont: {} hooks in {}",
737        valid_sign(),
738        written.len(),
739        hooks.display()
740    );
741    Ok(())
742}
743
744/// Name the commit-style settings, once, at the moment somebody acquires them.
745///
746/// A PRINT, never a prompt. `install` has to remain answerable by nobody: it
747/// runs under `amont-fleet install --root`, in provisioning scripts, and not
748/// at all for the `init.templateDir` users whose hooks arrive with a clone. A
749/// third question would break all three; a line of output breaks none of them,
750/// and it puts the dial in front of the one person guaranteed to be reading.
751fn point_at_setup(settings: &crate::config::Settings) {
752    let s = crate::commit_style::Style::resolve(settings);
753    println!(
754        "  commit style: gitmoji {}, subject ≤{}, description ≤{} — `amont setup` to change",
755        s.gitmoji.as_str(),
756        s.subject_max,
757        s.description_max
758    );
759}
760
761/// Ask about the manifest, once, at the moment somebody is already deciding
762/// about this repository.
763///
764/// `direnv` has to ask lazily on `cd` because it has no install step to hang
765/// the question from. We have one — so this is a single question, shown with
766/// the declarations in view, and declining still leaves the built-ins working.
767///
768/// Never blocks and never fails the install: a repository that declares nothing
769/// says nothing, and a non-interactive install simply reports the state.
770fn offer_trust() {
771    // No repository, no manifest to ask about. `repo_root()` answered "." and
772    // this went looking for `./amont.conf` in whatever directory the
773    // install was run from — a file it would then have offered to trust ON
774    // BEHALF of a repository that does not exist.
775    let Ok(root) = crate::hooks::common::repo_root_checked() else {
776        return;
777    };
778    let root = Path::new(&root);
779    let state = crate::trust::state(root);
780    if matches!(
781        state,
782        crate::trust::State::NoManifest | crate::trust::State::Trusted
783    ) {
784        return;
785    }
786
787    println!();
788    println!(
789        "{} {} declares checks and policy that would apply to your commits:",
790        warning_sign(),
791        crate::manifest::MANIFEST
792    );
793    // Read ONCE, then show and fingerprint that same buffer. `confirm()` blocks
794    // on a keypress, sometimes for several seconds, and a file rewritten in
795    // that window must not be trusted under the guise of the content that was
796    // displayed — `record_verified` re-checks this fingerprint once the answer
797    // is in. Reading separately to show and to hash would leave the same gap
798    // one step earlier: the listing approved need not be the one recorded.
799    let manifest = root.join(crate::manifest::MANIFEST);
800    let Ok(source) = std::fs::read(&manifest) else {
801        println!(
802            "{} could not read {}",
803            warning_sign(),
804            crate::manifest::MANIFEST
805        );
806        return;
807    };
808    print!(
809        "{}",
810        crate::trust::describe_source(&String::from_utf8_lossy(&source))
811    );
812    let Some(fp) = crate::trust::fingerprint_bytes(root, &source) else {
813        println!(
814            "{} could not hash {}",
815            warning_sign(),
816            crate::manifest::MANIFEST
817        );
818        return;
819    };
820    if crate::trust::confirm("    Trust them? (y/N) ") {
821        match crate::trust::record_verified(root, &fp) {
822            Ok(()) => println!("{} trusted ({fp})", valid_sign()),
823            Err(e) => println!("{} {e}", warning_sign()),
824        }
825    } else {
826        println!("    Left untrusted. The built-ins still run; these do not.");
827        println!("    Change your mind with `amont trust`.");
828    }
829}
830
831/// Ask about `AGENTS.md`, once, right where `offer_trust` asks about the
832/// manifest — same reasoning, same shape: a single question with an install
833/// step to hang it from, and declining changes nothing about how the hooks
834/// themselves run.
835///
836/// This is the first thing `install` would write to TRACKED repo content —
837/// everything else here lives in `.git/hooks` (never tracked) or a
838/// machine-local path (`~/.local/bin`, the XDG template dir). That is exactly
839/// why it is a confirm, not a silent write: `crate::agents_md::write` is
840/// marker-scoped and safe to re-run, but "safe to overwrite" is not the same
841/// promise as "yours to write unasked."
842///
843/// Never blocks and never fails the install: skips silently when there is
844/// nothing to offer, and a non-interactive install simply leaves the
845/// question unanswered — `trust::confirm` already treats no tty as "no".
846fn offer_agents_md(settings: &crate::config::Settings) {
847    // Same reason as `offer_trust`: with `repo_root()`'s "." fallback, an
848    // install run outside a repository offered to write an AGENTS.md into the
849    // current directory — the one thing `install` writes to TRACKED content,
850    // aimed at a directory nobody said was a project.
851    let Ok(root) = crate::hooks::common::repo_root_checked() else {
852        return;
853    };
854    let path = Path::new(&root).join("AGENTS.md");
855    match crate::agents_md::check(settings, &path) {
856        Ok(crate::agents_md::CheckResult::MatchesGenerated) => return,
857        Ok(_) => {}
858        // Malformed markers: nothing this prompt can safely offer to fix.
859        Err(_) => return,
860    }
861
862    println!();
863    println!(
864        "{} AGENTS.md can point coding agents at `amont list --json` \
865         instead of leaving them to discover these checks the hard way:",
866        warning_sign()
867    );
868    if crate::trust::confirm("    Add it? (y/N) ") {
869        match crate::agents_md::write(settings, &path) {
870            Ok(()) => println!("{} wrote {}", valid_sign(), path.display()),
871            Err(e) => println!("{} {e}", warning_sign()),
872        }
873    } else {
874        println!("    Left as-is. Change your mind with `amont agents-md`.");
875    }
876}
877
878/// Where this binary can already be found on `PATH`, if it can.
879///
880/// Returns the path as `PATH` exposes it — deliberately NOT the resolved one.
881/// Homebrew's `/usr/local/bin/amont` is a symlink into
882/// `/usr/local/Cellar/amont/<version>/bin/`, and that Cellar path is
883/// version-specific and removed on upgrade. Baking it would pin every repo to a
884/// version that is about to be deleted, which is worse than the copy this
885/// function exists to avoid. The same is true of any versioned store — nix,
886/// asdf, mise.
887///
888/// So the comparison is canonical (to recognise ourselves through the symlink)
889/// while the value returned is the entry that led here. That also makes the
890/// answer correct whichever way `current_exe()` behaves: it resolves symlinks
891/// on some platforms and libcs and not others, and this never has to care.
892fn on_path_already(me: &Path) -> Option<PathBuf> {
893    let me_real = me.canonicalize().ok()?;
894    let name = installed_name();
895    let path = std::env::var_os("PATH")?;
896    std::env::split_paths(&path)
897        .map(|dir| dir.join(&name))
898        .filter(|cand| !in_a_build_dir(cand))
899        .find(|cand| cand.canonicalize().is_ok_and(|real| real == me_real))
900        .map(|cand| absolute(&cand))
901        .filter(|abs| is_bakeable(&abs.to_string_lossy()))
902}
903
904/// Whether `p` sits inside a cargo build directory.
905///
906/// "On PATH" alone is not the question — the question is whether the path will
907/// still be there tomorrow, and a build directory is precisely the one that
908/// will not. `cargo clean`, or any rebuild, and the shims baked against it
909/// resolve nothing.
910///
911/// This is not hypothetical and it is not only about `cargo run`: **cargo
912/// prepends the build directory to PATH when it runs tests on Windows**, so
913/// `target/debug` genuinely appears there. That took out four existing install
914/// tests on the Windows runner and nowhere else, which is a fair description of
915/// how the loose predicate would have failed a user, too.
916///
917/// `CACHEDIR.TAG` is cargo's own marker for the directory, written since 1.55
918/// and standardised for exactly this — "a program wrote this, do not treat it
919/// as durable". Asking for it beats matching on the name `target`, which is
920/// configurable and is also an ordinary word for a directory. Bounded to a few
921/// levels so a stray tag high up somebody's home directory cannot disqualify
922/// every path on the system.
923fn in_a_build_dir(p: &Path) -> bool {
924    p.ancestors()
925        .skip(1)
926        .take(4)
927        .any(|dir| dir.join("CACHEDIR.TAG").is_file())
928}
929
930/// Copy the running binary to a stable location, and return where it now lives.
931fn install_binary() -> Result<String, String> {
932    let me =
933        std::env::current_exe().map_err(|e| format!("cannot locate the running binary: {e}"))?;
934
935    // A binary a package manager already put on PATH is not ours to copy.
936    //
937    // The copy below exists for `./target/release/amont install`, where the
938    // binary sits in a directory `cargo clean` will delete — baking that path
939    // would install hooks that stop resolving the next time somebody builds.
940    // For `brew install`, `cargo install` or a distro package, the opposite is
941    // true: the binary is already somewhere stable, and copying it produces a
942    // SECOND, unmanaged copy that the package manager will never update again.
943    //
944    // That is not hypothetical. It is what this machine was in: `brew upgrade`
945    // would have refreshed /usr/local/bin while every repo stayed baked to a
946    // frozen copy in ~/.local/bin — the same staleness the copy is meant to
947    // prevent, arrived at from the other direction.
948    //
949    // `$AMONT_BIN_DIR` is checked first because setting it IS the request to
950    // put the binary somewhere specific, and honouring it costs nothing.
951    if std::env::var_os("AMONT_BIN_DIR").is_none() {
952        if let Some(stable) = on_path_already(&me) {
953            let shown = stable.to_string_lossy().into_owned();
954            println!("{} using {}", valid_sign(), highlight(&shown));
955            println!("    already on PATH, so nothing was copied — an upgrade there");
956            println!("    reaches every repository without reinstalling.");
957            return Ok(shown);
958        }
959    }
960
961    let dir = bin_dir();
962    std::fs::create_dir_all(&dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
963
964    // Absolute from here on: this path is what gets baked into every shim, and
965    // `bin_dir()` honours `$AMONT_BIN_DIR`, which may be relative.
966    let target = absolute(&dir.join(installed_name()));
967    // Copying a running binary over ITSELF fails on some platforms and is
968    // pointless on all of them.
969    //
970    // `me.canonicalize().ok() == target.canonicalize().ok()` is the version
971    // this replaces, and it was wrong in the one case that matters: when the
972    // TARGET does not exist yet — a first install, the whole point of the
973    // step — `canonicalize` returns `Err`, both sides are `None`, `None ==
974    // None` is true, and the copy was skipped. The binary was never installed,
975    // and `install` printed "installed <path>" for a file that was not there.
976    // Every shim then baked that path and resolved nothing. Two `Ok`s that
977    // agree is the only thing that means "same file".
978    let already_there = matches!(
979        (me.canonicalize(), target.canonicalize()),
980        (Ok(a), Ok(b)) if a == b
981    );
982    if !already_there {
983        std::fs::copy(&me, &target)
984            .map_err(|e| format!("cannot install to {}: {e}", target.display()))?;
985        make_executable(&target).map_err(|e| format!("cannot chmod {}: {e}", target.display()))?;
986    }
987    let installed = target.to_string_lossy().into_owned();
988    println!("{} installed {}", valid_sign(), highlight(&installed));
989    Ok(installed)
990}
991
992/// Write the shims into the template directory — unless doing so would delete
993/// somebody's source.
994///
995/// REFUSING is not an error: on a machine where the template dir is the
996/// checkout, there is nothing to install and the install has succeeded. FAILING
997/// to write one it was allowed to write is, though — reporting success after a
998/// step did not happen is the thing this whole codebase is arranged against.
999fn populate_template_dir(binary: &str, force: bool) -> Result<(), String> {
1000    let dir = template_hooks_dir();
1001    let _ = std::fs::create_dir_all(&dir);
1002    // Report the RESOLVED path. "It is the checkout" is only useful with the
1003    // checkout named, and the configured path is usually the symlink that hides
1004    // exactly that.
1005    let shown = dir.canonicalize().unwrap_or_else(|_| dir.clone());
1006    let shown = shown.display();
1007
1008    match classify_dir(&dir) {
1009        TemplateDir::IsCheckout => {
1010            println!(
1011                "{} template dir IS the checkout ({shown}) — nothing to install.",
1012                warning_sign()
1013            );
1014            println!("    Its shims keep the placeholder deliberately and resolve");
1015            println!("    {binary} at run time. This is the intended setup.");
1016            // …as long as run-time resolution can actually reach the binary,
1017            // which the sentence above used to assert unconditionally.
1018            warn_if_unbaked_cannot_resolve(binary);
1019        }
1020        TemplateDir::InsideCheckout => {
1021            println!(
1022                "{} {shown} is inside a git checkout — leaving it alone.",
1023                warning_sign()
1024            );
1025            warn_if_unbaked_cannot_resolve(binary);
1026        }
1027        TemplateDir::NoGit => println!(
1028            "{} git is not on PATH — refusing to delete anything.",
1029            warning_sign()
1030        ),
1031        TemplateDir::Unresolvable => {
1032            println!("{} cannot resolve {shown} — skipping.", warning_sign())
1033        }
1034        TemplateDir::Safe => {
1035            let written = write_shims(&dir, binary, force)
1036                .map_err(|e| format!("cannot write shims to {shown}: {e}"))?;
1037            println!("{} wrote {} shims to {shown}", valid_sign(), written.len());
1038            report_overwrites(&written);
1039        }
1040    }
1041    Ok(())
1042}
1043
1044/// Say what each write took, for the writes that took something.
1045///
1046/// `install --force` used to print `baked 4 shims` and stop. `--force` is
1047/// typed precisely because a file is in the way, so the one fact the output
1048/// omitted is the only fact the user needed: which files, and what they were.
1049/// A hook replaced with no record of what it was is unrecoverable — `.git` is
1050/// not tracked, so there is nothing to `git checkout` it back from.
1051fn report_overwrites(written: &[Written]) {
1052    for w in written {
1053        if matches!(w.replaced, HookFile::Absent | HookFile::Ours) {
1054            continue;
1055        }
1056        println!(
1057            "{} overwrote {} — it was {}",
1058            warning_sign(),
1059            w.path.display(),
1060            w.replaced.describe()
1061        );
1062    }
1063}
1064
1065/// Where git dispatches hooks from, and whether that is this repository's OWN
1066/// hooks directory or somewhere `core.hooksPath` sent it.
1067///
1068/// This replaces a bare `repo_hooks_dir()` that returned only the dispatch path.
1069/// `--git-path hooks` is still the right question — never `--git-dir` plus
1070/// `join("hooks")`, because hooks are explicitly SHARED across worktrees while a
1071/// linked worktree's `--git-dir` is its own PRIVATE gitdir, so joining "hooks"
1072/// onto it names a directory git never dispatches from. The addition is asking
1073/// `--git-common-dir` alongside it, so the answer can be compared against the
1074/// directory that would be ours.
1075///
1076/// The distinction did not exist and its absence was silent. `--git-path hooks`
1077/// honours `core.hooksPath`, so in a repository running husky it answers
1078/// `.husky/_` — inside the repo, plausible, and wrong. `install` wrote four
1079/// shims there, husky's own `prepare` regenerated the directory on the next
1080/// `npm install`, and the repository went back to having no checks with nothing
1081/// to show for it. Every guarantee this tool makes was off in those repositories
1082/// and the fleet reported them as merely "drifted".
1083#[derive(Debug, Clone, PartialEq, Eq)]
1084pub enum RepoHooks {
1085    /// `<git-common-dir>/hooks`. Git dispatches from here and it is ours to write.
1086    Own(PathBuf),
1087    /// `core.hooksPath` points somewhere else. Another tool owns dispatch here,
1088    /// and writing to `own` would install shims git never runs.
1089    Redirected { to: PathBuf, own: PathBuf },
1090    /// Not in a repository — git itself said "not a git repository".
1091    Nowhere,
1092    /// git failed for any OTHER reason: dubious ownership, an unreadable
1093    /// `.git/config`, a corrupt gitfile. There is plausibly a repository here;
1094    /// git would not talk about it. Split from [`RepoHooks::Nowhere`] because
1095    /// the two demand opposite behaviour from `init` — outside a repository,
1096    /// silence is correct; a repository git refuses to answer about is one
1097    /// where somebody believes they are getting hooks and is not, which is the
1098    /// failure this whole tool is arranged against.
1099    Unanswerable { why: String },
1100}
1101
1102/// Ask git both questions at once — what it dispatches from, and what this
1103/// repository's own hooks directory is — then compare.
1104///
1105/// Lexical comparison via [`crate::hookfile::resolve_lexical`], not
1106/// `canonicalize`: neither directory is guaranteed to exist yet (a fresh clone
1107/// has no `.git/hooks` until something writes one), and `canonicalize` cannot be
1108/// asked about a path that does not. Same reason `is_within` is lexical.
1109pub fn repo_hooks() -> RepoHooks {
1110    repo_hooks_in(Path::new("."))
1111}
1112
1113/// [`repo_hooks`] for the repository at `dir`, so a test can aim it at a
1114/// fixture without moving the process.
1115pub fn repo_hooks_in(dir: &Path) -> RepoHooks {
1116    // `git::output`, not `git::stdout`: the latter collapses every non-zero
1117    // exit to `None` with stderr discarded, which folded "not in a repository"
1118    // (silence is right) and "git refused to answer" (silence hid a container
1119    // checkout getting no hooks from `prepare`, with exit 0) into one arm.
1120    let Some(out) = crate::git::output_in(
1121        dir,
1122        &[
1123            "rev-parse",
1124            "--path-format=absolute",
1125            "--git-path",
1126            "hooks",
1127            "--git-common-dir",
1128        ],
1129    ) else {
1130        return RepoHooks::Unanswerable {
1131            why: "could not run git".to_string(),
1132        };
1133    };
1134    if out.code != 0 {
1135        // git's own verdict draws the line — the same phrase
1136        // `amont-fleet::scan::hooks_dir_for` keys on.
1137        if out.stderr.contains("not a git repository") {
1138            return RepoHooks::Nowhere;
1139        }
1140        let why = out
1141            .stderr
1142            .lines()
1143            .find(|l| l.starts_with("fatal:"))
1144            .or_else(|| out.stderr.lines().find(|l| !l.trim().is_empty()))
1145            .unwrap_or("git gave no reason")
1146            .to_string();
1147        return RepoHooks::Unanswerable { why };
1148    }
1149    let mut lines = out.stdout.lines();
1150    let (Some(dispatched), Some(common)) = (lines.next(), lines.next()) else {
1151        return RepoHooks::Nowhere;
1152    };
1153    let dispatched = PathBuf::from(dispatched);
1154    let own = PathBuf::from(common).join("hooks");
1155    if crate::hookfile::resolve_lexical(&dispatched) == crate::hookfile::resolve_lexical(&own) {
1156        RepoHooks::Own(own)
1157    } else {
1158        RepoHooks::Redirected {
1159            to: dispatched,
1160            own,
1161        }
1162    }
1163}
1164
1165/// Name the tool behind a `core.hooksPath`, when the path gives it away.
1166///
1167/// A short list on purpose. It is half of [`redirect_is_hostile`]'s evidence,
1168/// not a general classifier, and a name guessed wrong is worse than none.
1169pub fn redirect_culprit(to: &Path) -> Option<&'static str> {
1170    let s = to.to_string_lossy().replace('\\', "/");
1171    if s.contains("/.husky") {
1172        return Some("husky");
1173    }
1174    if s.contains("/.lefthook") || s.contains("lefthook") {
1175        return Some("lefthook");
1176    }
1177    None
1178}
1179
1180/// Whether any of our four shims sits in `dir`.
1181///
1182/// The runtime's own copy of the question `amont-fleet::scan::is_managed`
1183/// answers, because the guard that needs it runs in the commit-path crate and
1184/// cannot depend on the dashboard.
1185pub fn holds_our_shims(dir: &Path) -> bool {
1186    DISPATCHERS
1187        .iter()
1188        .any(|name| matches!(hookfile::classify(&dir.join(name)), HookFile::Ours))
1189}
1190
1191/// Whether a redirect must be REFUSED rather than followed.
1192///
1193/// Not every `core.hooksPath` is a problem, and the first cut of this refused
1194/// them all — which would have broken a repository that deliberately keeps its
1195/// hooks in `tooling/hooks` under version control. That is a setup this project
1196/// has always honoured and `plan_finds_shims_at_a_redirected_hooks_path` pins.
1197///
1198/// So the refusal rests on evidence, not on the mere presence of the setting.
1199/// Either is enough:
1200///
1201///   * **the destination belongs to a hook manager we recognise** — husky and
1202///     lefthook both REGENERATE their directory on install, so anything we wrote
1203///     there is gone by the next `npm install` and the repository silently stops
1204///     being checked;
1205///   * **our shims sit in the repository's own hooks directory and NOT at the
1206///     destination** — amont was installed here and something later took
1207///     dispatch away. Whatever the destination is, this repository is not
1208///     running the checks it believes it is, and that is worth stopping for
1209///     whether or not we can name the culprit.
1210///
1211/// The second signal needs both halves. A repository whose shims sit at the
1212/// destination too moved its hooks there deliberately — amont IS running, from
1213/// the directory the repository chose — and whatever lingers in
1214/// `<git-common-dir>/hooks` is leftovers from before the move, not evidence of
1215/// a takeover. Refusing on the leftovers alone locked such a repository out of
1216/// `install` (and of `amont init` from npm's `prepare`, failing every
1217/// `npm install`) with a remedy that would have broken the deliberate setup.
1218///
1219/// A repository with neither signal keeps the old behaviour exactly.
1220pub fn redirect_is_hostile(to: &Path, own: &Path) -> bool {
1221    redirect_culprit(to).is_some() || (holds_our_shims(own) && !holds_our_shims(to))
1222}
1223
1224/// The refusal both `install` and `init` give when another tool owns dispatch.
1225///
1226/// One function, so the two cannot drift into saying different things about the
1227/// same situation — and so the remedy is spelled exactly once.
1228pub fn redirected_message(to: &Path, own: &Path) -> String {
1229    let mut msg = format!(
1230        "{} git dispatches hooks from {}, not {}",
1231        error_sign(),
1232        highlight(&to.display().to_string()),
1233        own.display()
1234    );
1235    match redirect_culprit(to) {
1236        Some(tool) => msg.push_str(&format!(
1237            "\n    `core.hooksPath` is set, so {tool} owns the hooks here. Shims\n    \
1238             written to either directory would be overwritten or never run."
1239        )),
1240        None => msg.push_str(
1241            "\n    `core.hooksPath` is set, so another tool owns the hooks here.\n    \
1242             Shims written to either directory would be overwritten or never run.",
1243        ),
1244    }
1245    msg.push_str(&format!(
1246        "\n    Hand dispatch back first: {}",
1247        highlight("git config --unset core.hooksPath")
1248    ));
1249    // Stranded shims are the evidence when no culprit is named — say how to
1250    // clear them, because "unset core.hooksPath" is the WRONG remedy for a
1251    // repository whose redirect is deliberate and merely predates a cleanup.
1252    if redirect_culprit(to).is_none() && holds_our_shims(own) {
1253        msg.push_str(&format!(
1254            "\n    Or, if the redirect is deliberate, clear our stale shims from {}\n    \
1255             first: {}",
1256            own.display(),
1257            highlight("amont uninstall")
1258        ));
1259    }
1260    msg
1261}
1262
1263/// Bake the shims into the repository we are standing in, if we are in one.
1264///
1265/// The tracked guard is inherited from `hookfile::guard_write` rather than
1266/// written here, and that inheritance closes a verified bug: with
1267/// `.git/hooks/pre-commit` a symlink to a TRACKED `devhooks/pre-commit`,
1268/// `install --force` rewrote the tracked source file. Every guard this function
1269/// had was about the LINK path — untracked, inside `.git`, unremarkable — while
1270/// `fs::write` followed the link and landed in the working tree. Both halves
1271/// are fixed at once: the symlink is refused by name, and `--force` replaces
1272/// the link by rename instead of writing through it.
1273fn bake_repo_hooks(binary: &str, force: bool) -> Result<(), String> {
1274    let hooks = match repo_hooks() {
1275        RepoHooks::Own(dir) => dir,
1276        // A hostile redirect is refused, and `--force` does not move it.
1277        // `--force` means "that file is mine to replace"; it has never meant
1278        // "write where git does not look". Writing `own` would leave four shims
1279        // git never dispatches, and writing `to` would hand our files to the
1280        // tool that regenerates that directory.
1281        //
1282        // A redirect that is merely a redirect is followed, as it always was —
1283        // see `redirect_is_hostile` for where the line is.
1284        RepoHooks::Redirected { to, own } if redirect_is_hostile(&to, &own) => {
1285            return Err(redirected_message(&to, &own))
1286        }
1287        RepoHooks::Redirected { to, .. } => to,
1288        RepoHooks::Nowhere => {
1289            println!(
1290                "{} not inside a git repository — no repo hooks written.",
1291                warning_sign()
1292            );
1293            return Ok(());
1294        }
1295        RepoHooks::Unanswerable { why } => {
1296            return Err(format!(
1297                "{} git would not say where this repository's hooks live —\n    {}",
1298                error_sign(),
1299                crate::ui::sanitize(&why)
1300            ))
1301        }
1302    };
1303    let _ = std::fs::create_dir_all(&hooks);
1304
1305    // Asked here, ahead of the guard, only so the message can offer `--force`.
1306    // The guard inside `write_shims` is the one that decides, and it refuses
1307    // things `--force` will not move (a tracked path, a path git cannot answer
1308    // for) which this pre-check deliberately says nothing about.
1309    let foreign = foreign_hooks(&hooks);
1310    if !foreign.is_empty() && !force {
1311        let mut msg = format!(
1312            "{} {} already has hooks that are not ours:",
1313            error_sign(),
1314            hooks.display()
1315        );
1316        for (name, what) in &foreign {
1317            msg.push_str(&format!("\n    {name} — {}", what.describe()));
1318        }
1319        msg.push_str("\n    Look at them first, then `amont install --force`.");
1320        return Err(msg);
1321    }
1322
1323    let written = write_shims(&hooks, binary, force)
1324        .map_err(|e| format!("cannot write shims to {}: {e}", hooks.display()))?;
1325    println!(
1326        "{} baked {} shims into {}",
1327        valid_sign(),
1328        written.len(),
1329        hooks.display()
1330    );
1331    report_overwrites(&written);
1332    Ok(())
1333}
1334
1335/// Take the shims out of the repository we are standing in.
1336///
1337/// Deliberately narrow. It removes files that are OURS and nothing else:
1338///
1339/// - a hook we did not write is left alone and named, because somebody wrote it
1340///   on purpose;
1341/// - `hook.skip` and `amont.severity` are never touched — those are the
1342///   user's statements about their own repository, not our artefacts, and a
1343///   reinstall should not silently forget that they disabled a check;
1344/// - the binary goes only when asked, because other repositories are using it.
1345pub fn uninstall(remove_binary: bool) -> Result<(), String> {
1346    // The template directory FIRST, and unconditionally, because it is the only
1347    // part of an install that keeps working when you are not standing in a
1348    // repository — and because `uninstall` returning early with "not inside a
1349    // git repository" is how the standing grant survived every attempt to
1350    // revoke it.
1351    uninstall_template_dir()?;
1352    uninstall_repo_hooks()?;
1353
1354    if remove_binary {
1355        let target = bin_dir().join(installed_name());
1356        match std::fs::remove_file(&target) {
1357            Ok(()) => println!(
1358                "{} removed {}",
1359                valid_sign(),
1360                highlight(&target.to_string_lossy())
1361            ),
1362            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
1363            Err(e) => return Err(format!("cannot remove {}: {e}", target.display())),
1364        }
1365    }
1366
1367    report_global_template_dir();
1368
1369    // Said out loud, because a user who uninstalls and reinstalls should not be
1370    // surprised that a check they disabled is still disabled.
1371    println!("    hook.skip and amont.severity were not touched.");
1372    Ok(())
1373}
1374
1375/// Everything this tool ever wrote into ONE repository, forgotten — the
1376/// single list, so the two uninstall paths cannot drift apart.
1377///
1378/// `amont uninstall` swept its own repository and the fleet's `uninstall`
1379/// swept none of them: it removed shims from 200 repositories and left 200
1380/// ledgers, stamp refs and trust records behind, each one a statement about
1381/// hooks that are no longer there. Both now call this.
1382///
1383/// **Trust is revoked here, and that is a deliberate reversal.** The record
1384/// is keyed on the manifest's content, so keeping it would re-honour the
1385/// consent automatically on a reinstall — for bytes somebody reviewed once,
1386/// possibly a year ago, in a repository they since asked amont to stop
1387/// running in. Consent defaults to no everywhere else in this codebase
1388/// (untrusted manifests are inert, policy is withheld); it defaults to no
1389/// here too. Re-granting is one `amont trust`, which shows the file again.
1390///
1391/// **What is never touched**: `hook.skip` and `amont.severity` (the user's
1392/// statements about their repository, not ours), and above all the held
1393/// store — `$GIT_DIR/amont-held` and `amont-preserved` hold UNCOMMITTED
1394/// WORK. An uninstall that deletes those loses the very thing the parking
1395/// machinery exists to protect; `amont restore` must keep working after the
1396/// hooks are gone.
1397pub fn forget_bookkeeping_in(repo: &Path) -> Vec<&'static str> {
1398    let mut gone = Vec::new();
1399    if crate::gate_stamp::forget_in(repo) {
1400        gone.push("gate stamps");
1401    }
1402    if crate::attest::forget_in(repo) {
1403        gone.push("attestations");
1404    }
1405    if crate::bypass::forget_in(repo) {
1406        gone.push("bypass ledger");
1407    }
1408    if crate::downgrade::forget_in(repo) {
1409        gone.push("downgrade ledger");
1410    }
1411    if crate::skew::forget_in(repo) {
1412        gone.push("version-skew marker");
1413    }
1414    // `--unset-all` exits 5 when the key is absent; that is not a removal.
1415    if crate::git::succeeds_in(repo, &["config", "--unset-all", "amont.knownIdentity"]) {
1416        gone.push("known-identity memo");
1417    }
1418    if !crate::trust::recorded(repo).is_empty() && crate::trust::revoke(repo).is_ok() {
1419        gone.push("amont.conf trust");
1420    }
1421    gone
1422}
1423
1424/// Remove our shims from the repository we are standing in, naming everything
1425/// we did not take and why.
1426///
1427/// Every non-removal is now NAMED. The loop this replaces matched
1428/// `Err(_) => {}` on `read_to_string`, so a hook that could not be read at all
1429/// — a compiled one, or one whose permissions we lack — was passed over in
1430/// total silence: not removed, not counted, not mentioned. The README's promise
1431/// that a foreign hook is "left alone and named" was true only for hooks that
1432/// happened to be valid UTF-8.
1433///
1434/// Not being in a repository is a warning rather than an error. It used to
1435/// return `Err`, which was defensible on its own but became wrong once
1436/// `uninstall_template_dir` existed: the early return meant that running
1437/// `amont uninstall` from a plain directory did nothing AND said nothing,
1438/// while `init.templateDir` quietly went on installing hooks into every future
1439/// clone. Refusing is not failing — the same rule `populate_template_dir`
1440/// already states.
1441fn uninstall_repo_hooks() -> Result<(), String> {
1442    // BOTH directories, where they differ. Uninstall is the one path that must
1443    // not inherit `install`'s new refusal: versions before it wrote shims into
1444    // whatever `core.hooksPath` named, so a repository can be carrying our files
1445    // in `.husky/_` right now — and refusing to look there would leave the only
1446    // command that removes them unable to find them. Removal is safe in a way
1447    // writing is not; `guard_remove` still decides file by file.
1448    let dirs: Vec<PathBuf> = match repo_hooks() {
1449        RepoHooks::Own(dir) => vec![dir],
1450        RepoHooks::Redirected { to, own } => vec![to, own],
1451        RepoHooks::Nowhere => {
1452            println!(
1453                "{} not inside a git repository — no repo hooks removed.",
1454                warning_sign()
1455            );
1456            return Ok(());
1457        }
1458        // Removal stays forgiving where writing does not: failing here would
1459        // leave `uninstall --binary` unable to finish its cleanup. Loud, and Ok.
1460        RepoHooks::Unanswerable { why } => {
1461            println!(
1462                "{} git would not answer here — no repo hooks removed ({})",
1463                warning_sign(),
1464                crate::ui::sanitize(&why)
1465            );
1466            return Ok(());
1467        }
1468    };
1469
1470    for hooks in &dirs {
1471        let mut removed = 0usize;
1472        let mut left: Vec<String> = Vec::new();
1473        for name in DISPATCHERS {
1474            let path = hooks.join(name);
1475            match hookfile::classify(&path) {
1476                HookFile::Absent => {}
1477                HookFile::Ours => match hookfile::guard_remove(&path, true) {
1478                    Ok(()) => {
1479                        hookfile::remove_regular(&path)
1480                            .map_err(|e| format!("cannot remove {}: {e}", path.display()))?;
1481                        removed += 1;
1482                    }
1483                    // Ours by marker, and still not ours to delete: a tracked
1484                    // path, or one git could not answer for.
1485                    Err(r) => left.push(r.explain()),
1486                },
1487                what => left.push(format!("{name} — {}", what.describe())),
1488            }
1489        }
1490        // The second directory is usually empty of ours and saying so every time
1491        // would be noise. Report it only when it held something.
1492        if removed > 0 || !left.is_empty() || dirs.len() == 1 {
1493            println!(
1494                "{} removed {removed} shims from {}",
1495                valid_sign(),
1496                hooks.display()
1497            );
1498        }
1499        for reason in &left {
1500            println!("{} left alone: {reason}", warning_sign());
1501        }
1502    }
1503    // Our bookkeeping only ever says "amont checked this" (or "didn't"),
1504    // which stops being true of anything the moment the hooks are gone. One
1505    // list, shared with the fleet — see `forget_bookkeeping_in` for what is
1506    // deliberately NOT taken.
1507    if let Ok(root) = crate::hooks::common::repo_root_checked() {
1508        let gone = forget_bookkeeping_in(Path::new(&root));
1509        if !gone.is_empty() {
1510            println!("{} forgot {}", valid_sign(), gone.join(", "));
1511        }
1512    }
1513    Ok(())
1514}
1515
1516/// Take our shims back out of the template directory.
1517///
1518/// `install` writes there; `uninstall` did not, which meant uninstall did not
1519/// undo install. Combined with `init.templateDir`, that is the failure worth
1520/// spelling out: the user runs `amont uninstall`, sees "removed 4 shims",
1521/// believes they are done — and every `git clone` and `git init` from then on
1522/// copies the template directory into the new repository's `.git/hooks` and
1523/// installs the hooks again. They uninstalled a repository, not a machine.
1524///
1525/// The same classification `install` uses decides what may happen here, for the
1526/// same reason and with the sharper stake: `~/.config/git/git-templates` is
1527/// commonly a SYMLINK to a checkout of this repository, and "uninstalling"
1528/// there means `rm` on tracked source. That is not a hypothetical; it is the
1529/// two incidents this module exists because of, and a delete has no `--force`.
1530fn uninstall_template_dir() -> Result<(), String> {
1531    let dir = template_hooks_dir();
1532    // The RESOLVED path, because the configured one is usually the symlink that
1533    // hides exactly what we are about to explain.
1534    let shown = dir.canonicalize().unwrap_or_else(|_| dir.clone());
1535    let shown = shown.display();
1536
1537    match classify_dir(&dir) {
1538        TemplateDir::IsCheckout | TemplateDir::InsideCheckout => {
1539            println!(
1540                "{} template dir is a git checkout ({shown}) — deleting NOTHING there.",
1541                warning_sign()
1542            );
1543            println!("    Those shims are tracked files belonging to that checkout,");
1544            println!("    not something this install put there. Remove them with git,");
1545            println!("    or point init.templateDir somewhere else.");
1546        }
1547        TemplateDir::NoGit => println!(
1548            "{} git is not on PATH — cannot tell whether {shown} is a checkout, deleting nothing.",
1549            warning_sign()
1550        ),
1551        TemplateDir::Unresolvable => println!(
1552            "{} no template dir at {shown} — nothing to remove.",
1553            warning_sign()
1554        ),
1555        TemplateDir::Safe => {
1556            let mut removed = 0usize;
1557            let mut left: Vec<String> = Vec::new();
1558            for name in DISPATCHERS {
1559                let path = dir.join(name);
1560                match hookfile::classify(&path) {
1561                    HookFile::Absent => {}
1562                    HookFile::Ours => match hookfile::guard_remove(&path, true) {
1563                        Ok(()) => {
1564                            hookfile::remove_regular(&path)
1565                                .map_err(|e| format!("cannot remove {}: {e}", path.display()))?;
1566                            removed += 1;
1567                        }
1568                        Err(r) => left.push(r.explain()),
1569                    },
1570                    what => left.push(format!("{name} — {}", what.describe())),
1571                }
1572            }
1573            println!("{} removed {removed} shims from {shown}", valid_sign());
1574            for reason in &left {
1575                println!("{} left alone: {reason}", warning_sign());
1576            }
1577        }
1578    }
1579    Ok(())
1580}
1581
1582/// Say, every time, whether `init.templateDir` is still pointing at us.
1583///
1584/// UNCONDITIONAL, including when the template dir was a checkout we refused to
1585/// touch and when there was no template dir at all — because the config setting
1586/// is what actually installs hooks into new repositories, and it survives every
1587/// file this command removes. An uninstall that leaves it set has not
1588/// uninstalled anything durable: the next `git clone` re-installs.
1589///
1590/// Printed rather than unset. `git config --global` is the user's file, holding
1591/// their identity and their aliases, and reaching into it uninvited is a larger
1592/// claim than removing files this tool wrote. The command to run is given
1593/// verbatim so it is a copy rather than a lookup.
1594fn report_global_template_dir() {
1595    let Some(configured) = crate::git::stdout(&["config", "--global", "--get", "init.templateDir"])
1596        .filter(|s| !s.is_empty())
1597    else {
1598        return;
1599    };
1600    println!();
1601    println!(
1602        "{} init.templateDir is still set: {}",
1603        warning_sign(),
1604        highlight(&configured)
1605    );
1606    println!("    Every `git clone` and `git init` still copies hooks from there");
1607    println!("    into the new repository. Uninstalling this repo did not change that.");
1608    println!("    Undo it with:");
1609    println!(
1610        "        {}",
1611        highlight("git config --global --unset init.templateDir")
1612    );
1613}
1614
1615#[cfg(test)]
1616mod tests {
1617    use super::*;
1618
1619    fn tmp(name: &str) -> PathBuf {
1620        let d = std::env::temp_dir().join(format!("gh-install-{name}-{}", std::process::id()));
1621        let _ = std::fs::remove_dir_all(&d);
1622        std::fs::create_dir_all(&d).expect("mkdir");
1623        d
1624    }
1625
1626    fn git(dir: &Path, args: &[&str]) {
1627        Command::new("git")
1628            .arg("-C")
1629            .arg(dir)
1630            .args(args)
1631            .output()
1632            .expect("git");
1633    }
1634
1635    /// The embedded shim must be the shim that ships. `include_str!` takes one
1636    /// of the four; if they ever diverge, the installer would write a file
1637    /// nobody reviewed.
1638    #[test]
1639    fn shims_on_disk_match_the_embedded_one() {
1640        let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../templates/hooks");
1641        // Absent when this crate is built from its PUBLISHED tarball, which
1642        // contains this directory and nothing above it. There is no drift to
1643        // catch in that situation — the only shim present is the one compiled
1644        // in — so say so rather than fail a test about a file that is not
1645        // supposed to be there.
1646        if !Path::new(dir).is_dir() {
1647            println!(
1648                "! no repository checkout here — nothing to compare the embedded shim against"
1649            );
1650            return;
1651        }
1652        for name in DISPATCHERS {
1653            let disk = std::fs::read_to_string(Path::new(dir).join(name))
1654                .unwrap_or_else(|e| panic!("read {name}: {e}"));
1655            assert_eq!(disk, SHIM, "{name} differs from the embedded shim");
1656        }
1657    }
1658
1659    /// The bytes that SHIP carry no carriage return.
1660    ///
1661    /// `SHIM` is an `include_str!`, so it is whatever the build host's
1662    /// checkout held — and with no `.gitattributes` that was CRLF on
1663    /// Windows, whose git defaults `core.autocrlf` to true. v1.14.0's
1664    /// `amont.exe` embedded `#!/bin/sh\r\n` and its archive shipped an
1665    /// 82-CR `pre-commit`, so every install there wrote a shell script with
1666    /// a carriage return in the shebang. This asserts the fix from the only
1667    /// side that matters — the compiled-in bytes — rather than trusting the
1668    /// attributes file to keep working. It runs on the Windows job, which is
1669    /// the checkout that could regress.
1670    #[test]
1671    fn the_embedded_shim_carries_no_carriage_return() {
1672        assert!(
1673            !SHIM.contains('\r'),
1674            "the embedded shim has CRLF line endings: this binary would \
1675             install `#!/bin/sh\\r` as a POSIX sh hook. `.gitattributes` \
1676             declares templates/hooks/* as eol=lf — has it been removed, or \
1677             is this checkout stale?"
1678        );
1679    }
1680
1681    /// The whole point of the module. A directory holding tracked files is the
1682    /// source checkout reached through a symlink, and emptying it destroys work.
1683    #[test]
1684    fn a_directory_holding_tracked_files_is_never_safe() {
1685        let d = tmp("tracked");
1686        git(&d, &["init", "-q", "--template=", "."]);
1687        git(&d, &["config", "user.email", "t@t.test"]);
1688        git(&d, &["config", "user.name", "t"]);
1689        std::fs::write(d.join("kept.txt"), "precious\n").expect("write");
1690        git(&d, &["add", "-A"]);
1691        git(&d, &["commit", "-qm", "seed"]);
1692
1693        assert_eq!(classify_dir(&d), TemplateDir::IsCheckout);
1694        let _ = std::fs::remove_dir_all(&d);
1695    }
1696
1697    /// A path comparison against the source tree passes here and is WRONG: a
1698    /// worktree is a different path holding the same tracked files. This is the
1699    /// case that caused the second incident.
1700    #[test]
1701    fn a_worktree_is_recognised_even_though_its_path_differs() {
1702        let d = tmp("wt-main");
1703        git(&d, &["init", "-q", "--template=", "."]);
1704        git(&d, &["config", "user.email", "t@t.test"]);
1705        git(&d, &["config", "user.name", "t"]);
1706        std::fs::write(d.join("kept.txt"), "precious\n").expect("write");
1707        git(&d, &["add", "-A"]);
1708        git(&d, &["commit", "-qm", "seed"]);
1709
1710        let wt = d.with_extension("wt");
1711        let _ = std::fs::remove_dir_all(&wt);
1712        git(&d, &["worktree", "add", "-q", wt.to_str().unwrap()]);
1713        assert!(
1714            wt.join("kept.txt").is_file(),
1715            "worktree did not materialise"
1716        );
1717        assert_ne!(d.canonicalize().ok(), wt.canonicalize().ok());
1718        assert_eq!(
1719            classify_dir(&wt),
1720            TemplateDir::IsCheckout,
1721            "a worktree must be refused exactly like the main checkout"
1722        );
1723        let _ = std::fs::remove_dir_all(&wt);
1724        let _ = std::fs::remove_dir_all(&d);
1725    }
1726
1727    /// Inside a checkout but tracking nothing here — still not ours to empty.
1728    #[test]
1729    fn an_untracked_directory_inside_a_checkout_is_refused() {
1730        let d = tmp("inside");
1731        git(&d, &["init", "-q", "--template=", "."]);
1732        let sub = d.join("scratch");
1733        std::fs::create_dir_all(&sub).expect("mkdir");
1734        assert_eq!(classify_dir(&sub), TemplateDir::InsideCheckout);
1735        let _ = std::fs::remove_dir_all(&d);
1736    }
1737
1738    #[test]
1739    fn an_ordinary_directory_is_safe() {
1740        let d = tmp("plain");
1741        assert_eq!(classify_dir(&d), TemplateDir::Safe);
1742        let _ = std::fs::remove_dir_all(&d);
1743    }
1744
1745    #[test]
1746    fn a_missing_directory_is_unresolvable_not_safe() {
1747        assert_eq!(
1748            classify_dir(Path::new("/nonexistent-install-c8f2/hooks")),
1749            TemplateDir::Unresolvable
1750        );
1751    }
1752
1753    /// Baking replaces every occurrence and is idempotent.
1754    #[test]
1755    fn baking_is_total_and_idempotent() {
1756        let once = bake(SHIM, "/opt/amont");
1757        assert!(!once.contains(PLACEHOLDER), "a token survived baking");
1758        assert!(once.contains("/opt/amont"));
1759        assert_eq!(bake(&once, "/other"), once, "re-baking must be a no-op");
1760    }
1761
1762    /// The shim's comment must not spell the token out, or a global replace
1763    /// turns the explanation into a machine path — which it did, in every shim
1764    /// baked before this module existed.
1765    #[test]
1766    fn baking_does_not_rewrite_the_comment_explaining_it() {
1767        for line in bake(SHIM, "/opt/amont").lines() {
1768            if line.trim_start().starts_with('#') {
1769                assert!(
1770                    !line.contains("/opt/amont"),
1771                    "baking rewrote a comment: {line}"
1772                );
1773            }
1774        }
1775    }
1776
1777    /// Only an absolute path may be baked.
1778    ///
1779    /// A relative one is resolved by the shim against the WORKING TREE, so a
1780    /// repository shipping an executable by that name would be running it on
1781    /// the first commit after clone.
1782    #[test]
1783    fn only_an_absolute_path_is_bakeable() {
1784        for good in [
1785            "/opt/amont",
1786            "/home/u/.local/bin/amont",
1787            "C:/Users/u/amont.exe",
1788            "C:\\Users\\u\\amont.exe",
1789        ] {
1790            assert!(is_bakeable(good), "{good} should be bakeable");
1791        }
1792        for bad in [
1793            "",
1794            PLACEHOLDER,
1795            "amont",
1796            "./amont",
1797            "../amont",
1798            "target/debug/amont",
1799            "C:amont.exe",
1800        ] {
1801            assert!(!is_bakeable(bad), "{bad:?} must not be bakeable");
1802        }
1803    }
1804
1805    /// The shim must never hand the unsubstituted token to `[ -x ]`: that is a
1806    /// filesystem question asked in the repository's own directory.
1807    #[test]
1808    fn the_shim_never_tests_the_placeholder_as_a_path() {
1809        assert!(
1810            !SHIM.contains(&format!("[ -x \"{PLACEHOLDER}\" ]")),
1811            "the shim tests the raw token as a path"
1812        );
1813        assert!(
1814            SHIM.contains("case \"$BAKED\" in"),
1815            "the shim lost its absoluteness guard"
1816        );
1817    }
1818
1819    /// Refusing beats writing four hooks that cannot resolve their binary.
1820    #[test]
1821    fn write_shims_refuses_a_relative_binary_path() {
1822        let d = tmp("relative");
1823        let err = write_shims(&d, "target/debug/amont", false).expect_err("must refuse");
1824        assert!(err.to_string().contains("absolute"), "{err}");
1825        for name in DISPATCHERS {
1826            assert!(!d.join(name).exists(), "{name} was written anyway");
1827        }
1828        let _ = std::fs::remove_dir_all(&d);
1829    }
1830
1831    /// Every hook git invokes gets a file, and each is the baked shim.
1832    #[test]
1833    fn writing_shims_covers_every_dispatcher() {
1834        let d = tmp("write");
1835        let written = write_shims(&d, "/opt/amont", false).expect("write");
1836        assert_eq!(written.len(), DISPATCHERS.len());
1837        for name in DISPATCHERS {
1838            let got = std::fs::read_to_string(d.join(name)).expect("read");
1839            assert!(!got.contains(PLACEHOLDER), "{name} was written unbaked");
1840            assert!(got.contains("/opt/amont"), "{name} has no path");
1841        }
1842        let _ = std::fs::remove_dir_all(&d);
1843    }
1844
1845    /// The whole-repository posture, at the level of the function that owes it:
1846    /// ONE unwritable path and nothing at all is written. `bake_repo_hooks` has
1847    /// claimed this in a comment since it was written, over a loop that checked
1848    /// one file then wrote it, four times over — so a refusal on the third hook
1849    /// arrived after two were already gone.
1850    #[test]
1851    fn one_refusal_writes_nothing_at_all() {
1852        let d = tmp("all-or-nothing");
1853        // `prepare-commit-msg` sorts last among the dispatchers, so under the
1854        // old check-then-write loop the first three would already be on disk by
1855        // the time this one refused.
1856        let theirs = d.join("prepare-commit-msg");
1857        std::fs::write(&theirs, "#!/bin/sh\necho MINE\n").expect("write");
1858
1859        let err = write_shims(&d, "/opt/amont", false).expect_err("must refuse");
1860        assert!(
1861            matches!(err, ShimWriteError::Refused(ref rs) if rs.len() == 1),
1862            "{err}"
1863        );
1864        for name in ["commit-msg", "pre-commit", "pre-push"] {
1865            assert!(
1866                !d.join(name).exists(),
1867                "{name} was written despite a refusal elsewhere"
1868            );
1869        }
1870        assert_eq!(
1871            std::fs::read_to_string(&theirs).expect("read"),
1872            "#!/bin/sh\necho MINE\n"
1873        );
1874        let _ = std::fs::remove_dir_all(&d);
1875    }
1876
1877    /// A refusal has to say WHAT was in the way, not only that something was.
1878    /// The old predicate could not: it read the file as a string, so the one
1879    /// case worth naming — a compiled hook — came back as "not foreign" and was
1880    /// overwritten in silence.
1881    #[test]
1882    fn a_refusal_names_the_reason_for_each_hook() {
1883        let d = tmp("named");
1884        std::fs::write(d.join("commit-msg"), [0x7f, b'E', b'L', b'F', 0xff]).expect("write");
1885        std::fs::write(d.join("pre-commit"), "#!/bin/sh\necho mine\n").expect("write");
1886
1887        let err = write_shims(&d, "/opt/amont", false).expect_err("must refuse");
1888        let text = err.to_string();
1889        assert!(text.contains("not valid UTF-8"), "{text}");
1890        assert!(text.contains("commit-msg"), "{text}");
1891        assert!(text.contains("pre-commit"), "{text}");
1892
1893        // And `foreign_hooks` — which is what phrases the `--force` offer —
1894        // agrees about both.
1895        let foreign = foreign_hooks(&d);
1896        assert_eq!(foreign.len(), 2, "{foreign:?}");
1897        assert!(foreign
1898            .iter()
1899            .any(|(n, w)| *n == "commit-msg" && matches!(w, HookFile::Foreign(_))));
1900        let _ = std::fs::remove_dir_all(&d);
1901    }
1902
1903    /// `--force` says what it took, per file, with what it was. Without this
1904    /// the output was "baked 4 shims" — a receipt with the transaction left
1905    /// off, for the one operation whose purpose is to destroy something.
1906    #[test]
1907    fn force_reports_what_each_write_replaced() {
1908        let d = tmp("force-report");
1909        std::fs::write(d.join("commit-msg"), "#!/bin/sh\necho mine\n").expect("write");
1910        let written = write_shims(&d, "/opt/amont", true).expect("force must write");
1911        let replaced: Vec<_> = written
1912            .iter()
1913            .filter(|w| !matches!(w.replaced, HookFile::Absent))
1914            .collect();
1915        assert_eq!(replaced.len(), 1, "{written:?}");
1916        assert!(replaced[0].path.ends_with("commit-msg"));
1917        assert!(matches!(replaced[0].replaced, HookFile::Foreign(_)));
1918        let _ = std::fs::remove_dir_all(&d);
1919    }
1920
1921    /// Windows builds amont.exe, and a shim testing `[ -x .../amont ]` is
1922    /// false for it — so the installed name has to keep the suffix. Asserted
1923    /// against explicit paths, because a `cfg!(windows)` branch is vacuous on
1924    /// the platform this is usually run on.
1925    #[test]
1926    fn the_installed_name_keeps_the_platform_suffix() {
1927        assert_eq!(
1928            name_for(Path::new("/w/target/release/amont.exe")),
1929            "amont.exe"
1930        );
1931        assert_eq!(name_for(Path::new("/u/target/release/amont")), "amont");
1932        // A path that happens to contain a dot elsewhere is not an extension.
1933        assert_eq!(name_for(Path::new("/some.dir/amont")), "amont");
1934    }
1935
1936    fn hook_bytes(dir: &Path) -> Vec<(String, Vec<u8>)> {
1937        let mut v: Vec<_> = std::fs::read_dir(dir)
1938            .expect("hooks dir")
1939            .filter_map(|e| e.ok())
1940            .map(|e| {
1941                (
1942                    e.file_name().to_string_lossy().into_owned(),
1943                    std::fs::read(e.path()).unwrap_or_default(),
1944                )
1945            })
1946            .collect();
1947        v.sort();
1948        v
1949    }
1950
1951    /// A snapshot probe that cannot answer is NOT "not a snapshot": that
1952    /// answer is the one that bakes, and baking from a snapshot is the bug.
1953    /// The shims sit at sentinel A and the binary offered is B, so a re-bake
1954    /// would show — and the control at the end proves it does show.
1955    #[test]
1956    fn init_fails_closed_when_the_snapshot_probe_cannot_answer() {
1957        let d = tmp("probe-err");
1958        git(&d, &["init", "-q", "--template=", "."]);
1959        let hooks = d.join(".git").join("hooks");
1960        std::fs::create_dir_all(&hooks).expect("mkdir");
1961        write_shims(&hooks, "/sentinel/A/amont", false).expect("seed");
1962        let before = hook_bytes(&hooks);
1963        let b = || Ok(PathBuf::from("/sentinel/B/amont"));
1964
1965        let got = init_with(b(), &d, &|_| Err(std::io::Error::other("probe broke")));
1966        let why = got.expect_err("a probe that cannot answer must fail init");
1967        assert!(why.contains("cannot tell"), "{why}");
1968        assert_eq!(before, hook_bytes(&hooks), "init wrote on doubt");
1969
1970        init_with(b(), &d, &|_| Ok(false)).expect("not a snapshot: bakes");
1971        assert_ne!(before, hook_bytes(&hooks), "the control could not fail");
1972        let _ = std::fs::remove_dir_all(&d);
1973    }
1974}