amont_runtime/hooks/common.rs
1//! Shared plumbing for the linter-orchestration hooks.
2//!
3//! Nine of them do the same four things: collect staged files of some kind,
4//! bail out if there are none, resolve a tool, run it. In shell that was ~65
5//! lines apiece, mostly duplicated; here it is a handful of helpers and each
6//! hook keeps only what is actually specific to it.
7
8use crate::git;
9use crate::ui::{error_sign, valid_sign, warning_sign};
10use std::path::Path;
11use std::process::{Command, Stdio};
12use std::sync::OnceLock;
13
14/// Staged files, deletions excluded, whose name ends with one of `exts`.
15/// The file set every check asks about, when it is not the staged one.
16///
17/// Set at most once, before any check runs, by `amont run --all-files`. A
18/// process-level override rather than a parameter because a check's signature
19/// is `(&[OsString])` — it never sees a `Ctx` — and threading a file set
20/// through twenty of them to serve one mode would be a worse trade than a
21/// value that is written once and read many times.
22///
23/// Same shape as `PushRefs`: read once, lent to every check that asks.
24static OVERRIDE: OnceLock<Vec<String>> = OnceLock::new();
25
26/// Set once the file set stops being the index.
27///
28/// `restage`'s own doc says what makes re-staging safe: the pre-commit stage
29/// holds the unstaged changes aside, so the tree contains the staged content
30/// and nothing else, and anything a formatter touched is by definition part of
31/// this commit. `amont run --all-files` replaces the file set with every
32/// tracked path — which is that precondition being FALSE.
33///
34/// With `amont.fix true`, every fixer's `restage(&files)` would then `git
35/// add` everything in the working tree that differs from the index, turning a
36/// read-only "does my tree pass" query into `git add .`. That is the hazard §2
37/// of docs/index-fidelity-and-run-modes.md names.
38///
39/// The gate hangs off the OVERRIDE rather than off a flag threaded through
40/// twenty check signatures, because the override IS the fact that matters. It
41/// therefore covers built-ins and `manifest::External::run` (which consults
42/// `fixing_enabled` in two places) in one change, and a future check cannot
43/// forget it.
44static NOT_THE_INDEX: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
45
46/// Make every subsequent `staged_files` answer from `files` instead of the
47/// index. Only the first call counts.
48pub fn override_file_set(files: Vec<String>) {
49 // Set unconditionally, even if a set already won the `OnceLock`: the
50 // statement "the file set is not the index" is true from the first call
51 // onwards regardless of which one supplied the paths.
52 NOT_THE_INDEX.store(true, std::sync::atomic::Ordering::SeqCst);
53 let _ = OVERRIDE.set(files);
54}
55
56/// Whether the file set every check sees is something other than the index.
57pub fn not_the_index() -> bool {
58 NOT_THE_INDEX.load(std::sync::atomic::Ordering::SeqCst)
59}
60
61/// An empty `exts` returns them all.
62pub fn staged_files(exts: &[&str]) -> Vec<String> {
63 if let Some(all) = OVERRIDE.get() {
64 return all
65 .iter()
66 .filter(|f| exts.is_empty() || exts.iter().any(|e| f.ends_with(e)))
67 .cloned()
68 .collect();
69 }
70 let Some(out) = git::stdout_paths(&["diff", "--diff-filter=d", "--cached", "--name-only"])
71 else {
72 return Vec::new();
73 };
74 out.into_iter()
75 .filter(|f| exts.is_empty() || exts.iter().any(|e| f.ends_with(e)))
76 .collect()
77}
78
79/// Repo root, or "." when git cannot say.
80///
81/// **For CHECK BODIES ONLY.** The fallback is safe there and nowhere else: git
82/// invokes a hook with the working tree as the current directory, so a check
83/// that reaches this line is already standing in the repository, and "." is the
84/// right answer rather than a guess.
85///
86/// Anything a user types — `amont agents-md`, `install`, `trust`, `restore`
87/// — can be typed from any directory on the machine, and there the fallback is
88/// not a fallback but a wrong answer that reads as a right one. Use
89/// [`repo_root_checked`] at every command entry point.
90pub fn repo_root() -> String {
91 git::stdout(&["rev-parse", "--show-toplevel"]).unwrap_or_else(|| ".".into())
92}
93
94/// Repo root, or an error naming the problem.
95///
96/// The same question as [`repo_root`] without the "." — because "." is a
97/// PLAUSIBLE root, and that is what made it dangerous. `amont agents-md`
98/// run outside a repository did not fail; it resolved the root to the current
99/// directory and wrote `./AGENTS.md` into whatever directory the user happened
100/// to be standing in, then printed `wrote ./AGENTS.md` as if that were the
101/// answer. Same shape in `install`'s two prompts, in `trust` (which then
102/// looked for a manifest, and would have recorded trust, under `.`) and in
103/// `restore`.
104///
105/// Every one of those is a command somebody types, and a command somebody
106/// types is a command they can type from `~`. There is no correct behaviour
107/// available to this function when git cannot answer, so it does not invent
108/// one.
109pub fn repo_root_checked() -> Result<String, String> {
110 git::stdout(&["rev-parse", "--show-toplevel"])
111 .filter(|s| !s.is_empty())
112 .ok_or_else(|| "not inside a git repository".to_string())
113}
114
115/// Resolve a tool, preferring the repo's PINNED copy so the hook matches CI.
116///
117///
118/// Order: `<root>/node_modules/.bin/<tool>`, then the MAIN worktree's (a linked
119/// worktree has no node_modules of its own — this is why the shell version
120/// consulted the git common dir), then PATH.
121pub fn resolve_tool(root: &str, tool: &str) -> Option<Vec<String>> {
122 // Same extension problem as `which`: an npm-installed binary is `eslint.cmd`
123 // on Windows, so the bare name misses the repo's PINNED copy and the hook
124 // silently falls through to an ambient one.
125 if let Some(p) = in_bin_dir(&format!("{root}/node_modules/.bin"), tool) {
126 return Some(vec![p]);
127 }
128 if let Some(common) = git::stdout(&["rev-parse", "--path-format=absolute", "--git-common-dir"])
129 {
130 if let Some(main) = Path::new(&common).parent() {
131 if let Some(p) = in_bin_dir(&main.join("node_modules/.bin").to_string_lossy(), tool) {
132 return Some(vec![p]);
133 }
134 }
135 }
136 if let Some(full) = which(tool) {
137 return Some(vec![full]);
138 }
139 // `npx --no-install`: never silently download a random latest version — a
140 // hook that quietly pulls a different linter than CI uses is worse than one
141 // that skips.
142 if which("npx").is_some()
143 && Command::new(program("npx"))
144 .args(["--no-install", tool, "--version"])
145 .current_dir(root)
146 .stdin(Stdio::null())
147 .stdout(Stdio::null())
148 .stderr(Stdio::null())
149 .status()
150 .map(|s| s.success())
151 .unwrap_or(false)
152 {
153 return Some(vec![
154 program("npx"),
155 "--no-install".to_string(),
156 tool.to_string(),
157 ]);
158 }
159 None
160}
161
162/// First match for `tool` on PATH.
163///
164/// Windows executables carry an extension — `git` is `git.exe`, an npm-installed
165/// `eslint` is `eslint.cmd` — so the bare name finds nothing there. PATHEXT is
166/// the OS's own list of what counts as executable; fall back to the usual set
167/// when it is unset. Found by the Windows CI job on its first run, where
168/// `which("git")` returned None on a machine that plainly has git.
169pub fn which(tool: &str) -> Option<String> {
170 let path = std::env::var_os("PATH")?;
171 let exts: Vec<String> = if cfg!(windows) {
172 std::env::var("PATHEXT")
173 .unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".into())
174 .split(';')
175 .filter(|e| !e.is_empty())
176 .map(|e| e.to_lowercase())
177 .collect()
178 } else {
179 Vec::new()
180 };
181 for dir in std::env::split_paths(&path) {
182 // On Windows the EXTENSION forms come first. A node install ships both
183 // `npm` (an extensionless shell script, for MSYS) and `npm.cmd` in the
184 // same directory; preferring the bare name hands CreateProcess a shell
185 // script it cannot execute — "%1 is not a valid Win32 application" —
186 // and the hook reports an installed tool as broken.
187 for e in &exts {
188 let c = dir.join(format!("{tool}{e}"));
189 if c.is_file() {
190 return Some(c.to_string_lossy().into_owned());
191 }
192 }
193 let bare = dir.join(tool);
194 if bare.is_file() {
195 return Some(bare.to_string_lossy().into_owned());
196 }
197 }
198 None
199}
200
201/// `<dir>/<tool>`, trying the Windows executable extensions too.
202fn in_bin_dir(dir: &str, tool: &str) -> Option<String> {
203 let bare = Path::new(dir).join(tool);
204 if bare.is_file() {
205 return Some(bare.to_string_lossy().into_owned());
206 }
207 if cfg!(windows) {
208 for e in [".cmd", ".exe", ".bat", ".ps1"] {
209 let c = Path::new(dir).join(format!("{tool}{e}"));
210 if c.is_file() {
211 return Some(c.to_string_lossy().into_owned());
212 }
213 }
214 }
215 None
216}
217
218/// Resolve a tool name to a full path for spawning.
219///
220/// `Command::new("npm")` cannot execute `npm.cmd`: Rust does no PATHEXT
221/// resolution, so on Windows every bare-name spawn fails with "program not
222/// found" and the hook reports the tool as broken rather than absent. Found by
223/// the Windows job on its first FULL-suite run — the smoke never spawned a
224/// tool, so it could not have surfaced this.
225///
226/// Falls back to the name unchanged, so a caller still gets a sensible error.
227pub fn program(name: &str) -> String {
228 which(name).unwrap_or_else(|| name.to_string())
229}
230
231/// The first of `names` that exists at the repo root — how these hooks decide
232/// a repo has opted into a tool.
233pub fn first_existing(root: &str, names: &[&str]) -> Option<String> {
234 names
235 .iter()
236 .find(|n| Path::new(root).join(n).exists())
237 .map(|n| (*n).to_string())
238}
239
240/// Strip git's own environment before handing a Command to another tool.
241///
242/// git exports GIT_DIR, GIT_INDEX_FILE, GIT_WORK_TREE and friends to every
243/// hook. Those OVERRIDE the working directory, so any tool that shells out to
244/// git operates on the hook's repository no matter where it was launched.
245///
246/// That is not hypothetical: `pre-push-cargo-test` runs a project's test suite,
247/// and this repo's own suite creates throwaway repos and commits to them. With
248/// GIT_DIR inherited, `git commit` in a test wrote into the REAL repository —
249/// an actual stray commit, authored by the test fixture, pushed to a branch.
250///
251/// A test suite should behave exactly as it does when run by hand, which means
252/// seeing no git environment at all.
253pub fn strip_git_env(cmd: &mut Command) {
254 for (k, _) in std::env::vars_os() {
255 let key = k.to_string_lossy();
256 if key.starts_with("GIT_") {
257 cmd.env_remove(&k);
258 }
259 }
260}
261
262/// Run `argv` from `root`, inheriting stdio. True when it exits 0.
263pub fn run(root: &str, argv: &[String], extra: &[String]) -> bool {
264 let Some((program, rest)) = argv.split_first() else {
265 return true;
266 };
267 let mut cmd = Command::new(program);
268 cmd.args(rest)
269 .args(extra)
270 .current_dir(root)
271 .stdin(Stdio::null());
272 strip_git_env(&mut cmd);
273 cmd.status().map(|s| s.success()).unwrap_or(false)
274}
275
276/// As [`run`], but with the tool's own output discarded.
277///
278/// For a pass whose only job is to decide something — prettier's `--check`,
279/// ruff's `--fix` sweep — where the offenders are printed once, by the pass
280/// that reports them, rather than twice.
281pub fn run_quiet(root: &str, argv: &[String], extra: &[String]) -> bool {
282 let Some((program, rest)) = argv.split_first() else {
283 return true;
284 };
285 let mut cmd = Command::new(program);
286 cmd.args(rest)
287 .args(extra)
288 .current_dir(root)
289 .stdin(Stdio::null())
290 .stdout(Stdio::null())
291 .stderr(Stdio::null());
292 strip_git_env(&mut cmd);
293 cmd.status().map(|s| s.success()).unwrap_or(false)
294}
295
296/// Whether the user asked for checks to repair what they find.
297///
298/// OFF by default. `git config amont.fix true` turns it on, per repository,
299/// because a hook that edits your files without being asked is a larger
300/// surprise than one that complains — and because with index fidelity in place
301/// the repair lands in the commit you are making, which is a bigger claim to
302/// make on somebody's behalf than printing an error.
303pub fn fixing_enabled() -> bool {
304 // Never while the file set is not the index — see `NOT_THE_INDEX`.
305 !not_the_index() && fixing_requested()
306}
307
308/// What the CONFIG says, ignoring whether the current run may act on it.
309///
310/// Split out so `run_all` can tell the difference between "fixing is off" and
311/// "you asked for fixing and this mode will not do it", and say the second out
312/// loud instead of silently ignoring the key.
313pub fn fixing_requested() -> bool {
314 crate::config::boolean_or("amont.fix", false)
315}
316
317/// What a re-stage actually did. THREE answers, because the old `bool`
318/// conflated two of them and the conflation shipped unformatted code.
319///
320/// `prettier.rs` read `if run_quiet(write) && restage(&files) { … Fixed }`. When
321/// `git add` FAILED, `restage` returned `false` — indistinguishable from
322/// "nothing needed staging" — so control fell through to a second `--check`
323/// pass, which inspected the NOW-FORMATTED WORKING TREE, passed, printed
324/// "Prettier passed" and returned `Outcome::Passed`. The index still held the
325/// unformatted content, so the commit contained unformatted code and the hook
326/// said it had passed. `manifest.rs` had the same shape.
327#[derive(Debug, Clone, PartialEq, Eq)]
328pub enum Restaged {
329 /// No path differed from the index — nothing to do, and nothing wrong.
330 Nothing,
331 /// `git add` succeeded; the index now holds the repair.
332 Staged,
333 /// `git add` failed, carrying the paths it could not stage. The index
334 /// holds content the fixer has already replaced on disk, so this MUST be
335 /// loud at every call site — and naming the files is the difference
336 /// between a message somebody can act on and one they cannot.
337 Failed(Vec<String>),
338}
339
340/// Serialises this process's own `git add` calls.
341///
342/// pre-commit runs its checks concurrently (`dispatch.rs`), and up to three of
343/// them can re-stage. git takes `$GIT_DIR/index.lock` exclusively, so two
344/// concurrent `git add`s in the same repository make one of them fail — which,
345/// before `Restaged`, was silently read as "nothing moved". Holding this across
346/// the `git add` removes self-contention entirely; the retry below is only for
347/// OTHER processes.
348static INDEX_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
349
350/// Re-stage exactly the paths a fixer rewrote, and say what happened.
351///
352/// Safe ONLY because the pre-commit stage holds unstaged changes aside: the
353/// tree contains the staged content and nothing else, so anything a formatter
354/// touched is by definition part of this commit. Without that, re-staging would
355/// sweep in work the author deliberately kept back.
356pub fn restage(paths: &[String]) -> Restaged {
357 // Belt and braces alongside `fixing_enabled`: a future fixer that forgets
358 // the gate still cannot turn `amont run --all-files` into `git add .`.
359 if not_the_index() {
360 return Restaged::Nothing;
361 }
362 let changed: Vec<String> = paths
363 .iter()
364 .filter(|p| !git::succeeds(&["diff", "--quiet", "--", p]))
365 .cloned()
366 .collect();
367 if changed.is_empty() {
368 return Restaged::Nothing;
369 }
370 let mut args = vec!["add", "--"];
371 args.extend(changed.iter().map(String::as_str));
372
373 let _serialised = INDEX_LOCK.lock().unwrap_or_else(|e| e.into_inner());
374 // Another PROCESS can hold `index.lock` — a `git status` from an editor, a
375 // second hook in a linked worktree. Back off and retry rather than
376 // reporting a transient collision as a failed repair. `git add` of the same
377 // paths is idempotent: it records the paths' current worktree content, so
378 // running it twice records the same thing twice and cannot double-stage.
379 const BACKOFF_MS: [u64; 3] = [50, 150, 400];
380 if git::succeeds(&args) {
381 return Restaged::Staged;
382 }
383 for wait in BACKOFF_MS {
384 std::thread::sleep(std::time::Duration::from_millis(wait));
385 if git::succeeds(&args) {
386 return Restaged::Staged;
387 }
388 }
389 Restaged::Failed(changed)
390}
391
392pub fn ok(msg: &str) {
393 println!("{} {msg}", valid_sign());
394}
395pub fn fail(msg: &str) {
396 println!("{} {msg}", error_sign());
397}
398pub fn warn(msg: &str) {
399 println!("{} {msg}", warning_sign());
400}
401
402/// Orange, for the fragments these hooks highlight.
403pub fn hl(s: &str) -> String {
404 crate::ui::highlight(s)
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410
411 #[test]
412 fn which_finds_a_real_binary_and_not_a_fake_one() {
413 assert!(which("git").is_some());
414 assert!(which("definitely-not-a-real-binary-xyz").is_none());
415 }
416
417 /// On Windows a tool can exist BOTH as an extensionless shell script and as
418 /// a .cmd/.exe in the same directory; only the latter is executable by
419 /// CreateProcess, so the extension forms must win.
420 #[test]
421 #[cfg(windows)]
422 fn windows_prefers_an_executable_extension_over_a_bare_file() {
423 let dir = std::env::temp_dir().join("amont-which-order");
424 let _ = std::fs::create_dir_all(&dir);
425 std::fs::write(dir.join("faketool"), "#!/bin/sh\n").unwrap();
426 std::fs::write(dir.join("faketool.cmd"), "@echo off\n").unwrap();
427 let saved = std::env::var_os("PATH");
428 std::env::set_var("PATH", &dir);
429 let found = which("faketool").unwrap();
430 if let Some(p) = saved {
431 std::env::set_var("PATH", p);
432 }
433 assert!(found.ends_with(".cmd"), "got {found}");
434 let _ = std::fs::remove_dir_all(&dir);
435 }
436
437 /// "Nothing moved" and "`git add` FAILED" are different answers, and the
438 /// old `bool` gave the same one for both.
439 ///
440 /// That conflation is what shipped unformatted code: `prettier.rs` read
441 /// `if wrote && restage(&files)`, so a failed `git add` fell through to a
442 /// second `--check` against the now-formatted WORKING TREE, which passed —
443 /// while the INDEX still held the unformatted content the commit would
444 /// carry.
445 ///
446 /// An absolute path outside any repository is a `git add` git will always
447 /// refuse, which is the only way to reach the failing branch without
448 /// sabotaging a real index.
449 #[test]
450 fn restage_distinguishes_nothing_from_failure() {
451 let outside = std::env::temp_dir()
452 .join("amont-restage-outside-any-repo")
453 .to_string_lossy()
454 .into_owned();
455 assert_eq!(
456 restage(std::slice::from_ref(&outside)),
457 Restaged::Failed(vec![outside]),
458 "a `git add` git refuses must report Failed, never Nothing"
459 );
460 assert_eq!(
461 restage(&[]),
462 Restaged::Nothing,
463 "no paths is nothing to do, and nothing wrong"
464 );
465 }
466
467 /// No check may hand `Command` a bare program name.
468 ///
469 /// `Command::new` does NO PATHEXT resolution, so `Command::new("npm")`
470 /// cannot execute `npm.cmd` and `Command::new("uvx")` cannot execute
471 /// `uvx.exe`: the spawn fails with "program not found" and a
472 /// `Severity::Block` check reports an installed tool as broken. That is the
473 /// incident `program()` exists for, and it kept recurring — `yamllint` and
474 /// three sites in `python_tools` were still doing it, THREE OF THEM after
475 /// `which()` had already succeeded and discarded the answer.
476 ///
477 /// A source scan rather than a runtime assertion because the failure only
478 /// reproduces on Windows, and the whole point is to catch the next one on
479 /// every platform. Comment lines are skipped: `program()`'s own doc quotes
480 /// the offending call. The needle is assembled from two pieces so this
481 /// module — which the scan also reads — does not match itself.
482 #[test]
483 fn no_hook_spawns_a_bare_program_name() {
484 let needle = concat!("Command", "::new(");
485 let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/src/hooks");
486 let mut scanned = 0usize;
487 for entry in std::fs::read_dir(dir).expect("hooks dir").flatten() {
488 let path = entry.path();
489 if path.extension().and_then(|e| e.to_str()) != Some("rs") {
490 continue;
491 }
492 scanned += 1;
493 let src = std::fs::read_to_string(&path).expect("read a hook module");
494 for (n, line) in src.lines().enumerate() {
495 if line.trim_start().starts_with("//") {
496 continue;
497 }
498 let Some(after) = line.split_once(needle) else {
499 continue;
500 };
501 assert!(
502 !after.1.starts_with('"'),
503 "{}:{} spawns a bare name — route it through `program()` or \
504 the path `which()` already resolved: {}",
505 path.display(),
506 n + 1,
507 line.trim()
508 );
509 }
510 }
511 assert!(
512 scanned > 10,
513 "the scan found almost nothing: {scanned} files"
514 );
515 }
516
517 #[test]
518 fn first_existing_picks_the_earliest_present_name() {
519 let dir = std::env::temp_dir().join("amont-first-existing-test");
520 let _ = std::fs::create_dir_all(&dir);
521 let root = dir.to_string_lossy().into_owned();
522 let _ = std::fs::write(dir.join("second"), "x");
523 assert_eq!(
524 first_existing(&root, &["first", "second", "third"]).as_deref(),
525 Some("second")
526 );
527 assert_eq!(first_existing(&root, &["nope"]), None);
528 let _ = std::fs::remove_dir_all(&dir);
529 }
530}