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/// Name the commit-style settings, once, at the moment somebody acquires them.
449///
450/// A PRINT, never a prompt. `install` has to remain answerable by nobody: it
451/// runs under `amont-fleet install --root`, in provisioning scripts, and not
452/// at all for the `init.templateDir` users whose hooks arrive with a clone. A
453/// third question would break all three; a line of output breaks none of them,
454/// and it puts the dial in front of the one person guaranteed to be reading.
455fn point_at_setup() {
456    let s = crate::commit_style::Style::resolve();
457    println!(
458        "  commit style: gitmoji {}, subject ≤{}, description ≤{} — `amont setup` to change",
459        s.gitmoji.as_str(),
460        s.subject_max,
461        s.description_max
462    );
463}
464
465/// Ask about the manifest, once, at the moment somebody is already deciding
466/// about this repository.
467///
468/// `direnv` has to ask lazily on `cd` because it has no install step to hang
469/// the question from. We have one — so this is a single question, shown with
470/// the declarations in view, and declining still leaves the built-ins working.
471///
472/// Never blocks and never fails the install: a repository that declares nothing
473/// says nothing, and a non-interactive install simply reports the state.
474fn offer_trust() {
475    // No repository, no manifest to ask about. `repo_root()` answered "." and
476    // this went looking for `./amont.conf` in whatever directory the
477    // install was run from — a file it would then have offered to trust ON
478    // BEHALF of a repository that does not exist.
479    let Ok(root) = crate::hooks::common::repo_root_checked() else {
480        return;
481    };
482    let root = Path::new(&root);
483    let state = crate::trust::state(root);
484    if matches!(
485        state,
486        crate::trust::State::NoManifest | crate::trust::State::Trusted
487    ) {
488        return;
489    }
490
491    println!();
492    println!(
493        "{} {} declares checks that would run on your commits:",
494        warning_sign(),
495        crate::manifest::MANIFEST
496    );
497    // Read ONCE, then show and fingerprint that same buffer. `confirm()` blocks
498    // on a keypress, sometimes for several seconds, and a file rewritten in
499    // that window must not be trusted under the guise of the content that was
500    // displayed — `record_verified` re-checks this fingerprint once the answer
501    // is in. Reading separately to show and to hash would leave the same gap
502    // one step earlier: the listing approved need not be the one recorded.
503    let manifest = root.join(crate::manifest::MANIFEST);
504    let Ok(source) = std::fs::read(&manifest) else {
505        println!(
506            "{} could not read {}",
507            warning_sign(),
508            crate::manifest::MANIFEST
509        );
510        return;
511    };
512    print!(
513        "{}",
514        crate::trust::describe_source(&String::from_utf8_lossy(&source))
515    );
516    let Some(fp) = crate::trust::fingerprint_bytes(root, &source) else {
517        println!(
518            "{} could not hash {}",
519            warning_sign(),
520            crate::manifest::MANIFEST
521        );
522        return;
523    };
524    if crate::trust::confirm("    Trust them? (y/N) ") {
525        match crate::trust::record_verified(root, &fp) {
526            Ok(()) => println!("{} trusted ({fp})", valid_sign()),
527            Err(e) => println!("{} {e}", warning_sign()),
528        }
529    } else {
530        println!("    Left untrusted. The built-ins still run; these do not.");
531        println!("    Change your mind with `amont trust`.");
532    }
533}
534
535/// Ask about `AGENTS.md`, once, right where `offer_trust` asks about the
536/// manifest — same reasoning, same shape: a single question with an install
537/// step to hang it from, and declining changes nothing about how the hooks
538/// themselves run.
539///
540/// This is the first thing `install` would write to TRACKED repo content —
541/// everything else here lives in `.git/hooks` (never tracked) or a
542/// machine-local path (`~/.local/bin`, the XDG template dir). That is exactly
543/// why it is a confirm, not a silent write: `crate::agents_md::write` is
544/// marker-scoped and safe to re-run, but "safe to overwrite" is not the same
545/// promise as "yours to write unasked."
546///
547/// Never blocks and never fails the install: skips silently when there is
548/// nothing to offer, and a non-interactive install simply leaves the
549/// question unanswered — `trust::confirm` already treats no tty as "no".
550fn offer_agents_md() {
551    // Same reason as `offer_trust`: with `repo_root()`'s "." fallback, an
552    // install run outside a repository offered to write an AGENTS.md into the
553    // current directory — the one thing `install` writes to TRACKED content,
554    // aimed at a directory nobody said was a project.
555    let Ok(root) = crate::hooks::common::repo_root_checked() else {
556        return;
557    };
558    let path = Path::new(&root).join("AGENTS.md");
559    match crate::agents_md::check(&path) {
560        Ok(crate::agents_md::CheckResult::MatchesGenerated) => return,
561        Ok(_) => {}
562        // Malformed markers: nothing this prompt can safely offer to fix.
563        Err(_) => return,
564    }
565
566    println!();
567    println!(
568        "{} AGENTS.md can point coding agents at `amont list --json` \
569         instead of leaving them to discover these checks the hard way:",
570        warning_sign()
571    );
572    if crate::trust::confirm("    Add it? (y/N) ") {
573        match crate::agents_md::write(&path) {
574            Ok(()) => println!("{} wrote {}", valid_sign(), path.display()),
575            Err(e) => println!("{} {e}", warning_sign()),
576        }
577    } else {
578        println!("    Left as-is. Change your mind with `amont agents-md`.");
579    }
580}
581
582/// Where this binary can already be found on `PATH`, if it can.
583///
584/// Returns the path as `PATH` exposes it — deliberately NOT the resolved one.
585/// Homebrew's `/usr/local/bin/amont` is a symlink into
586/// `/usr/local/Cellar/amont/<version>/bin/`, and that Cellar path is
587/// version-specific and removed on upgrade. Baking it would pin every repo to a
588/// version that is about to be deleted, which is worse than the copy this
589/// function exists to avoid. The same is true of any versioned store — nix,
590/// asdf, mise.
591///
592/// So the comparison is canonical (to recognise ourselves through the symlink)
593/// while the value returned is the entry that led here. That also makes the
594/// answer correct whichever way `current_exe()` behaves: it resolves symlinks
595/// on some platforms and libcs and not others, and this never has to care.
596fn on_path_already(me: &Path) -> Option<PathBuf> {
597    let me_real = me.canonicalize().ok()?;
598    let name = installed_name();
599    let path = std::env::var_os("PATH")?;
600    std::env::split_paths(&path)
601        .map(|dir| dir.join(&name))
602        .filter(|cand| !in_a_build_dir(cand))
603        .find(|cand| cand.canonicalize().is_ok_and(|real| real == me_real))
604        .map(|cand| absolute(&cand))
605        .filter(|abs| is_bakeable(&abs.to_string_lossy()))
606}
607
608/// Whether `p` sits inside a cargo build directory.
609///
610/// "On PATH" alone is not the question — the question is whether the path will
611/// still be there tomorrow, and a build directory is precisely the one that
612/// will not. `cargo clean`, or any rebuild, and the shims baked against it
613/// resolve nothing.
614///
615/// This is not hypothetical and it is not only about `cargo run`: **cargo
616/// prepends the build directory to PATH when it runs tests on Windows**, so
617/// `target/debug` genuinely appears there. That took out four existing install
618/// tests on the Windows runner and nowhere else, which is a fair description of
619/// how the loose predicate would have failed a user, too.
620///
621/// `CACHEDIR.TAG` is cargo's own marker for the directory, written since 1.55
622/// and standardised for exactly this — "a program wrote this, do not treat it
623/// as durable". Asking for it beats matching on the name `target`, which is
624/// configurable and is also an ordinary word for a directory. Bounded to a few
625/// levels so a stray tag high up somebody's home directory cannot disqualify
626/// every path on the system.
627fn in_a_build_dir(p: &Path) -> bool {
628    p.ancestors()
629        .skip(1)
630        .take(4)
631        .any(|dir| dir.join("CACHEDIR.TAG").is_file())
632}
633
634/// Copy the running binary to a stable location, and return where it now lives.
635fn install_binary() -> Result<String, String> {
636    let me =
637        std::env::current_exe().map_err(|e| format!("cannot locate the running binary: {e}"))?;
638
639    // A binary a package manager already put on PATH is not ours to copy.
640    //
641    // The copy below exists for `./target/release/amont install`, where the
642    // binary sits in a directory `cargo clean` will delete — baking that path
643    // would install hooks that stop resolving the next time somebody builds.
644    // For `brew install`, `cargo install` or a distro package, the opposite is
645    // true: the binary is already somewhere stable, and copying it produces a
646    // SECOND, unmanaged copy that the package manager will never update again.
647    //
648    // That is not hypothetical. It is what this machine was in: `brew upgrade`
649    // would have refreshed /usr/local/bin while every repo stayed baked to a
650    // frozen copy in ~/.local/bin — the same staleness the copy is meant to
651    // prevent, arrived at from the other direction.
652    //
653    // `$AMONT_BIN_DIR` is checked first because setting it IS the request to
654    // put the binary somewhere specific, and honouring it costs nothing.
655    if std::env::var_os("AMONT_BIN_DIR").is_none() {
656        if let Some(stable) = on_path_already(&me) {
657            let shown = stable.to_string_lossy().into_owned();
658            println!("{} using {}", valid_sign(), highlight(&shown));
659            println!("    already on PATH, so nothing was copied — an upgrade there");
660            println!("    reaches every repository without reinstalling.");
661            return Ok(shown);
662        }
663    }
664
665    let dir = bin_dir();
666    std::fs::create_dir_all(&dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
667
668    // Absolute from here on: this path is what gets baked into every shim, and
669    // `bin_dir()` honours `$AMONT_BIN_DIR`, which may be relative.
670    let target = absolute(&dir.join(installed_name()));
671    // Copying a running binary over ITSELF fails on some platforms and is
672    // pointless on all of them.
673    //
674    // `me.canonicalize().ok() == target.canonicalize().ok()` is the version
675    // this replaces, and it was wrong in the one case that matters: when the
676    // TARGET does not exist yet — a first install, the whole point of the
677    // step — `canonicalize` returns `Err`, both sides are `None`, `None ==
678    // None` is true, and the copy was skipped. The binary was never installed,
679    // and `install` printed "installed <path>" for a file that was not there.
680    // Every shim then baked that path and resolved nothing. Two `Ok`s that
681    // agree is the only thing that means "same file".
682    let already_there = matches!(
683        (me.canonicalize(), target.canonicalize()),
684        (Ok(a), Ok(b)) if a == b
685    );
686    if !already_there {
687        std::fs::copy(&me, &target)
688            .map_err(|e| format!("cannot install to {}: {e}", target.display()))?;
689        make_executable(&target).map_err(|e| format!("cannot chmod {}: {e}", target.display()))?;
690    }
691    let installed = target.to_string_lossy().into_owned();
692    println!("{} installed {}", valid_sign(), highlight(&installed));
693    Ok(installed)
694}
695
696/// Write the shims into the template directory — unless doing so would delete
697/// somebody's source.
698///
699/// REFUSING is not an error: on a machine where the template dir is the
700/// checkout, there is nothing to install and the install has succeeded. FAILING
701/// to write one it was allowed to write is, though — reporting success after a
702/// step did not happen is the thing this whole codebase is arranged against.
703fn populate_template_dir(binary: &str, force: bool) -> Result<(), String> {
704    let dir = template_hooks_dir();
705    let _ = std::fs::create_dir_all(&dir);
706    // Report the RESOLVED path. "It is the checkout" is only useful with the
707    // checkout named, and the configured path is usually the symlink that hides
708    // exactly that.
709    let shown = dir.canonicalize().unwrap_or_else(|_| dir.clone());
710    let shown = shown.display();
711
712    match classify_dir(&dir) {
713        TemplateDir::IsCheckout => {
714            println!(
715                "{} template dir IS the checkout ({shown}) — nothing to install.",
716                warning_sign()
717            );
718            println!("    Its shims keep the placeholder deliberately and resolve");
719            println!("    {binary} at run time. This is the intended setup.");
720            // …as long as run-time resolution can actually reach the binary,
721            // which the sentence above used to assert unconditionally.
722            warn_if_unbaked_cannot_resolve(binary);
723        }
724        TemplateDir::InsideCheckout => {
725            println!(
726                "{} {shown} is inside a git checkout — leaving it alone.",
727                warning_sign()
728            );
729            warn_if_unbaked_cannot_resolve(binary);
730        }
731        TemplateDir::NoGit => println!(
732            "{} git is not on PATH — refusing to delete anything.",
733            warning_sign()
734        ),
735        TemplateDir::Unresolvable => {
736            println!("{} cannot resolve {shown} — skipping.", warning_sign())
737        }
738        TemplateDir::Safe => {
739            let written = write_shims(&dir, binary, force)
740                .map_err(|e| format!("cannot write shims to {shown}: {e}"))?;
741            println!("{} wrote {} shims to {shown}", valid_sign(), written.len());
742            report_overwrites(&written);
743        }
744    }
745    Ok(())
746}
747
748/// Say what each write took, for the writes that took something.
749///
750/// `install --force` used to print `baked 4 shims` and stop. `--force` is
751/// typed precisely because a file is in the way, so the one fact the output
752/// omitted is the only fact the user needed: which files, and what they were.
753/// A hook replaced with no record of what it was is unrecoverable — `.git` is
754/// not tracked, so there is nothing to `git checkout` it back from.
755fn report_overwrites(written: &[Written]) {
756    for w in written {
757        if matches!(w.replaced, HookFile::Absent | HookFile::Ours) {
758            continue;
759        }
760        println!(
761            "{} overwrote {} — it was {}",
762            warning_sign(),
763            w.path.display(),
764            w.replaced.describe()
765        );
766    }
767}
768
769/// Where git will actually look for hooks in the repository we are standing
770/// in — never `--git-dir` plus `join("hooks")`. For the main worktree the two
771/// agree, but hooks are explicitly SHARED across every worktree, unlike
772/// `MERGE_HEAD` and friends: a linked worktree's `--git-dir` is its own
773/// PRIVATE gitdir, so joining "hooks" onto it names a directory git never
774/// dispatches from, and a shim baked there is inert — installed, and never
775/// run. `--git-path hooks` is the question actually being asked, and git
776/// itself resolves the worktree case correctly.
777fn repo_hooks_dir() -> Option<PathBuf> {
778    crate::git::stdout(&["rev-parse", "--path-format=absolute", "--git-path", "hooks"])
779        .map(PathBuf::from)
780}
781
782/// Bake the shims into the repository we are standing in, if we are in one.
783///
784/// The tracked guard is inherited from `hookfile::guard_write` rather than
785/// written here, and that inheritance closes a verified bug: with
786/// `.git/hooks/pre-commit` a symlink to a TRACKED `devhooks/pre-commit`,
787/// `install --force` rewrote the tracked source file. Every guard this function
788/// had was about the LINK path — untracked, inside `.git`, unremarkable — while
789/// `fs::write` followed the link and landed in the working tree. Both halves
790/// are fixed at once: the symlink is refused by name, and `--force` replaces
791/// the link by rename instead of writing through it.
792fn bake_repo_hooks(binary: &str, force: bool) -> Result<(), String> {
793    let Some(hooks) = repo_hooks_dir() else {
794        println!(
795            "{} not inside a git repository — no repo hooks written.",
796            warning_sign()
797        );
798        return Ok(());
799    };
800    let _ = std::fs::create_dir_all(&hooks);
801
802    // Asked here, ahead of the guard, only so the message can offer `--force`.
803    // The guard inside `write_shims` is the one that decides, and it refuses
804    // things `--force` will not move (a tracked path, a path git cannot answer
805    // for) which this pre-check deliberately says nothing about.
806    let foreign = foreign_hooks(&hooks);
807    if !foreign.is_empty() && !force {
808        let mut msg = format!(
809            "{} {} already has hooks that are not ours:",
810            error_sign(),
811            hooks.display()
812        );
813        for (name, what) in &foreign {
814            msg.push_str(&format!("\n    {name} — {}", what.describe()));
815        }
816        msg.push_str("\n    Look at them first, then `amont install --force`.");
817        return Err(msg);
818    }
819
820    let written = write_shims(&hooks, binary, force)
821        .map_err(|e| format!("cannot write shims to {}: {e}", hooks.display()))?;
822    println!(
823        "{} baked {} shims into {}",
824        valid_sign(),
825        written.len(),
826        hooks.display()
827    );
828    report_overwrites(&written);
829    Ok(())
830}
831
832/// Take the shims out of the repository we are standing in.
833///
834/// Deliberately narrow. It removes files that are OURS and nothing else:
835///
836/// - a hook we did not write is left alone and named, because somebody wrote it
837///   on purpose;
838/// - `hook.skip` and `amont.severity` are never touched — those are the
839///   user's statements about their own repository, not our artefacts, and a
840///   reinstall should not silently forget that they disabled a check;
841/// - the binary goes only when asked, because other repositories are using it.
842pub fn uninstall(remove_binary: bool) -> Result<(), String> {
843    // The template directory FIRST, and unconditionally, because it is the only
844    // part of an install that keeps working when you are not standing in a
845    // repository — and because `uninstall` returning early with "not inside a
846    // git repository" is how the standing grant survived every attempt to
847    // revoke it.
848    uninstall_template_dir()?;
849    uninstall_repo_hooks()?;
850
851    if remove_binary {
852        let target = bin_dir().join(installed_name());
853        match std::fs::remove_file(&target) {
854            Ok(()) => println!(
855                "{} removed {}",
856                valid_sign(),
857                highlight(&target.to_string_lossy())
858            ),
859            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
860            Err(e) => return Err(format!("cannot remove {}: {e}", target.display())),
861        }
862    }
863
864    report_global_template_dir();
865
866    // Said out loud, because a user who uninstalls and reinstalls should not be
867    // surprised that a check they disabled is still disabled.
868    println!("    hook.skip and amont.severity were not touched.");
869    Ok(())
870}
871
872/// Remove our shims from the repository we are standing in, naming everything
873/// we did not take and why.
874///
875/// Every non-removal is now NAMED. The loop this replaces matched
876/// `Err(_) => {}` on `read_to_string`, so a hook that could not be read at all
877/// — a compiled one, or one whose permissions we lack — was passed over in
878/// total silence: not removed, not counted, not mentioned. The README's promise
879/// that a foreign hook is "left alone and named" was true only for hooks that
880/// happened to be valid UTF-8.
881///
882/// Not being in a repository is a warning rather than an error. It used to
883/// return `Err`, which was defensible on its own but became wrong once
884/// `uninstall_template_dir` existed: the early return meant that running
885/// `amont uninstall` from a plain directory did nothing AND said nothing,
886/// while `init.templateDir` quietly went on installing hooks into every future
887/// clone. Refusing is not failing — the same rule `populate_template_dir`
888/// already states.
889fn uninstall_repo_hooks() -> Result<(), String> {
890    let Some(hooks) = repo_hooks_dir() else {
891        println!(
892            "{} not inside a git repository — no repo hooks removed.",
893            warning_sign()
894        );
895        return Ok(());
896    };
897
898    let mut removed = 0usize;
899    let mut left: Vec<String> = Vec::new();
900    for name in DISPATCHERS {
901        let path = hooks.join(name);
902        match hookfile::classify(&path) {
903            HookFile::Absent => {}
904            HookFile::Ours => match hookfile::guard_remove(&path, true) {
905                Ok(()) => {
906                    hookfile::remove_regular(&path)
907                        .map_err(|e| format!("cannot remove {}: {e}", path.display()))?;
908                    removed += 1;
909                }
910                // Ours by marker, and still not ours to delete: a tracked path,
911                // or one git could not answer for.
912                Err(r) => left.push(r.explain()),
913            },
914            what => left.push(format!("{name} — {}", what.describe())),
915        }
916    }
917    println!(
918        "{} removed {removed} shims from {}",
919        valid_sign(),
920        hooks.display()
921    );
922    for reason in &left {
923        println!("{} left alone: {reason}", warning_sign());
924    }
925    Ok(())
926}
927
928/// Take our shims back out of the template directory.
929///
930/// `install` writes there; `uninstall` did not, which meant uninstall did not
931/// undo install. Combined with `init.templateDir`, that is the failure worth
932/// spelling out: the user runs `amont uninstall`, sees "removed 4 shims",
933/// believes they are done — and every `git clone` and `git init` from then on
934/// copies the template directory into the new repository's `.git/hooks` and
935/// installs the hooks again. They uninstalled a repository, not a machine.
936///
937/// The same classification `install` uses decides what may happen here, for the
938/// same reason and with the sharper stake: `~/.config/git/git-templates` is
939/// commonly a SYMLINK to a checkout of this repository, and "uninstalling"
940/// there means `rm` on tracked source. That is not a hypothetical; it is the
941/// two incidents this module exists because of, and a delete has no `--force`.
942fn uninstall_template_dir() -> Result<(), String> {
943    let dir = template_hooks_dir();
944    // The RESOLVED path, because the configured one is usually the symlink that
945    // hides exactly what we are about to explain.
946    let shown = dir.canonicalize().unwrap_or_else(|_| dir.clone());
947    let shown = shown.display();
948
949    match classify_dir(&dir) {
950        TemplateDir::IsCheckout | TemplateDir::InsideCheckout => {
951            println!(
952                "{} template dir is a git checkout ({shown}) — deleting NOTHING there.",
953                warning_sign()
954            );
955            println!("    Those shims are tracked files belonging to that checkout,");
956            println!("    not something this install put there. Remove them with git,");
957            println!("    or point init.templateDir somewhere else.");
958        }
959        TemplateDir::NoGit => println!(
960            "{} git is not on PATH — cannot tell whether {shown} is a checkout, deleting nothing.",
961            warning_sign()
962        ),
963        TemplateDir::Unresolvable => println!(
964            "{} no template dir at {shown} — nothing to remove.",
965            warning_sign()
966        ),
967        TemplateDir::Safe => {
968            let mut removed = 0usize;
969            let mut left: Vec<String> = Vec::new();
970            for name in DISPATCHERS {
971                let path = dir.join(name);
972                match hookfile::classify(&path) {
973                    HookFile::Absent => {}
974                    HookFile::Ours => match hookfile::guard_remove(&path, true) {
975                        Ok(()) => {
976                            hookfile::remove_regular(&path)
977                                .map_err(|e| format!("cannot remove {}: {e}", path.display()))?;
978                            removed += 1;
979                        }
980                        Err(r) => left.push(r.explain()),
981                    },
982                    what => left.push(format!("{name} — {}", what.describe())),
983                }
984            }
985            println!("{} removed {removed} shims from {shown}", valid_sign());
986            for reason in &left {
987                println!("{} left alone: {reason}", warning_sign());
988            }
989        }
990    }
991    Ok(())
992}
993
994/// Say, every time, whether `init.templateDir` is still pointing at us.
995///
996/// UNCONDITIONAL, including when the template dir was a checkout we refused to
997/// touch and when there was no template dir at all — because the config setting
998/// is what actually installs hooks into new repositories, and it survives every
999/// file this command removes. An uninstall that leaves it set has not
1000/// uninstalled anything durable: the next `git clone` re-installs.
1001///
1002/// Printed rather than unset. `git config --global` is the user's file, holding
1003/// their identity and their aliases, and reaching into it uninvited is a larger
1004/// claim than removing files this tool wrote. The command to run is given
1005/// verbatim so it is a copy rather than a lookup.
1006fn report_global_template_dir() {
1007    let Some(configured) = crate::git::stdout(&["config", "--global", "--get", "init.templateDir"])
1008        .filter(|s| !s.is_empty())
1009    else {
1010        return;
1011    };
1012    println!();
1013    println!(
1014        "{} init.templateDir is still set: {}",
1015        warning_sign(),
1016        highlight(&configured)
1017    );
1018    println!("    Every `git clone` and `git init` still copies hooks from there");
1019    println!("    into the new repository. Uninstalling this repo did not change that.");
1020    println!("    Undo it with:");
1021    println!(
1022        "        {}",
1023        highlight("git config --global --unset init.templateDir")
1024    );
1025}
1026
1027#[cfg(test)]
1028mod tests {
1029    use super::*;
1030
1031    fn tmp(name: &str) -> PathBuf {
1032        let d = std::env::temp_dir().join(format!("gh-install-{name}-{}", std::process::id()));
1033        let _ = std::fs::remove_dir_all(&d);
1034        std::fs::create_dir_all(&d).expect("mkdir");
1035        d
1036    }
1037
1038    fn git(dir: &Path, args: &[&str]) {
1039        Command::new("git")
1040            .arg("-C")
1041            .arg(dir)
1042            .args(args)
1043            .output()
1044            .expect("git");
1045    }
1046
1047    /// The embedded shim must be the shim that ships. `include_str!` takes one
1048    /// of the four; if they ever diverge, the installer would write a file
1049    /// nobody reviewed.
1050    #[test]
1051    fn shims_on_disk_match_the_embedded_one() {
1052        let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../templates/hooks");
1053        // Absent when this crate is built from its PUBLISHED tarball, which
1054        // contains this directory and nothing above it. There is no drift to
1055        // catch in that situation — the only shim present is the one compiled
1056        // in — so say so rather than fail a test about a file that is not
1057        // supposed to be there.
1058        if !Path::new(dir).is_dir() {
1059            println!(
1060                "! no repository checkout here — nothing to compare the embedded shim against"
1061            );
1062            return;
1063        }
1064        for name in DISPATCHERS {
1065            let disk = std::fs::read_to_string(Path::new(dir).join(name))
1066                .unwrap_or_else(|e| panic!("read {name}: {e}"));
1067            assert_eq!(disk, SHIM, "{name} differs from the embedded shim");
1068        }
1069    }
1070
1071    /// The whole point of the module. A directory holding tracked files is the
1072    /// source checkout reached through a symlink, and emptying it destroys work.
1073    #[test]
1074    fn a_directory_holding_tracked_files_is_never_safe() {
1075        let d = tmp("tracked");
1076        git(&d, &["init", "-q", "--template=", "."]);
1077        git(&d, &["config", "user.email", "t@t.test"]);
1078        git(&d, &["config", "user.name", "t"]);
1079        std::fs::write(d.join("kept.txt"), "precious\n").expect("write");
1080        git(&d, &["add", "-A"]);
1081        git(&d, &["commit", "-qm", "seed"]);
1082
1083        assert_eq!(classify_dir(&d), TemplateDir::IsCheckout);
1084        let _ = std::fs::remove_dir_all(&d);
1085    }
1086
1087    /// A path comparison against the source tree passes here and is WRONG: a
1088    /// worktree is a different path holding the same tracked files. This is the
1089    /// case that caused the second incident.
1090    #[test]
1091    fn a_worktree_is_recognised_even_though_its_path_differs() {
1092        let d = tmp("wt-main");
1093        git(&d, &["init", "-q", "--template=", "."]);
1094        git(&d, &["config", "user.email", "t@t.test"]);
1095        git(&d, &["config", "user.name", "t"]);
1096        std::fs::write(d.join("kept.txt"), "precious\n").expect("write");
1097        git(&d, &["add", "-A"]);
1098        git(&d, &["commit", "-qm", "seed"]);
1099
1100        let wt = d.with_extension("wt");
1101        let _ = std::fs::remove_dir_all(&wt);
1102        git(&d, &["worktree", "add", "-q", wt.to_str().unwrap()]);
1103        assert!(
1104            wt.join("kept.txt").is_file(),
1105            "worktree did not materialise"
1106        );
1107        assert_ne!(d.canonicalize().ok(), wt.canonicalize().ok());
1108        assert_eq!(
1109            classify_dir(&wt),
1110            TemplateDir::IsCheckout,
1111            "a worktree must be refused exactly like the main checkout"
1112        );
1113        let _ = std::fs::remove_dir_all(&wt);
1114        let _ = std::fs::remove_dir_all(&d);
1115    }
1116
1117    /// Inside a checkout but tracking nothing here — still not ours to empty.
1118    #[test]
1119    fn an_untracked_directory_inside_a_checkout_is_refused() {
1120        let d = tmp("inside");
1121        git(&d, &["init", "-q", "--template=", "."]);
1122        let sub = d.join("scratch");
1123        std::fs::create_dir_all(&sub).expect("mkdir");
1124        assert_eq!(classify_dir(&sub), TemplateDir::InsideCheckout);
1125        let _ = std::fs::remove_dir_all(&d);
1126    }
1127
1128    #[test]
1129    fn an_ordinary_directory_is_safe() {
1130        let d = tmp("plain");
1131        assert_eq!(classify_dir(&d), TemplateDir::Safe);
1132        let _ = std::fs::remove_dir_all(&d);
1133    }
1134
1135    #[test]
1136    fn a_missing_directory_is_unresolvable_not_safe() {
1137        assert_eq!(
1138            classify_dir(Path::new("/nonexistent-install-c8f2/hooks")),
1139            TemplateDir::Unresolvable
1140        );
1141    }
1142
1143    /// Baking replaces every occurrence and is idempotent.
1144    #[test]
1145    fn baking_is_total_and_idempotent() {
1146        let once = bake(SHIM, "/opt/amont");
1147        assert!(!once.contains(PLACEHOLDER), "a token survived baking");
1148        assert!(once.contains("/opt/amont"));
1149        assert_eq!(bake(&once, "/other"), once, "re-baking must be a no-op");
1150    }
1151
1152    /// The shim's comment must not spell the token out, or a global replace
1153    /// turns the explanation into a machine path — which it did, in every shim
1154    /// baked before this module existed.
1155    #[test]
1156    fn baking_does_not_rewrite_the_comment_explaining_it() {
1157        for line in bake(SHIM, "/opt/amont").lines() {
1158            if line.trim_start().starts_with('#') {
1159                assert!(
1160                    !line.contains("/opt/amont"),
1161                    "baking rewrote a comment: {line}"
1162                );
1163            }
1164        }
1165    }
1166
1167    /// Only an absolute path may be baked.
1168    ///
1169    /// A relative one is resolved by the shim against the WORKING TREE, so a
1170    /// repository shipping an executable by that name would be running it on
1171    /// the first commit after clone.
1172    #[test]
1173    fn only_an_absolute_path_is_bakeable() {
1174        for good in [
1175            "/opt/amont",
1176            "/home/u/.local/bin/amont",
1177            "C:/Users/u/amont.exe",
1178            "C:\\Users\\u\\amont.exe",
1179        ] {
1180            assert!(is_bakeable(good), "{good} should be bakeable");
1181        }
1182        for bad in [
1183            "",
1184            PLACEHOLDER,
1185            "amont",
1186            "./amont",
1187            "../amont",
1188            "target/debug/amont",
1189            "C:amont.exe",
1190        ] {
1191            assert!(!is_bakeable(bad), "{bad:?} must not be bakeable");
1192        }
1193    }
1194
1195    /// The shim must never hand the unsubstituted token to `[ -x ]`: that is a
1196    /// filesystem question asked in the repository's own directory.
1197    #[test]
1198    fn the_shim_never_tests_the_placeholder_as_a_path() {
1199        assert!(
1200            !SHIM.contains(&format!("[ -x \"{PLACEHOLDER}\" ]")),
1201            "the shim tests the raw token as a path"
1202        );
1203        assert!(
1204            SHIM.contains("case \"$BAKED\" in"),
1205            "the shim lost its absoluteness guard"
1206        );
1207    }
1208
1209    /// Refusing beats writing four hooks that cannot resolve their binary.
1210    #[test]
1211    fn write_shims_refuses_a_relative_binary_path() {
1212        let d = tmp("relative");
1213        let err = write_shims(&d, "target/debug/amont", false).expect_err("must refuse");
1214        assert!(err.to_string().contains("absolute"), "{err}");
1215        for name in DISPATCHERS {
1216            assert!(!d.join(name).exists(), "{name} was written anyway");
1217        }
1218        let _ = std::fs::remove_dir_all(&d);
1219    }
1220
1221    /// Every hook git invokes gets a file, and each is the baked shim.
1222    #[test]
1223    fn writing_shims_covers_every_dispatcher() {
1224        let d = tmp("write");
1225        let written = write_shims(&d, "/opt/amont", false).expect("write");
1226        assert_eq!(written.len(), DISPATCHERS.len());
1227        for name in DISPATCHERS {
1228            let got = std::fs::read_to_string(d.join(name)).expect("read");
1229            assert!(!got.contains(PLACEHOLDER), "{name} was written unbaked");
1230            assert!(got.contains("/opt/amont"), "{name} has no path");
1231        }
1232        let _ = std::fs::remove_dir_all(&d);
1233    }
1234
1235    /// The whole-repository posture, at the level of the function that owes it:
1236    /// ONE unwritable path and nothing at all is written. `bake_repo_hooks` has
1237    /// claimed this in a comment since it was written, over a loop that checked
1238    /// one file then wrote it, four times over — so a refusal on the third hook
1239    /// arrived after two were already gone.
1240    #[test]
1241    fn one_refusal_writes_nothing_at_all() {
1242        let d = tmp("all-or-nothing");
1243        // `prepare-commit-msg` sorts last among the dispatchers, so under the
1244        // old check-then-write loop the first three would already be on disk by
1245        // the time this one refused.
1246        let theirs = d.join("prepare-commit-msg");
1247        std::fs::write(&theirs, "#!/bin/sh\necho MINE\n").expect("write");
1248
1249        let err = write_shims(&d, "/opt/amont", false).expect_err("must refuse");
1250        assert!(
1251            matches!(err, ShimWriteError::Refused(ref rs) if rs.len() == 1),
1252            "{err}"
1253        );
1254        for name in ["commit-msg", "pre-commit", "pre-push"] {
1255            assert!(
1256                !d.join(name).exists(),
1257                "{name} was written despite a refusal elsewhere"
1258            );
1259        }
1260        assert_eq!(
1261            std::fs::read_to_string(&theirs).expect("read"),
1262            "#!/bin/sh\necho MINE\n"
1263        );
1264        let _ = std::fs::remove_dir_all(&d);
1265    }
1266
1267    /// A refusal has to say WHAT was in the way, not only that something was.
1268    /// The old predicate could not: it read the file as a string, so the one
1269    /// case worth naming — a compiled hook — came back as "not foreign" and was
1270    /// overwritten in silence.
1271    #[test]
1272    fn a_refusal_names_the_reason_for_each_hook() {
1273        let d = tmp("named");
1274        std::fs::write(d.join("commit-msg"), [0x7f, b'E', b'L', b'F', 0xff]).expect("write");
1275        std::fs::write(d.join("pre-commit"), "#!/bin/sh\necho mine\n").expect("write");
1276
1277        let err = write_shims(&d, "/opt/amont", false).expect_err("must refuse");
1278        let text = err.to_string();
1279        assert!(text.contains("not valid UTF-8"), "{text}");
1280        assert!(text.contains("commit-msg"), "{text}");
1281        assert!(text.contains("pre-commit"), "{text}");
1282
1283        // And `foreign_hooks` — which is what phrases the `--force` offer —
1284        // agrees about both.
1285        let foreign = foreign_hooks(&d);
1286        assert_eq!(foreign.len(), 2, "{foreign:?}");
1287        assert!(foreign
1288            .iter()
1289            .any(|(n, w)| *n == "commit-msg" && matches!(w, HookFile::Foreign(_))));
1290        let _ = std::fs::remove_dir_all(&d);
1291    }
1292
1293    /// `--force` says what it took, per file, with what it was. Without this
1294    /// the output was "baked 4 shims" — a receipt with the transaction left
1295    /// off, for the one operation whose purpose is to destroy something.
1296    #[test]
1297    fn force_reports_what_each_write_replaced() {
1298        let d = tmp("force-report");
1299        std::fs::write(d.join("commit-msg"), "#!/bin/sh\necho mine\n").expect("write");
1300        let written = write_shims(&d, "/opt/amont", true).expect("force must write");
1301        let replaced: Vec<_> = written
1302            .iter()
1303            .filter(|w| !matches!(w.replaced, HookFile::Absent))
1304            .collect();
1305        assert_eq!(replaced.len(), 1, "{written:?}");
1306        assert!(replaced[0].path.ends_with("commit-msg"));
1307        assert!(matches!(replaced[0].replaced, HookFile::Foreign(_)));
1308        let _ = std::fs::remove_dir_all(&d);
1309    }
1310
1311    /// Windows builds amont.exe, and a shim testing `[ -x .../amont ]` is
1312    /// false for it — so the installed name has to keep the suffix. Asserted
1313    /// against explicit paths, because a `cfg!(windows)` branch is vacuous on
1314    /// the platform this is usually run on.
1315    #[test]
1316    fn the_installed_name_keeps_the_platform_suffix() {
1317        assert_eq!(
1318            name_for(Path::new("/w/target/release/amont.exe")),
1319            "amont.exe"
1320        );
1321        assert_eq!(name_for(Path::new("/u/target/release/amont")), "amont");
1322        // A path that happens to contain a dot elsewhere is not an extension.
1323        assert_eq!(name_for(Path::new("/some.dir/amont")), "amont");
1324    }
1325}