Skip to main content

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}