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