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; 4] = ["commit-msg", "pre-commit", "pre-push", "prepare-commit-msg"];
62
63/// What may be done with a candidate template directory.
64///
65/// `~/.config/git/git-templates` is commonly a SYMLINK to a checkout of this
66/// repository, in which case "installing" there means deleting and overwriting
67/// TRACKED files.
68///
69/// Comparing the path against the source tree is NOT enough, and that is the
70/// mistake that caused both incidents: run the install from a git worktree and
71/// the two resolve to different paths — a different checkout of the same repo —
72/// so a path comparison says "not the source" and clobbers the main checkout.
73/// Asking git is the reliable test whatever route the symlink took.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum TemplateDir {
76    /// The path does not exist and could not be created.
77    Unresolvable,
78    /// No git to ask. Refuse rather than guess.
79    NoGit,
80    /// It holds tracked files: it IS a checkout. Nothing to install — `git init`
81    /// already reads its templates from there, and the shims keep their
82    /// placeholder and resolve the binary at run time.
83    IsCheckout,
84    /// Inside a checkout but tracking nothing here. Still not ours to empty.
85    InsideCheckout,
86    /// An ordinary directory. Safe to populate.
87    Safe,
88}
89
90fn git_ok(dir: &Path, args: &[&str]) -> bool {
91    Command::new("git")
92        .arg("-C")
93        .arg(dir)
94        .args(args)
95        .stdout(std::process::Stdio::null())
96        .stderr(std::process::Stdio::null())
97        .status()
98        .map(|s| s.success())
99        .unwrap_or(false)
100}
101
102/// Decide what may be done with `dir`. Never mutates anything.
103pub fn classify_dir(dir: &Path) -> TemplateDir {
104    let Ok(real) = dir.canonicalize() else {
105        return TemplateDir::Unresolvable;
106    };
107    if Command::new("git").arg("--version").output().is_err() {
108        return TemplateDir::NoGit;
109    }
110    // `ls-files --error-unmatch .` is the question that matters: does git track
111    // anything HERE? A directory can be inside a checkout and still be
112    // untracked scratch space, which the next test separates.
113    if git_ok(&real, &["ls-files", "--error-unmatch", "."]) {
114        return TemplateDir::IsCheckout;
115    }
116    if git_ok(&real, &["rev-parse", "--git-dir"]) {
117        return TemplateDir::InsideCheckout;
118    }
119    TemplateDir::Safe
120}
121
122/// Write the absolute binary path into a shim.
123///
124/// A plain global replace, which is why the shim's own comment must not spell
125/// the token out — it did, and every baked shim carried an "explanation" whose
126/// text was a machine path. Idempotent: re-baking a baked shim is a no-op
127/// because the token is gone.
128pub fn bake(shim: &str, bin: &str) -> String {
129    shim.replace(PLACEHOLDER, bin)
130}
131
132/// Whether the shim will accept `bin` as its baked path.
133///
134/// The shim rejects anything relative before it ever touches the filesystem,
135/// and this is the same rule stated where the value is produced. Git runs a
136/// hook with the working tree as the current directory, so a relative baked
137/// path is a question asked of the REPOSITORY — and a clone that ships a file
138/// by that name gets to answer it. Baking one would install a hook that either
139/// cannot resolve its binary or resolves it to somebody else's, so refuse here
140/// too, where the message can say which path was wrong.
141///
142/// Absolute means POSIX `/…` or a Windows drive path (`C:\…`, `C:/…`).
143pub fn is_bakeable(bin: &str) -> bool {
144    if bin.is_empty() || bin == PLACEHOLDER {
145        return false;
146    }
147    let b = bin.as_bytes();
148    if b[0] == b'/' {
149        return true;
150    }
151    b.len() > 2 && b[0].is_ascii_alphabetic() && b[1] == b':' && (b[2] == b'/' || b[2] == b'\\')
152}
153
154/// An absolute form of `p`, for baking.
155///
156/// NOT `canonicalize`: that returns an extended-length path (`\\?\C:\…`) on
157/// Windows, which `sh` cannot test, and it fails outright on a path that does
158/// not exist yet. `$AMONT_BIN_DIR` may be relative, which is the route by
159/// which a relative path could reach a shim at all.
160fn absolute(p: &Path) -> PathBuf {
161    if p.is_absolute() {
162        return p.to_path_buf();
163    }
164    match std::env::current_dir() {
165        Ok(cwd) => cwd.join(p),
166        Err(_) => p.to_path_buf(),
167    }
168}
169
170/// The one directory an UNBAKED shim looks in, hardcoded in the shim itself.
171///
172/// Deliberately NOT `bin_dir()`, and the difference is the whole point of
173/// `warn_if_unbaked_cannot_resolve`. `bin_dir()` answers "where should install
174/// PUT the binary?" and honours `$AMONT_BIN_DIR`. This answers "where will a
175/// shim that never got a path baked into it LOOK?", which is a constant in a
176/// POSIX sh file and cannot be configured at all.
177///
178/// `$AMONT_BIN_DIR` is deliberately not wired into the shim to close that
179/// gap. It is an install-time question answered in the shell where `amont
180/// install` ran; the shim runs inside git's environment during a commit, where
181/// that variable is almost never set — so honouring it there would ship a knob
182/// that looks like it works and silently does not. The runtime override already
183/// exists and is `$GIT_HOOKS_BIN`; a second variable able to redirect which
184/// binary executes on every commit would double that surface for nothing.
185///
186/// Not "XDG", either: the XDG Base Directory spec defines no binary directory.
187/// `~/.local/bin` is simply the widely-observed convention.
188fn unbaked_lookup_dir() -> PathBuf {
189    home().join(".local").join("bin")
190}
191
192/// Say so when the binary went somewhere an unbaked shim will never look.
193///
194/// Two supported choices stop composing when made together. A template dir that
195/// IS the checkout keeps the placeholder on purpose — those shims resolve the
196/// binary at run time from [`unbaked_lookup_dir`]. Install to a custom
197/// `$AMONT_BIN_DIR` as well and nothing baked a path, while the one path the
198/// shim knows is not where the binary went.
199///
200/// Nothing is silently skipped — the shim prints what it looked at and exits 1,
201/// which fails the commit loudly. But it fails at somebody's next commit, in a
202/// repository they have not thought about since, and the cause is a decision
203/// made here. So it is said here.
204fn warn_if_unbaked_cannot_resolve(binary: &str) {
205    let looked = unbaked_lookup_dir();
206    let placed = Path::new(binary).parent();
207
208    // Compare RESOLVED paths, and require both to resolve. `a.ok() == b.ok()`
209    // would read `None == None` as "the same directory" — the shape of a bug
210    // this module has already had once, in `already_there`.
211    let reachable = placed.is_some_and(|p| {
212        matches!(
213            (p.canonicalize(), looked.canonicalize()),
214            (Ok(a), Ok(b)) if a == b
215        )
216    });
217    if reachable {
218        return;
219    }
220
221    println!();
222    println!(
223        "{} the binary is at {}, which an unbaked shim will not find.",
224        warning_sign(),
225        highlight(binary)
226    );
227    println!(
228        "    Shims here keep the placeholder, and they look only in {}.",
229        looked.display()
230    );
231    println!("    Either link it where they look:");
232    println!("      ln -s {} {}", binary, looked.join("amont").display());
233    println!("    or set GIT_HOOKS_BIN in the environment git runs hooks with:");
234    println!("      export GIT_HOOKS_BIN={binary}");
235}
236
237/// `~/.local/bin`, or `$AMONT_BIN_DIR`.
238pub fn bin_dir() -> PathBuf {
239    if let Some(d) = std::env::var_os("AMONT_BIN_DIR") {
240        return PathBuf::from(d);
241    }
242    home().join(".local").join("bin")
243}
244
245/// `$XDG_CONFIG_HOME/git/git-templates/templates/hooks`.
246pub fn template_hooks_dir() -> PathBuf {
247    let base = std::env::var_os("XDG_CONFIG_HOME")
248        .map(PathBuf::from)
249        .unwrap_or_else(|| home().join(".config"));
250    base.join("git")
251        .join("git-templates")
252        .join("templates")
253        .join("hooks")
254}
255
256fn home() -> PathBuf {
257    std::env::var_os("HOME")
258        .or_else(|| std::env::var_os("USERPROFILE"))
259        .map(PathBuf::from)
260        .unwrap_or_else(|| PathBuf::from("."))
261}
262
263/// The name to install under. Windows builds `amont.exe`, and a shim testing
264/// `[ -x .../amont ]` is false for it.
265fn installed_name() -> String {
266    match std::env::current_exe() {
267        Ok(p) => name_for(&p),
268        Err(_) => "amont".to_string(),
269    }
270}
271
272/// Split from `installed_name` so it can be tested on every platform rather
273/// than only the one that produces a `.exe`. A `cfg!(windows)` assertion is
274/// vacuous on the machine most of this is written on.
275fn name_for(exe: &Path) -> String {
276    match exe.extension().and_then(|e| e.to_str()) {
277        Some(e) if !e.is_empty() => format!("amont.{e}"),
278        _ => "amont".to_string(),
279    }
280}
281
282/// Hook files in `dir` that exist and are NOT ours, each with its reason.
283///
284/// `install` used to write all four unconditionally, which silently destroyed a
285/// `commit-msg` somebody had written themselves. That is the same failure as the
286/// two that overwrote tracked files, one directory along, and it had no guard at
287/// all — the fleet's `fix` planner has one and the per-repo installer never did.
288///
289/// It carries the [`HookFile`] rather than only the name because "commit-msg is
290/// not ours" sends somebody to diff a file against a shim they have never seen,
291/// while "commit-msg is not valid UTF-8 — a compiled hook, probably" ends the
292/// question. The old version could not have said either: it read the file as a
293/// string, and a file it could not read came back as NOT foreign.
294fn foreign_hooks(dir: &Path) -> Vec<(&'static str, HookFile)> {
295    DISPATCHERS
296        .into_iter()
297        .map(|name| (name, hookfile::classify(&dir.join(name))))
298        .filter(|(_, what)| !matches!(what, HookFile::Absent | HookFile::Ours))
299        .collect()
300}
301
302/// One dispatcher that landed, and what stood at that path before it.
303///
304/// `replaced` exists so `--force` can say what it took. It printed
305/// "baked 4 shims" and nothing else, which is a receipt for an act whose whole
306/// point is that it destroys something — the user typed `--force` precisely
307/// because there was a file there, and the one thing the output never said was
308/// which files or what they were.
309#[derive(Debug)]
310pub struct Written {
311    pub path: PathBuf,
312    pub replaced: HookFile,
313}
314
315/// Why a set of shims was not written.
316#[derive(Debug)]
317pub enum ShimWriteError {
318    /// One or more paths are not ours to write. Every refusal is carried, not
319    /// just the first: somebody about to run `--force` should see all four.
320    Refused(Vec<Refuse>),
321    /// A failure before any destination was touched — an unbakeable path, or a
322    /// staging write that could not happen (no space, no permission).
323    Preflight { at: PathBuf, error: std::io::Error },
324    /// Staging succeeded and a rename did not. The only failure mode that can
325    /// leave a directory partly written, which is why it carries the lists.
326    Swap(SwapFailure),
327}
328
329impl std::fmt::Display for ShimWriteError {
330    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
331        match self {
332            ShimWriteError::Refused(refusals) => {
333                writeln!(f, "refusing to write {} hooks:", refusals.len())?;
334                for (i, r) in refusals.iter().enumerate() {
335                    if i > 0 {
336                        writeln!(f)?;
337                    }
338                    write!(f, "    {}", r.explain())?;
339                }
340                Ok(())
341            }
342            ShimWriteError::Preflight { at, error } => {
343                write!(f, "cannot prepare {}: {error}", at.display())
344            }
345            ShimWriteError::Swap(s) => write!(f, "{s}"),
346        }
347    }
348}
349
350/// Write the four dispatchers into `dir`: guard ALL, stage ALL, then swap.
351///
352/// Three phases, in that order, because the posture `bake_repo_hooks` has
353/// claimed since it was written — "fail closed, and for the whole repository
354/// rather than per file: a partial install is how a repo ends up with two of
355/// four hooks and no way to tell" — was a comment over a loop that checked one
356/// file and then wrote it, four times. A refusal on the third hook came after
357/// two had already been overwritten.
358///
359/// Now: every path is guarded before any body is written, and every body is
360/// written before any destination is touched. A refusal anywhere means nothing
361/// at all was written; a staging failure likewise. Only the swap can leave a
362/// directory partly done, and that is reported as exactly which files landed
363/// and which did not (see [`SwapFailure`]) rather than as a count.
364///
365/// Returns what was written and what each write replaced, so `--force` can name
366/// what it took.
367fn write_shims(dir: &Path, bin: &str, force: bool) -> Result<Vec<Written>, ShimWriteError> {
368    // Fail closed rather than write four hooks that resolve to nothing — or, if
369    // the repository happens to hold a file by that name, to something.
370    if !is_bakeable(bin) {
371        return Err(ShimWriteError::Preflight {
372            at: dir.to_path_buf(),
373            error: std::io::Error::other(format!(
374                "refusing to bake {bin:?}: the shim takes an absolute path only"
375            )),
376        });
377    }
378    let baked = bake(SHIM, bin);
379
380    // Phase 1 — guard every path. Nothing has been written and nothing will be
381    // if a single one of these refuses.
382    let mut allowed: Vec<(PathBuf, HookFile)> = Vec::new();
383    let mut refusals: Vec<Refuse> = Vec::new();
384    for name in DISPATCHERS {
385        let path = dir.join(name);
386        match hookfile::guard_write(&path, force) {
387            Ok(what) => allowed.push((path, what)),
388            Err(r) => refusals.push(r),
389        }
390    }
391    if !refusals.is_empty() {
392        return Err(ShimWriteError::Refused(refusals));
393    }
394
395    // Phase 2 — stage every body. A `Staged` that never lands removes its own
396    // temporary on drop, so an error here leaves the directory as it was.
397    let mut staged: Vec<Staged> = Vec::new();
398    for (path, _) in &allowed {
399        match hookfile::stage(path, &baked, true) {
400            Ok(s) => staged.push(s),
401            Err(error) => {
402                return Err(ShimWriteError::Preflight {
403                    at: path.clone(),
404                    error,
405                });
406            }
407        }
408    }
409
410    // Phase 3 — swap. Renames, so a symlinked destination is REPLACED rather
411    // than written through.
412    hookfile::commit_all(staged).map_err(ShimWriteError::Swap)?;
413    Ok(allowed
414        .into_iter()
415        .map(|(path, replaced)| Written { path, replaced })
416        .collect())
417}
418
419/// For the installed BINARY only — shims get their mode from `hookfile::stage`,
420/// before they are anywhere a hook could be dispatched from.
421#[cfg(unix)]
422fn make_executable(p: &Path) -> std::io::Result<()> {
423    use std::os::unix::fs::PermissionsExt;
424    std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o755))
425}
426
427#[cfg(not(unix))]
428fn make_executable(_p: &Path) -> std::io::Result<()> {
429    Ok(()) // Windows has no execute bit; git runs the shim through sh regardless.
430}
431
432/// Install: copy this binary somewhere stable, populate the template directory
433/// if that is safe, and bake the current repository's hooks.
434///
435/// Three steps, three functions. This was one 88-line body whose own comments
436/// numbered its sections — which is the tell that the sections wanted to be
437/// functions.
438pub fn run(force: bool) -> Result<(), String> {
439    let binary = install_binary()?;
440    populate_template_dir(&binary, force)?;
441    bake_repo_hooks(&binary, force)?;
442    offer_trust();
443    offer_agents_md();
444    point_at_setup();
445    Ok(())
446}
447
448/// `amont init` — wire up THIS repository, and touch nothing else.
449///
450/// The verb a package manager can call. `"prepare": "amont init"` in a
451/// `package.json` means a teammate who clones and runs `npm install` gets the
452/// hooks, which is the ergonomic husky has and this project did not.
453///
454/// ## Why `install` could not be that verb
455///
456/// Every one of its three extra steps is wrong for something that runs on every
457/// teammate's install, and the third is a hang:
458///
459///   * `install_binary` copies into `~/.local/bin` — a machine-level write from
460///     `npm install`;
461///   * `populate_template_dir` writes `~/.config/git/git-templates`, so a
462///     package manager would be arranging for every FUTURE clone to get hooks;
463///   * `offer_trust` calls `trust::confirm`, which opens `/dev/tty`. In a
464///     terminal that succeeds and BLOCKS, so `npm install` would stop dead on a
465///     prompt about a manifest the user has not read.
466///
467/// So this does one thing: bake four shims into the repository's own hooks
468/// directory.
469///
470/// ## What it bakes, and why not the PATH hit
471///
472/// `current_exe()`, always — never [`install_binary`]'s "is it already on
473/// `PATH`?" branch. Under npm the answer to that question is
474/// `node_modules/.bin/amont`, which is the **JS wrapper**: baking it would put a
475/// node process in front of every single commit, on a tool whose start-up cost
476/// is a stated feature. `current_exe()` is the native binary inside the platform
477/// package, which is what should run.
478///
479/// ## Silence, and its limits
480///
481/// Outside a git repository this reports nothing and exits 0. `npm install`
482/// legitimately runs where there is no `.git` — from a tarball, inside a Docker
483/// build, in CI — and failing there would make the package uninstallable in all
484/// three. It stays LOUD about everything else: a redirect, an unwritable
485/// directory and a foreign hook all still fail, because those are repositories
486/// where somebody believes they have hooks and does not.
487pub fn init() -> Result<(), String> {
488    let hooks = match repo_hooks() {
489        RepoHooks::Own(dir) => dir,
490        RepoHooks::Redirected { to, own } if redirect_is_hostile(&to, &own) => {
491            return Err(redirected_message(&to, &own))
492        }
493        RepoHooks::Redirected { to, .. } => to,
494        // The one silent exit, and the only one.
495        RepoHooks::Nowhere => return Ok(()),
496    };
497
498    let me =
499        std::env::current_exe().map_err(|e| format!("cannot locate the running binary: {e}"))?;
500    // `absolute` rather than `canonicalize`: the shim needs an absolute path and
501    // nothing more, and resolving symlinks here would bake pnpm's
502    // `.pnpm/amont-<target>@<version>/…` store path in place of the stable link
503    // the package manager maintains.
504    let binary = absolute(&me);
505    let binary = binary.to_string_lossy().into_owned();
506    if !is_bakeable(&binary) {
507        return Err(format!(
508            "{} cannot bake {binary:?} — the shim takes an absolute path only",
509            error_sign()
510        ));
511    }
512
513    std::fs::create_dir_all(&hooks)
514        .map_err(|e| format!("cannot create {}: {e}", hooks.display()))?;
515
516    // `force: false`. A hook somebody else wrote is theirs, and `init` runs
517    // unattended — there is no one at the keyboard to have decided otherwise.
518    let written = write_shims(&hooks, &binary, false)
519        .map_err(|e| format!("cannot write shims to {}: {e}", hooks.display()))?;
520    println!(
521        "{} amont: {} hooks in {}",
522        valid_sign(),
523        written.len(),
524        hooks.display()
525    );
526    Ok(())
527}
528
529/// Name the commit-style settings, once, at the moment somebody acquires them.
530///
531/// A PRINT, never a prompt. `install` has to remain answerable by nobody: it
532/// runs under `amont-fleet install --root`, in provisioning scripts, and not
533/// at all for the `init.templateDir` users whose hooks arrive with a clone. A
534/// third question would break all three; a line of output breaks none of them,
535/// and it puts the dial in front of the one person guaranteed to be reading.
536fn point_at_setup() {
537    let s = crate::commit_style::Style::resolve();
538    println!(
539        "  commit style: gitmoji {}, subject ≤{}, description ≤{} — `amont setup` to change",
540        s.gitmoji.as_str(),
541        s.subject_max,
542        s.description_max
543    );
544}
545
546/// Ask about the manifest, once, at the moment somebody is already deciding
547/// about this repository.
548///
549/// `direnv` has to ask lazily on `cd` because it has no install step to hang
550/// the question from. We have one — so this is a single question, shown with
551/// the declarations in view, and declining still leaves the built-ins working.
552///
553/// Never blocks and never fails the install: a repository that declares nothing
554/// says nothing, and a non-interactive install simply reports the state.
555fn offer_trust() {
556    // No repository, no manifest to ask about. `repo_root()` answered "." and
557    // this went looking for `./amont.conf` in whatever directory the
558    // install was run from — a file it would then have offered to trust ON
559    // BEHALF of a repository that does not exist.
560    let Ok(root) = crate::hooks::common::repo_root_checked() else {
561        return;
562    };
563    let root = Path::new(&root);
564    let state = crate::trust::state(root);
565    if matches!(
566        state,
567        crate::trust::State::NoManifest | crate::trust::State::Trusted
568    ) {
569        return;
570    }
571
572    println!();
573    println!(
574        "{} {} declares checks that would run on your commits:",
575        warning_sign(),
576        crate::manifest::MANIFEST
577    );
578    // Read ONCE, then show and fingerprint that same buffer. `confirm()` blocks
579    // on a keypress, sometimes for several seconds, and a file rewritten in
580    // that window must not be trusted under the guise of the content that was
581    // displayed — `record_verified` re-checks this fingerprint once the answer
582    // is in. Reading separately to show and to hash would leave the same gap
583    // one step earlier: the listing approved need not be the one recorded.
584    let manifest = root.join(crate::manifest::MANIFEST);
585    let Ok(source) = std::fs::read(&manifest) else {
586        println!(
587            "{} could not read {}",
588            warning_sign(),
589            crate::manifest::MANIFEST
590        );
591        return;
592    };
593    print!(
594        "{}",
595        crate::trust::describe_source(&String::from_utf8_lossy(&source))
596    );
597    let Some(fp) = crate::trust::fingerprint_bytes(root, &source) else {
598        println!(
599            "{} could not hash {}",
600            warning_sign(),
601            crate::manifest::MANIFEST
602        );
603        return;
604    };
605    if crate::trust::confirm("    Trust them? (y/N) ") {
606        match crate::trust::record_verified(root, &fp) {
607            Ok(()) => println!("{} trusted ({fp})", valid_sign()),
608            Err(e) => println!("{} {e}", warning_sign()),
609        }
610    } else {
611        println!("    Left untrusted. The built-ins still run; these do not.");
612        println!("    Change your mind with `amont trust`.");
613    }
614}
615
616/// Ask about `AGENTS.md`, once, right where `offer_trust` asks about the
617/// manifest — same reasoning, same shape: a single question with an install
618/// step to hang it from, and declining changes nothing about how the hooks
619/// themselves run.
620///
621/// This is the first thing `install` would write to TRACKED repo content —
622/// everything else here lives in `.git/hooks` (never tracked) or a
623/// machine-local path (`~/.local/bin`, the XDG template dir). That is exactly
624/// why it is a confirm, not a silent write: `crate::agents_md::write` is
625/// marker-scoped and safe to re-run, but "safe to overwrite" is not the same
626/// promise as "yours to write unasked."
627///
628/// Never blocks and never fails the install: skips silently when there is
629/// nothing to offer, and a non-interactive install simply leaves the
630/// question unanswered — `trust::confirm` already treats no tty as "no".
631fn offer_agents_md() {
632    // Same reason as `offer_trust`: with `repo_root()`'s "." fallback, an
633    // install run outside a repository offered to write an AGENTS.md into the
634    // current directory — the one thing `install` writes to TRACKED content,
635    // aimed at a directory nobody said was a project.
636    let Ok(root) = crate::hooks::common::repo_root_checked() else {
637        return;
638    };
639    let path = Path::new(&root).join("AGENTS.md");
640    match crate::agents_md::check(&path) {
641        Ok(crate::agents_md::CheckResult::MatchesGenerated) => return,
642        Ok(_) => {}
643        // Malformed markers: nothing this prompt can safely offer to fix.
644        Err(_) => return,
645    }
646
647    println!();
648    println!(
649        "{} AGENTS.md can point coding agents at `amont list --json` \
650         instead of leaving them to discover these checks the hard way:",
651        warning_sign()
652    );
653    if crate::trust::confirm("    Add it? (y/N) ") {
654        match crate::agents_md::write(&path) {
655            Ok(()) => println!("{} wrote {}", valid_sign(), path.display()),
656            Err(e) => println!("{} {e}", warning_sign()),
657        }
658    } else {
659        println!("    Left as-is. Change your mind with `amont agents-md`.");
660    }
661}
662
663/// Where this binary can already be found on `PATH`, if it can.
664///
665/// Returns the path as `PATH` exposes it — deliberately NOT the resolved one.
666/// Homebrew's `/usr/local/bin/amont` is a symlink into
667/// `/usr/local/Cellar/amont/<version>/bin/`, and that Cellar path is
668/// version-specific and removed on upgrade. Baking it would pin every repo to a
669/// version that is about to be deleted, which is worse than the copy this
670/// function exists to avoid. The same is true of any versioned store — nix,
671/// asdf, mise.
672///
673/// So the comparison is canonical (to recognise ourselves through the symlink)
674/// while the value returned is the entry that led here. That also makes the
675/// answer correct whichever way `current_exe()` behaves: it resolves symlinks
676/// on some platforms and libcs and not others, and this never has to care.
677fn on_path_already(me: &Path) -> Option<PathBuf> {
678    let me_real = me.canonicalize().ok()?;
679    let name = installed_name();
680    let path = std::env::var_os("PATH")?;
681    std::env::split_paths(&path)
682        .map(|dir| dir.join(&name))
683        .filter(|cand| !in_a_build_dir(cand))
684        .find(|cand| cand.canonicalize().is_ok_and(|real| real == me_real))
685        .map(|cand| absolute(&cand))
686        .filter(|abs| is_bakeable(&abs.to_string_lossy()))
687}
688
689/// Whether `p` sits inside a cargo build directory.
690///
691/// "On PATH" alone is not the question — the question is whether the path will
692/// still be there tomorrow, and a build directory is precisely the one that
693/// will not. `cargo clean`, or any rebuild, and the shims baked against it
694/// resolve nothing.
695///
696/// This is not hypothetical and it is not only about `cargo run`: **cargo
697/// prepends the build directory to PATH when it runs tests on Windows**, so
698/// `target/debug` genuinely appears there. That took out four existing install
699/// tests on the Windows runner and nowhere else, which is a fair description of
700/// how the loose predicate would have failed a user, too.
701///
702/// `CACHEDIR.TAG` is cargo's own marker for the directory, written since 1.55
703/// and standardised for exactly this — "a program wrote this, do not treat it
704/// as durable". Asking for it beats matching on the name `target`, which is
705/// configurable and is also an ordinary word for a directory. Bounded to a few
706/// levels so a stray tag high up somebody's home directory cannot disqualify
707/// every path on the system.
708fn in_a_build_dir(p: &Path) -> bool {
709    p.ancestors()
710        .skip(1)
711        .take(4)
712        .any(|dir| dir.join("CACHEDIR.TAG").is_file())
713}
714
715/// Copy the running binary to a stable location, and return where it now lives.
716fn install_binary() -> Result<String, String> {
717    let me =
718        std::env::current_exe().map_err(|e| format!("cannot locate the running binary: {e}"))?;
719
720    // A binary a package manager already put on PATH is not ours to copy.
721    //
722    // The copy below exists for `./target/release/amont install`, where the
723    // binary sits in a directory `cargo clean` will delete — baking that path
724    // would install hooks that stop resolving the next time somebody builds.
725    // For `brew install`, `cargo install` or a distro package, the opposite is
726    // true: the binary is already somewhere stable, and copying it produces a
727    // SECOND, unmanaged copy that the package manager will never update again.
728    //
729    // That is not hypothetical. It is what this machine was in: `brew upgrade`
730    // would have refreshed /usr/local/bin while every repo stayed baked to a
731    // frozen copy in ~/.local/bin — the same staleness the copy is meant to
732    // prevent, arrived at from the other direction.
733    //
734    // `$AMONT_BIN_DIR` is checked first because setting it IS the request to
735    // put the binary somewhere specific, and honouring it costs nothing.
736    if std::env::var_os("AMONT_BIN_DIR").is_none() {
737        if let Some(stable) = on_path_already(&me) {
738            let shown = stable.to_string_lossy().into_owned();
739            println!("{} using {}", valid_sign(), highlight(&shown));
740            println!("    already on PATH, so nothing was copied — an upgrade there");
741            println!("    reaches every repository without reinstalling.");
742            return Ok(shown);
743        }
744    }
745
746    let dir = bin_dir();
747    std::fs::create_dir_all(&dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
748
749    // Absolute from here on: this path is what gets baked into every shim, and
750    // `bin_dir()` honours `$AMONT_BIN_DIR`, which may be relative.
751    let target = absolute(&dir.join(installed_name()));
752    // Copying a running binary over ITSELF fails on some platforms and is
753    // pointless on all of them.
754    //
755    // `me.canonicalize().ok() == target.canonicalize().ok()` is the version
756    // this replaces, and it was wrong in the one case that matters: when the
757    // TARGET does not exist yet — a first install, the whole point of the
758    // step — `canonicalize` returns `Err`, both sides are `None`, `None ==
759    // None` is true, and the copy was skipped. The binary was never installed,
760    // and `install` printed "installed <path>" for a file that was not there.
761    // Every shim then baked that path and resolved nothing. Two `Ok`s that
762    // agree is the only thing that means "same file".
763    let already_there = matches!(
764        (me.canonicalize(), target.canonicalize()),
765        (Ok(a), Ok(b)) if a == b
766    );
767    if !already_there {
768        std::fs::copy(&me, &target)
769            .map_err(|e| format!("cannot install to {}: {e}", target.display()))?;
770        make_executable(&target).map_err(|e| format!("cannot chmod {}: {e}", target.display()))?;
771    }
772    let installed = target.to_string_lossy().into_owned();
773    println!("{} installed {}", valid_sign(), highlight(&installed));
774    Ok(installed)
775}
776
777/// Write the shims into the template directory — unless doing so would delete
778/// somebody's source.
779///
780/// REFUSING is not an error: on a machine where the template dir is the
781/// checkout, there is nothing to install and the install has succeeded. FAILING
782/// to write one it was allowed to write is, though — reporting success after a
783/// step did not happen is the thing this whole codebase is arranged against.
784fn populate_template_dir(binary: &str, force: bool) -> Result<(), String> {
785    let dir = template_hooks_dir();
786    let _ = std::fs::create_dir_all(&dir);
787    // Report the RESOLVED path. "It is the checkout" is only useful with the
788    // checkout named, and the configured path is usually the symlink that hides
789    // exactly that.
790    let shown = dir.canonicalize().unwrap_or_else(|_| dir.clone());
791    let shown = shown.display();
792
793    match classify_dir(&dir) {
794        TemplateDir::IsCheckout => {
795            println!(
796                "{} template dir IS the checkout ({shown}) — nothing to install.",
797                warning_sign()
798            );
799            println!("    Its shims keep the placeholder deliberately and resolve");
800            println!("    {binary} at run time. This is the intended setup.");
801            // …as long as run-time resolution can actually reach the binary,
802            // which the sentence above used to assert unconditionally.
803            warn_if_unbaked_cannot_resolve(binary);
804        }
805        TemplateDir::InsideCheckout => {
806            println!(
807                "{} {shown} is inside a git checkout — leaving it alone.",
808                warning_sign()
809            );
810            warn_if_unbaked_cannot_resolve(binary);
811        }
812        TemplateDir::NoGit => println!(
813            "{} git is not on PATH — refusing to delete anything.",
814            warning_sign()
815        ),
816        TemplateDir::Unresolvable => {
817            println!("{} cannot resolve {shown} — skipping.", warning_sign())
818        }
819        TemplateDir::Safe => {
820            let written = write_shims(&dir, binary, force)
821                .map_err(|e| format!("cannot write shims to {shown}: {e}"))?;
822            println!("{} wrote {} shims to {shown}", valid_sign(), written.len());
823            report_overwrites(&written);
824        }
825    }
826    Ok(())
827}
828
829/// Say what each write took, for the writes that took something.
830///
831/// `install --force` used to print `baked 4 shims` and stop. `--force` is
832/// typed precisely because a file is in the way, so the one fact the output
833/// omitted is the only fact the user needed: which files, and what they were.
834/// A hook replaced with no record of what it was is unrecoverable — `.git` is
835/// not tracked, so there is nothing to `git checkout` it back from.
836fn report_overwrites(written: &[Written]) {
837    for w in written {
838        if matches!(w.replaced, HookFile::Absent | HookFile::Ours) {
839            continue;
840        }
841        println!(
842            "{} overwrote {} — it was {}",
843            warning_sign(),
844            w.path.display(),
845            w.replaced.describe()
846        );
847    }
848}
849
850/// Where git dispatches hooks from, and whether that is this repository's OWN
851/// hooks directory or somewhere `core.hooksPath` sent it.
852///
853/// This replaces a bare `repo_hooks_dir()` that returned only the dispatch path.
854/// `--git-path hooks` is still the right question — never `--git-dir` plus
855/// `join("hooks")`, because hooks are explicitly SHARED across worktrees while a
856/// linked worktree's `--git-dir` is its own PRIVATE gitdir, so joining "hooks"
857/// onto it names a directory git never dispatches from. The addition is asking
858/// `--git-common-dir` alongside it, so the answer can be compared against the
859/// directory that would be ours.
860///
861/// The distinction did not exist and its absence was silent. `--git-path hooks`
862/// honours `core.hooksPath`, so in a repository running husky it answers
863/// `.husky/_` — inside the repo, plausible, and wrong. `install` wrote four
864/// shims there, husky's own `prepare` regenerated the directory on the next
865/// `npm install`, and the repository went back to having no checks with nothing
866/// to show for it. Every guarantee this tool makes was off in those repositories
867/// and the fleet reported them as merely "drifted".
868#[derive(Debug, Clone, PartialEq, Eq)]
869pub enum RepoHooks {
870    /// `<git-common-dir>/hooks`. Git dispatches from here and it is ours to write.
871    Own(PathBuf),
872    /// `core.hooksPath` points somewhere else. Another tool owns dispatch here,
873    /// and writing to `own` would install shims git never runs.
874    Redirected { to: PathBuf, own: PathBuf },
875    /// Not in a repository, or git would not answer.
876    Nowhere,
877}
878
879/// Ask git both questions at once — what it dispatches from, and what this
880/// repository's own hooks directory is — then compare.
881///
882/// Lexical comparison via [`crate::hookfile::resolve_lexical`], not
883/// `canonicalize`: neither directory is guaranteed to exist yet (a fresh clone
884/// has no `.git/hooks` until something writes one), and `canonicalize` cannot be
885/// asked about a path that does not. Same reason `is_within` is lexical.
886pub fn repo_hooks() -> RepoHooks {
887    let Some(out) = crate::git::stdout(&[
888        "rev-parse",
889        "--path-format=absolute",
890        "--git-path",
891        "hooks",
892        "--git-common-dir",
893    ]) else {
894        return RepoHooks::Nowhere;
895    };
896    let mut lines = out.lines();
897    let (Some(dispatched), Some(common)) = (lines.next(), lines.next()) else {
898        return RepoHooks::Nowhere;
899    };
900    let dispatched = PathBuf::from(dispatched);
901    let own = PathBuf::from(common).join("hooks");
902    if crate::hookfile::resolve_lexical(&dispatched) == crate::hookfile::resolve_lexical(&own) {
903        RepoHooks::Own(own)
904    } else {
905        RepoHooks::Redirected {
906            to: dispatched,
907            own,
908        }
909    }
910}
911
912/// Name the tool behind a `core.hooksPath`, when the path gives it away.
913///
914/// A short list on purpose. It is half of [`redirect_is_hostile`]'s evidence,
915/// not a general classifier, and a name guessed wrong is worse than none.
916pub fn redirect_culprit(to: &Path) -> Option<&'static str> {
917    let s = to.to_string_lossy().replace('\\', "/");
918    if s.contains("/.husky") {
919        return Some("husky");
920    }
921    if s.contains("/.lefthook") || s.contains("lefthook") {
922        return Some("lefthook");
923    }
924    None
925}
926
927/// Whether any of our four shims sits in `dir`.
928///
929/// The runtime's own copy of the question `amont-fleet::scan::is_managed`
930/// answers, because the guard that needs it runs in the commit-path crate and
931/// cannot depend on the dashboard.
932pub fn holds_our_shims(dir: &Path) -> bool {
933    DISPATCHERS
934        .iter()
935        .any(|name| matches!(hookfile::classify(&dir.join(name)), HookFile::Ours))
936}
937
938/// Whether a redirect must be REFUSED rather than followed.
939///
940/// Not every `core.hooksPath` is a problem, and the first cut of this refused
941/// them all — which would have broken a repository that deliberately keeps its
942/// hooks in `tooling/hooks` under version control. That is a setup this project
943/// has always honoured and `plan_finds_shims_at_a_redirected_hooks_path` pins.
944///
945/// So the refusal rests on evidence, not on the mere presence of the setting.
946/// Either is enough:
947///
948///   * **the destination belongs to a hook manager we recognise** — husky and
949///     lefthook both REGENERATE their directory on install, so anything we wrote
950///     there is gone by the next `npm install` and the repository silently stops
951///     being checked;
952///   * **our shims are already in the repository's own hooks directory** — amont
953///     was installed here and something later took dispatch away. Whatever the
954///     destination is, this repository is not running the checks it believes it
955///     is, and that is worth stopping for whether or not we can name the culprit.
956///
957/// A repository with neither signal keeps the old behaviour exactly.
958pub fn redirect_is_hostile(to: &Path, own: &Path) -> bool {
959    redirect_culprit(to).is_some() || holds_our_shims(own)
960}
961
962/// The refusal both `install` and `init` give when another tool owns dispatch.
963///
964/// One function, so the two cannot drift into saying different things about the
965/// same situation — and so the remedy is spelled exactly once.
966pub fn redirected_message(to: &Path, own: &Path) -> String {
967    let mut msg = format!(
968        "{} git dispatches hooks from {}, not {}",
969        error_sign(),
970        highlight(&to.display().to_string()),
971        own.display()
972    );
973    match redirect_culprit(to) {
974        Some(tool) => msg.push_str(&format!(
975            "\n    `core.hooksPath` is set, so {tool} owns the hooks here. Shims\n    \
976             written to either directory would be overwritten or never run."
977        )),
978        None => msg.push_str(
979            "\n    `core.hooksPath` is set, so another tool owns the hooks here.\n    \
980             Shims written to either directory would be overwritten or never run.",
981        ),
982    }
983    msg.push_str(&format!(
984        "\n    Hand dispatch back first: {}",
985        highlight("git config --unset core.hooksPath")
986    ));
987    msg
988}
989
990/// Bake the shims into the repository we are standing in, if we are in one.
991///
992/// The tracked guard is inherited from `hookfile::guard_write` rather than
993/// written here, and that inheritance closes a verified bug: with
994/// `.git/hooks/pre-commit` a symlink to a TRACKED `devhooks/pre-commit`,
995/// `install --force` rewrote the tracked source file. Every guard this function
996/// had was about the LINK path — untracked, inside `.git`, unremarkable — while
997/// `fs::write` followed the link and landed in the working tree. Both halves
998/// are fixed at once: the symlink is refused by name, and `--force` replaces
999/// the link by rename instead of writing through it.
1000fn bake_repo_hooks(binary: &str, force: bool) -> Result<(), String> {
1001    let hooks = match repo_hooks() {
1002        RepoHooks::Own(dir) => dir,
1003        // A hostile redirect is refused, and `--force` does not move it.
1004        // `--force` means "that file is mine to replace"; it has never meant
1005        // "write where git does not look". Writing `own` would leave four shims
1006        // git never dispatches, and writing `to` would hand our files to the
1007        // tool that regenerates that directory.
1008        //
1009        // A redirect that is merely a redirect is followed, as it always was —
1010        // see `redirect_is_hostile` for where the line is.
1011        RepoHooks::Redirected { to, own } if redirect_is_hostile(&to, &own) => {
1012            return Err(redirected_message(&to, &own))
1013        }
1014        RepoHooks::Redirected { to, .. } => to,
1015        RepoHooks::Nowhere => {
1016            println!(
1017                "{} not inside a git repository — no repo hooks written.",
1018                warning_sign()
1019            );
1020            return Ok(());
1021        }
1022    };
1023    let _ = std::fs::create_dir_all(&hooks);
1024
1025    // Asked here, ahead of the guard, only so the message can offer `--force`.
1026    // The guard inside `write_shims` is the one that decides, and it refuses
1027    // things `--force` will not move (a tracked path, a path git cannot answer
1028    // for) which this pre-check deliberately says nothing about.
1029    let foreign = foreign_hooks(&hooks);
1030    if !foreign.is_empty() && !force {
1031        let mut msg = format!(
1032            "{} {} already has hooks that are not ours:",
1033            error_sign(),
1034            hooks.display()
1035        );
1036        for (name, what) in &foreign {
1037            msg.push_str(&format!("\n    {name} — {}", what.describe()));
1038        }
1039        msg.push_str("\n    Look at them first, then `amont install --force`.");
1040        return Err(msg);
1041    }
1042
1043    let written = write_shims(&hooks, binary, force)
1044        .map_err(|e| format!("cannot write shims to {}: {e}", hooks.display()))?;
1045    println!(
1046        "{} baked {} shims into {}",
1047        valid_sign(),
1048        written.len(),
1049        hooks.display()
1050    );
1051    report_overwrites(&written);
1052    Ok(())
1053}
1054
1055/// Take the shims out of the repository we are standing in.
1056///
1057/// Deliberately narrow. It removes files that are OURS and nothing else:
1058///
1059/// - a hook we did not write is left alone and named, because somebody wrote it
1060///   on purpose;
1061/// - `hook.skip` and `amont.severity` are never touched — those are the
1062///   user's statements about their own repository, not our artefacts, and a
1063///   reinstall should not silently forget that they disabled a check;
1064/// - the binary goes only when asked, because other repositories are using it.
1065pub fn uninstall(remove_binary: bool) -> Result<(), String> {
1066    // The template directory FIRST, and unconditionally, because it is the only
1067    // part of an install that keeps working when you are not standing in a
1068    // repository — and because `uninstall` returning early with "not inside a
1069    // git repository" is how the standing grant survived every attempt to
1070    // revoke it.
1071    uninstall_template_dir()?;
1072    uninstall_repo_hooks()?;
1073
1074    if remove_binary {
1075        let target = bin_dir().join(installed_name());
1076        match std::fs::remove_file(&target) {
1077            Ok(()) => println!(
1078                "{} removed {}",
1079                valid_sign(),
1080                highlight(&target.to_string_lossy())
1081            ),
1082            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
1083            Err(e) => return Err(format!("cannot remove {}: {e}", target.display())),
1084        }
1085    }
1086
1087    report_global_template_dir();
1088
1089    // Said out loud, because a user who uninstalls and reinstalls should not be
1090    // surprised that a check they disabled is still disabled.
1091    println!("    hook.skip and amont.severity were not touched.");
1092    Ok(())
1093}
1094
1095/// Remove our shims from the repository we are standing in, naming everything
1096/// we did not take and why.
1097///
1098/// Every non-removal is now NAMED. The loop this replaces matched
1099/// `Err(_) => {}` on `read_to_string`, so a hook that could not be read at all
1100/// — a compiled one, or one whose permissions we lack — was passed over in
1101/// total silence: not removed, not counted, not mentioned. The README's promise
1102/// that a foreign hook is "left alone and named" was true only for hooks that
1103/// happened to be valid UTF-8.
1104///
1105/// Not being in a repository is a warning rather than an error. It used to
1106/// return `Err`, which was defensible on its own but became wrong once
1107/// `uninstall_template_dir` existed: the early return meant that running
1108/// `amont uninstall` from a plain directory did nothing AND said nothing,
1109/// while `init.templateDir` quietly went on installing hooks into every future
1110/// clone. Refusing is not failing — the same rule `populate_template_dir`
1111/// already states.
1112fn uninstall_repo_hooks() -> Result<(), String> {
1113    // BOTH directories, where they differ. Uninstall is the one path that must
1114    // not inherit `install`'s new refusal: versions before it wrote shims into
1115    // whatever `core.hooksPath` named, so a repository can be carrying our files
1116    // in `.husky/_` right now — and refusing to look there would leave the only
1117    // command that removes them unable to find them. Removal is safe in a way
1118    // writing is not; `guard_remove` still decides file by file.
1119    let dirs: Vec<PathBuf> = match repo_hooks() {
1120        RepoHooks::Own(dir) => vec![dir],
1121        RepoHooks::Redirected { to, own } => vec![to, own],
1122        RepoHooks::Nowhere => {
1123            println!(
1124                "{} not inside a git repository — no repo hooks removed.",
1125                warning_sign()
1126            );
1127            return Ok(());
1128        }
1129    };
1130
1131    for hooks in &dirs {
1132        let mut removed = 0usize;
1133        let mut left: Vec<String> = Vec::new();
1134        for name in DISPATCHERS {
1135            let path = hooks.join(name);
1136            match hookfile::classify(&path) {
1137                HookFile::Absent => {}
1138                HookFile::Ours => match hookfile::guard_remove(&path, true) {
1139                    Ok(()) => {
1140                        hookfile::remove_regular(&path)
1141                            .map_err(|e| format!("cannot remove {}: {e}", path.display()))?;
1142                        removed += 1;
1143                    }
1144                    // Ours by marker, and still not ours to delete: a tracked
1145                    // path, or one git could not answer for.
1146                    Err(r) => left.push(r.explain()),
1147                },
1148                what => left.push(format!("{name} — {}", what.describe())),
1149            }
1150        }
1151        // The second directory is usually empty of ours and saying so every time
1152        // would be noise. Report it only when it held something.
1153        if removed > 0 || !left.is_empty() || dirs.len() == 1 {
1154            println!(
1155                "{} removed {removed} shims from {}",
1156                valid_sign(),
1157                hooks.display()
1158            );
1159        }
1160        for reason in &left {
1161            println!("{} left alone: {reason}", warning_sign());
1162        }
1163    }
1164    Ok(())
1165}
1166
1167/// Take our shims back out of the template directory.
1168///
1169/// `install` writes there; `uninstall` did not, which meant uninstall did not
1170/// undo install. Combined with `init.templateDir`, that is the failure worth
1171/// spelling out: the user runs `amont uninstall`, sees "removed 4 shims",
1172/// believes they are done — and every `git clone` and `git init` from then on
1173/// copies the template directory into the new repository's `.git/hooks` and
1174/// installs the hooks again. They uninstalled a repository, not a machine.
1175///
1176/// The same classification `install` uses decides what may happen here, for the
1177/// same reason and with the sharper stake: `~/.config/git/git-templates` is
1178/// commonly a SYMLINK to a checkout of this repository, and "uninstalling"
1179/// there means `rm` on tracked source. That is not a hypothetical; it is the
1180/// two incidents this module exists because of, and a delete has no `--force`.
1181fn uninstall_template_dir() -> Result<(), String> {
1182    let dir = template_hooks_dir();
1183    // The RESOLVED path, because the configured one is usually the symlink that
1184    // hides exactly what we are about to explain.
1185    let shown = dir.canonicalize().unwrap_or_else(|_| dir.clone());
1186    let shown = shown.display();
1187
1188    match classify_dir(&dir) {
1189        TemplateDir::IsCheckout | TemplateDir::InsideCheckout => {
1190            println!(
1191                "{} template dir is a git checkout ({shown}) — deleting NOTHING there.",
1192                warning_sign()
1193            );
1194            println!("    Those shims are tracked files belonging to that checkout,");
1195            println!("    not something this install put there. Remove them with git,");
1196            println!("    or point init.templateDir somewhere else.");
1197        }
1198        TemplateDir::NoGit => println!(
1199            "{} git is not on PATH — cannot tell whether {shown} is a checkout, deleting nothing.",
1200            warning_sign()
1201        ),
1202        TemplateDir::Unresolvable => println!(
1203            "{} no template dir at {shown} — nothing to remove.",
1204            warning_sign()
1205        ),
1206        TemplateDir::Safe => {
1207            let mut removed = 0usize;
1208            let mut left: Vec<String> = Vec::new();
1209            for name in DISPATCHERS {
1210                let path = dir.join(name);
1211                match hookfile::classify(&path) {
1212                    HookFile::Absent => {}
1213                    HookFile::Ours => match hookfile::guard_remove(&path, true) {
1214                        Ok(()) => {
1215                            hookfile::remove_regular(&path)
1216                                .map_err(|e| format!("cannot remove {}: {e}", path.display()))?;
1217                            removed += 1;
1218                        }
1219                        Err(r) => left.push(r.explain()),
1220                    },
1221                    what => left.push(format!("{name} — {}", what.describe())),
1222                }
1223            }
1224            println!("{} removed {removed} shims from {shown}", valid_sign());
1225            for reason in &left {
1226                println!("{} left alone: {reason}", warning_sign());
1227            }
1228        }
1229    }
1230    Ok(())
1231}
1232
1233/// Say, every time, whether `init.templateDir` is still pointing at us.
1234///
1235/// UNCONDITIONAL, including when the template dir was a checkout we refused to
1236/// touch and when there was no template dir at all — because the config setting
1237/// is what actually installs hooks into new repositories, and it survives every
1238/// file this command removes. An uninstall that leaves it set has not
1239/// uninstalled anything durable: the next `git clone` re-installs.
1240///
1241/// Printed rather than unset. `git config --global` is the user's file, holding
1242/// their identity and their aliases, and reaching into it uninvited is a larger
1243/// claim than removing files this tool wrote. The command to run is given
1244/// verbatim so it is a copy rather than a lookup.
1245fn report_global_template_dir() {
1246    let Some(configured) = crate::git::stdout(&["config", "--global", "--get", "init.templateDir"])
1247        .filter(|s| !s.is_empty())
1248    else {
1249        return;
1250    };
1251    println!();
1252    println!(
1253        "{} init.templateDir is still set: {}",
1254        warning_sign(),
1255        highlight(&configured)
1256    );
1257    println!("    Every `git clone` and `git init` still copies hooks from there");
1258    println!("    into the new repository. Uninstalling this repo did not change that.");
1259    println!("    Undo it with:");
1260    println!(
1261        "        {}",
1262        highlight("git config --global --unset init.templateDir")
1263    );
1264}
1265
1266#[cfg(test)]
1267mod tests {
1268    use super::*;
1269
1270    fn tmp(name: &str) -> PathBuf {
1271        let d = std::env::temp_dir().join(format!("gh-install-{name}-{}", std::process::id()));
1272        let _ = std::fs::remove_dir_all(&d);
1273        std::fs::create_dir_all(&d).expect("mkdir");
1274        d
1275    }
1276
1277    fn git(dir: &Path, args: &[&str]) {
1278        Command::new("git")
1279            .arg("-C")
1280            .arg(dir)
1281            .args(args)
1282            .output()
1283            .expect("git");
1284    }
1285
1286    /// The embedded shim must be the shim that ships. `include_str!` takes one
1287    /// of the four; if they ever diverge, the installer would write a file
1288    /// nobody reviewed.
1289    #[test]
1290    fn shims_on_disk_match_the_embedded_one() {
1291        let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../templates/hooks");
1292        // Absent when this crate is built from its PUBLISHED tarball, which
1293        // contains this directory and nothing above it. There is no drift to
1294        // catch in that situation — the only shim present is the one compiled
1295        // in — so say so rather than fail a test about a file that is not
1296        // supposed to be there.
1297        if !Path::new(dir).is_dir() {
1298            println!(
1299                "! no repository checkout here — nothing to compare the embedded shim against"
1300            );
1301            return;
1302        }
1303        for name in DISPATCHERS {
1304            let disk = std::fs::read_to_string(Path::new(dir).join(name))
1305                .unwrap_or_else(|e| panic!("read {name}: {e}"));
1306            assert_eq!(disk, SHIM, "{name} differs from the embedded shim");
1307        }
1308    }
1309
1310    /// The whole point of the module. A directory holding tracked files is the
1311    /// source checkout reached through a symlink, and emptying it destroys work.
1312    #[test]
1313    fn a_directory_holding_tracked_files_is_never_safe() {
1314        let d = tmp("tracked");
1315        git(&d, &["init", "-q", "--template=", "."]);
1316        git(&d, &["config", "user.email", "t@t.test"]);
1317        git(&d, &["config", "user.name", "t"]);
1318        std::fs::write(d.join("kept.txt"), "precious\n").expect("write");
1319        git(&d, &["add", "-A"]);
1320        git(&d, &["commit", "-qm", "seed"]);
1321
1322        assert_eq!(classify_dir(&d), TemplateDir::IsCheckout);
1323        let _ = std::fs::remove_dir_all(&d);
1324    }
1325
1326    /// A path comparison against the source tree passes here and is WRONG: a
1327    /// worktree is a different path holding the same tracked files. This is the
1328    /// case that caused the second incident.
1329    #[test]
1330    fn a_worktree_is_recognised_even_though_its_path_differs() {
1331        let d = tmp("wt-main");
1332        git(&d, &["init", "-q", "--template=", "."]);
1333        git(&d, &["config", "user.email", "t@t.test"]);
1334        git(&d, &["config", "user.name", "t"]);
1335        std::fs::write(d.join("kept.txt"), "precious\n").expect("write");
1336        git(&d, &["add", "-A"]);
1337        git(&d, &["commit", "-qm", "seed"]);
1338
1339        let wt = d.with_extension("wt");
1340        let _ = std::fs::remove_dir_all(&wt);
1341        git(&d, &["worktree", "add", "-q", wt.to_str().unwrap()]);
1342        assert!(
1343            wt.join("kept.txt").is_file(),
1344            "worktree did not materialise"
1345        );
1346        assert_ne!(d.canonicalize().ok(), wt.canonicalize().ok());
1347        assert_eq!(
1348            classify_dir(&wt),
1349            TemplateDir::IsCheckout,
1350            "a worktree must be refused exactly like the main checkout"
1351        );
1352        let _ = std::fs::remove_dir_all(&wt);
1353        let _ = std::fs::remove_dir_all(&d);
1354    }
1355
1356    /// Inside a checkout but tracking nothing here — still not ours to empty.
1357    #[test]
1358    fn an_untracked_directory_inside_a_checkout_is_refused() {
1359        let d = tmp("inside");
1360        git(&d, &["init", "-q", "--template=", "."]);
1361        let sub = d.join("scratch");
1362        std::fs::create_dir_all(&sub).expect("mkdir");
1363        assert_eq!(classify_dir(&sub), TemplateDir::InsideCheckout);
1364        let _ = std::fs::remove_dir_all(&d);
1365    }
1366
1367    #[test]
1368    fn an_ordinary_directory_is_safe() {
1369        let d = tmp("plain");
1370        assert_eq!(classify_dir(&d), TemplateDir::Safe);
1371        let _ = std::fs::remove_dir_all(&d);
1372    }
1373
1374    #[test]
1375    fn a_missing_directory_is_unresolvable_not_safe() {
1376        assert_eq!(
1377            classify_dir(Path::new("/nonexistent-install-c8f2/hooks")),
1378            TemplateDir::Unresolvable
1379        );
1380    }
1381
1382    /// Baking replaces every occurrence and is idempotent.
1383    #[test]
1384    fn baking_is_total_and_idempotent() {
1385        let once = bake(SHIM, "/opt/amont");
1386        assert!(!once.contains(PLACEHOLDER), "a token survived baking");
1387        assert!(once.contains("/opt/amont"));
1388        assert_eq!(bake(&once, "/other"), once, "re-baking must be a no-op");
1389    }
1390
1391    /// The shim's comment must not spell the token out, or a global replace
1392    /// turns the explanation into a machine path — which it did, in every shim
1393    /// baked before this module existed.
1394    #[test]
1395    fn baking_does_not_rewrite_the_comment_explaining_it() {
1396        for line in bake(SHIM, "/opt/amont").lines() {
1397            if line.trim_start().starts_with('#') {
1398                assert!(
1399                    !line.contains("/opt/amont"),
1400                    "baking rewrote a comment: {line}"
1401                );
1402            }
1403        }
1404    }
1405
1406    /// Only an absolute path may be baked.
1407    ///
1408    /// A relative one is resolved by the shim against the WORKING TREE, so a
1409    /// repository shipping an executable by that name would be running it on
1410    /// the first commit after clone.
1411    #[test]
1412    fn only_an_absolute_path_is_bakeable() {
1413        for good in [
1414            "/opt/amont",
1415            "/home/u/.local/bin/amont",
1416            "C:/Users/u/amont.exe",
1417            "C:\\Users\\u\\amont.exe",
1418        ] {
1419            assert!(is_bakeable(good), "{good} should be bakeable");
1420        }
1421        for bad in [
1422            "",
1423            PLACEHOLDER,
1424            "amont",
1425            "./amont",
1426            "../amont",
1427            "target/debug/amont",
1428            "C:amont.exe",
1429        ] {
1430            assert!(!is_bakeable(bad), "{bad:?} must not be bakeable");
1431        }
1432    }
1433
1434    /// The shim must never hand the unsubstituted token to `[ -x ]`: that is a
1435    /// filesystem question asked in the repository's own directory.
1436    #[test]
1437    fn the_shim_never_tests_the_placeholder_as_a_path() {
1438        assert!(
1439            !SHIM.contains(&format!("[ -x \"{PLACEHOLDER}\" ]")),
1440            "the shim tests the raw token as a path"
1441        );
1442        assert!(
1443            SHIM.contains("case \"$BAKED\" in"),
1444            "the shim lost its absoluteness guard"
1445        );
1446    }
1447
1448    /// Refusing beats writing four hooks that cannot resolve their binary.
1449    #[test]
1450    fn write_shims_refuses_a_relative_binary_path() {
1451        let d = tmp("relative");
1452        let err = write_shims(&d, "target/debug/amont", false).expect_err("must refuse");
1453        assert!(err.to_string().contains("absolute"), "{err}");
1454        for name in DISPATCHERS {
1455            assert!(!d.join(name).exists(), "{name} was written anyway");
1456        }
1457        let _ = std::fs::remove_dir_all(&d);
1458    }
1459
1460    /// Every hook git invokes gets a file, and each is the baked shim.
1461    #[test]
1462    fn writing_shims_covers_every_dispatcher() {
1463        let d = tmp("write");
1464        let written = write_shims(&d, "/opt/amont", false).expect("write");
1465        assert_eq!(written.len(), DISPATCHERS.len());
1466        for name in DISPATCHERS {
1467            let got = std::fs::read_to_string(d.join(name)).expect("read");
1468            assert!(!got.contains(PLACEHOLDER), "{name} was written unbaked");
1469            assert!(got.contains("/opt/amont"), "{name} has no path");
1470        }
1471        let _ = std::fs::remove_dir_all(&d);
1472    }
1473
1474    /// The whole-repository posture, at the level of the function that owes it:
1475    /// ONE unwritable path and nothing at all is written. `bake_repo_hooks` has
1476    /// claimed this in a comment since it was written, over a loop that checked
1477    /// one file then wrote it, four times over — so a refusal on the third hook
1478    /// arrived after two were already gone.
1479    #[test]
1480    fn one_refusal_writes_nothing_at_all() {
1481        let d = tmp("all-or-nothing");
1482        // `prepare-commit-msg` sorts last among the dispatchers, so under the
1483        // old check-then-write loop the first three would already be on disk by
1484        // the time this one refused.
1485        let theirs = d.join("prepare-commit-msg");
1486        std::fs::write(&theirs, "#!/bin/sh\necho MINE\n").expect("write");
1487
1488        let err = write_shims(&d, "/opt/amont", false).expect_err("must refuse");
1489        assert!(
1490            matches!(err, ShimWriteError::Refused(ref rs) if rs.len() == 1),
1491            "{err}"
1492        );
1493        for name in ["commit-msg", "pre-commit", "pre-push"] {
1494            assert!(
1495                !d.join(name).exists(),
1496                "{name} was written despite a refusal elsewhere"
1497            );
1498        }
1499        assert_eq!(
1500            std::fs::read_to_string(&theirs).expect("read"),
1501            "#!/bin/sh\necho MINE\n"
1502        );
1503        let _ = std::fs::remove_dir_all(&d);
1504    }
1505
1506    /// A refusal has to say WHAT was in the way, not only that something was.
1507    /// The old predicate could not: it read the file as a string, so the one
1508    /// case worth naming — a compiled hook — came back as "not foreign" and was
1509    /// overwritten in silence.
1510    #[test]
1511    fn a_refusal_names_the_reason_for_each_hook() {
1512        let d = tmp("named");
1513        std::fs::write(d.join("commit-msg"), [0x7f, b'E', b'L', b'F', 0xff]).expect("write");
1514        std::fs::write(d.join("pre-commit"), "#!/bin/sh\necho mine\n").expect("write");
1515
1516        let err = write_shims(&d, "/opt/amont", false).expect_err("must refuse");
1517        let text = err.to_string();
1518        assert!(text.contains("not valid UTF-8"), "{text}");
1519        assert!(text.contains("commit-msg"), "{text}");
1520        assert!(text.contains("pre-commit"), "{text}");
1521
1522        // And `foreign_hooks` — which is what phrases the `--force` offer —
1523        // agrees about both.
1524        let foreign = foreign_hooks(&d);
1525        assert_eq!(foreign.len(), 2, "{foreign:?}");
1526        assert!(foreign
1527            .iter()
1528            .any(|(n, w)| *n == "commit-msg" && matches!(w, HookFile::Foreign(_))));
1529        let _ = std::fs::remove_dir_all(&d);
1530    }
1531
1532    /// `--force` says what it took, per file, with what it was. Without this
1533    /// the output was "baked 4 shims" — a receipt with the transaction left
1534    /// off, for the one operation whose purpose is to destroy something.
1535    #[test]
1536    fn force_reports_what_each_write_replaced() {
1537        let d = tmp("force-report");
1538        std::fs::write(d.join("commit-msg"), "#!/bin/sh\necho mine\n").expect("write");
1539        let written = write_shims(&d, "/opt/amont", true).expect("force must write");
1540        let replaced: Vec<_> = written
1541            .iter()
1542            .filter(|w| !matches!(w.replaced, HookFile::Absent))
1543            .collect();
1544        assert_eq!(replaced.len(), 1, "{written:?}");
1545        assert!(replaced[0].path.ends_with("commit-msg"));
1546        assert!(matches!(replaced[0].replaced, HookFile::Foreign(_)));
1547        let _ = std::fs::remove_dir_all(&d);
1548    }
1549
1550    /// Windows builds amont.exe, and a shim testing `[ -x .../amont ]` is
1551    /// false for it — so the installed name has to keep the suffix. Asserted
1552    /// against explicit paths, because a `cfg!(windows)` branch is vacuous on
1553    /// the platform this is usually run on.
1554    #[test]
1555    fn the_installed_name_keeps_the_platform_suffix() {
1556        assert_eq!(
1557            name_for(Path::new("/w/target/release/amont.exe")),
1558            "amont.exe"
1559        );
1560        assert_eq!(name_for(Path::new("/u/target/release/amont")), "amont");
1561        // A path that happens to contain a dot elsewhere is not an extension.
1562        assert_eq!(name_for(Path::new("/some.dir/amont")), "amont");
1563    }
1564}