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}