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.
62///
63/// The UNFILTERED list is read from git ONCE per process and lent to every
64/// caller — the per-stage snapshot. Eleven of the pre-commit checks ask this
65/// question, concurrently, and each used to pay its own `git diff` spawn for
66/// an answer that cannot change while the stage runs: the index-fidelity
67/// hold pins the tree, and a fixer's `restage()` re-adds only paths already
68/// on this list. `PushRefs` ("read once and lent") and `Overrides` ("ONE
69/// subprocess for the whole stage") are the same pattern; this was the last
70/// hot question still answered per asker.
71pub fn staged_files(exts: &[&str]) -> Vec<String> {
72    if let Some(all) = OVERRIDE.get() {
73        return all
74            .iter()
75            .filter(|f| exts.is_empty() || exts.iter().any(|e| f.ends_with(e)))
76            .cloned()
77            .collect();
78    }
79    static INDEX: OnceLock<Vec<String>> = OnceLock::new();
80    INDEX
81        .get_or_init(|| {
82            match git::stdout_paths(&["diff", "--diff-filter=d", "--cached", "--name-only"]) {
83                Some(files) => files,
84                // The third member of a bug family (`repo_hooks`, the push
85                // gates): git FAILING is not git answering "empty", and a
86                // stage that judges an empty set on a git failure reports
87                // clean having verified nothing. Say so — once, this cache
88                // being the once — and still fail open: pre-commit's job is
89                // never to block a commit over its own plumbing.
90                None => {
91                    warn(
92                        "git would not list the staged files — the checks are judging \
93                         an EMPTY set, not a verified one",
94                    );
95                    Vec::new()
96                }
97            }
98        })
99        .iter()
100        .filter(|f| exts.is_empty() || exts.iter().any(|e| f.ends_with(e)))
101        .cloned()
102        .collect()
103}
104
105/// Repo root, or "." when git cannot say.
106///
107/// **For CHECK BODIES ONLY.** The fallback is safe there and nowhere else: git
108/// invokes a hook with the working tree as the current directory, so a check
109/// that reaches this line is already standing in the repository, and "." is the
110/// right answer rather than a guess.
111///
112/// Anything a user types — `amont agents-md`, `install`, `trust`, `restore`
113/// — can be typed from any directory on the machine, and there the fallback is
114/// not a fallback but a wrong answer that reads as a right one. Use
115/// [`repo_root_checked`] at every command entry point.
116pub fn repo_root() -> String {
117    // Cached: the answer is a property of the process's repository, and
118    // every check asked it through its own subprocess.
119    static ROOT: OnceLock<String> = OnceLock::new();
120    ROOT.get_or_init(|| {
121        git::stdout(&["rev-parse", "--show-toplevel"]).unwrap_or_else(|| ".".into())
122    })
123    .clone()
124}
125
126/// Repo root, or an error naming the problem.
127///
128/// The same question as [`repo_root`] without the "." — because "." is a
129/// PLAUSIBLE root, and that is what made it dangerous. `amont agents-md`
130/// run outside a repository did not fail; it resolved the root to the current
131/// directory and wrote `./AGENTS.md` into whatever directory the user happened
132/// to be standing in, then printed `wrote ./AGENTS.md` as if that were the
133/// answer. Same shape in `install`'s two prompts, in `trust` (which then
134/// looked for a manifest, and would have recorded trust, under `.`) and in
135/// `restore`.
136///
137/// Every one of those is a command somebody types, and a command somebody
138/// types is a command they can type from `~`. There is no correct behaviour
139/// available to this function when git cannot answer, so it does not invent
140/// one.
141pub fn repo_root_checked() -> Result<String, String> {
142    git::stdout(&["rev-parse", "--show-toplevel"])
143        .filter(|s| !s.is_empty())
144        .ok_or_else(|| "not inside a git repository".to_string())
145}
146
147/// Resolve a tool, preferring the repo's PINNED copy so the hook matches CI.
148///
149///
150/// Order: `<root>/node_modules/.bin/<tool>`, then the MAIN worktree's (a linked
151/// worktree has no node_modules of its own — this is why the shell version
152/// consulted the git common dir), then PATH.
153pub fn resolve_tool(root: &str, tool: &str) -> Option<Vec<String>> {
154    // Same extension problem as `which`: an npm-installed binary is `eslint.cmd`
155    // on Windows, so the bare name misses the repo's PINNED copy and the hook
156    // silently falls through to an ambient one.
157    if let Some(p) = in_bin_dir(&format!("{root}/node_modules/.bin"), tool) {
158        return Some(vec![p]);
159    }
160    if let Some(common) = git::stdout(&["rev-parse", "--path-format=absolute", "--git-common-dir"])
161    {
162        if let Some(main) = Path::new(&common).parent() {
163            if let Some(p) = in_bin_dir(&main.join("node_modules/.bin").to_string_lossy(), tool) {
164                return Some(vec![p]);
165            }
166        }
167    }
168    if let Some(full) = which(tool) {
169        return Some(vec![full]);
170    }
171    // `npx --no-install`: never silently download a random latest version — a
172    // hook that quietly pulls a different linter than CI uses is worse than one
173    // that skips.
174    if which("npx").is_some()
175        && Command::new(program("npx"))
176            .args(["--no-install", tool, "--version"])
177            .current_dir(root)
178            .stdin(Stdio::null())
179            .stdout(Stdio::null())
180            .stderr(Stdio::null())
181            .status()
182            .map(|s| s.success())
183            .unwrap_or(false)
184    {
185        return Some(vec![
186            program("npx"),
187            "--no-install".to_string(),
188            tool.to_string(),
189        ]);
190    }
191    None
192}
193
194/// First match for `tool` on PATH.
195///
196/// Windows executables carry an extension — `git` is `git.exe`, an npm-installed
197/// `eslint` is `eslint.cmd` — so the bare name finds nothing there. PATHEXT is
198/// the OS's own list of what counts as executable; fall back to the usual set
199/// when it is unset. Found by the Windows CI job on its first run, where
200/// `which("git")` returned None on a machine that plainly has git.
201pub fn which(tool: &str) -> Option<String> {
202    which_on(&std::env::var_os("PATH")?, tool)
203}
204
205/// [`which`] against an EXPLICIT path list — the seam its own test needs.
206///
207/// The test that pins the Windows extension order used to `set_var("PATH")`
208/// around the call, which is process-global: for the length of that call
209/// every OTHER test in the binary — 340 of them, running in parallel, many
210/// spawning git — had a PATH containing one fake tool and nothing else. A
211/// git spawned in that window fails with "not found", which is not a
212/// transient `git::retrying` may retry (correctly: it is a hard error), so
213/// the caller reads it as git's ANSWER. In `gate_stamp` that answer is
214/// "nothing is stamped". Passing the path in deletes the shared state
215/// rather than guarding it — a lock only protects the callers who remember
216/// to take it, and every future test here would have to remember.
217pub fn which_on(path: &std::ffi::OsStr, tool: &str) -> Option<String> {
218    let exts: Vec<String> = if cfg!(windows) {
219        std::env::var("PATHEXT")
220            .unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".into())
221            .split(';')
222            .filter(|e| !e.is_empty())
223            .map(|e| e.to_lowercase())
224            .collect()
225    } else {
226        Vec::new()
227    };
228    for dir in std::env::split_paths(path) {
229        // On Windows the EXTENSION forms come first. A node install ships both
230        // `npm` (an extensionless shell script, for MSYS) and `npm.cmd` in the
231        // same directory; preferring the bare name hands CreateProcess a shell
232        // script it cannot execute — "%1 is not a valid Win32 application" —
233        // and the hook reports an installed tool as broken.
234        for e in &exts {
235            let c = dir.join(format!("{tool}{e}"));
236            if c.is_file() {
237                return Some(c.to_string_lossy().into_owned());
238            }
239        }
240        let bare = dir.join(tool);
241        if bare.is_file() {
242            return Some(bare.to_string_lossy().into_owned());
243        }
244    }
245    None
246}
247
248/// `<dir>/<tool>`, trying the Windows executable extensions too.
249fn in_bin_dir(dir: &str, tool: &str) -> Option<String> {
250    let bare = Path::new(dir).join(tool);
251    if bare.is_file() {
252        return Some(bare.to_string_lossy().into_owned());
253    }
254    if cfg!(windows) {
255        for e in [".cmd", ".exe", ".bat", ".ps1"] {
256            let c = Path::new(dir).join(format!("{tool}{e}"));
257            if c.is_file() {
258                return Some(c.to_string_lossy().into_owned());
259            }
260        }
261    }
262    None
263}
264
265/// Resolve a tool name to a full path for spawning.
266///
267/// `Command::new("npm")` cannot execute `npm.cmd`: Rust does no PATHEXT
268/// resolution, so on Windows every bare-name spawn fails with "program not
269/// found" and the hook reports the tool as broken rather than absent. Found by
270/// the Windows job on its first FULL-suite run — the smoke never spawned a
271/// tool, so it could not have surfaced this.
272///
273/// Falls back to the name unchanged, so a caller still gets a sensible error.
274pub fn program(name: &str) -> String {
275    which(name).unwrap_or_else(|| name.to_string())
276}
277
278/// The first of `names` that exists at the repo root — how these hooks decide
279/// a repo has opted into a tool.
280pub fn first_existing(root: &str, names: &[&str]) -> Option<String> {
281    names
282        .iter()
283        .find(|n| Path::new(root).join(n).exists())
284        .map(|n| (*n).to_string())
285}
286
287/// Strip git's own environment before handing a Command to another tool.
288///
289/// git exports GIT_DIR, GIT_INDEX_FILE, GIT_WORK_TREE and friends to every
290/// hook. Those OVERRIDE the working directory, so any tool that shells out to
291/// git operates on the hook's repository no matter where it was launched.
292///
293/// That is not hypothetical: `pre-push-cargo-test` runs a project's test suite,
294/// and this repo's own suite creates throwaway repos and commits to them. With
295/// GIT_DIR inherited, `git commit` in a test wrote into the REAL repository —
296/// an actual stray commit, authored by the test fixture, pushed to a branch.
297///
298/// A test suite should behave exactly as it does when run by hand, which means
299/// seeing no git environment at all.
300pub fn strip_git_env(cmd: &mut Command) {
301    for (k, _) in std::env::vars_os() {
302        let key = k.to_string_lossy();
303        if key.starts_with("GIT_") {
304            cmd.env_remove(&k);
305        }
306    }
307}
308
309/// The wall-clock CEILING for one check's spawned command, in seconds.
310///
311/// `amont.timeout`, default 3600. This used to be 600 and to be the only
312/// clock, which made it answer two different questions with one number: "is
313/// this tool stuck?" and "is this suite slow?". A stuck tool is silent, and
314/// [`idle_timeout`] catches it in minutes; what is left for the ceiling is
315/// the tool that keeps printing and never finishes, which is rare enough to
316/// afford an hour. `0` disables. Read once per process: twenty concurrent
317/// checks must not each spawn a `git config` to learn the same number.
318pub fn check_timeout() -> u64 {
319    static TIMEOUT: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
320    *TIMEOUT.get_or_init(|| crate::config::integer_or("amont.timeout", 3600, 0..=86_400) as u64)
321}
322
323/// The SILENCE budget: how long a spawned command may go without writing a
324/// byte before it is judged stuck, in seconds.
325///
326/// `amont.idleTimeout`, default 120. A hang is silent; a slow test suite
327/// talks — `cargo test` prints a line per test. Killing on silence catches
328/// the captive portal, the deadlocked lock file and the tool waiting on a
329/// prompt nobody will answer FASTER than a ten-minute wall clock did, while
330/// letting a chatty twenty-five-minute suite finish. Only applies where the
331/// output is observed (the captured runners); a command inheriting the
332/// terminal directly answers to the ceiling alone. `0` disables.
333pub fn idle_timeout() -> u64 {
334    static IDLE: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
335    *IDLE.get_or_init(|| crate::config::integer_or("amont.idleTimeout", 120, 0..=86_400) as u64)
336}
337
338/// `secs` as people read it: `12s`, `8m12s`, `1h02m`.
339pub fn human_secs(secs: u64) -> String {
340    match secs {
341        s if s < 60 => format!("{s}s"),
342        s if s < 3600 => format!("{}m{:02}s", s / 60, s % 60),
343        s => format!("{}h{:02}m", s / 3600, (s % 3600) / 60),
344    }
345}
346
347/// When a spawned command last wrote a byte, shared between the reader
348/// threads that see the bytes and the wait loop that judges the silence.
349pub struct Activity {
350    last: std::sync::Mutex<std::time::Instant>,
351}
352
353impl Activity {
354    pub fn new() -> std::sync::Arc<Activity> {
355        std::sync::Arc::new(Activity {
356            last: std::sync::Mutex::new(std::time::Instant::now()),
357        })
358    }
359    pub fn touch(&self) {
360        *self.last.lock().unwrap_or_else(|p| p.into_inner()) = std::time::Instant::now();
361    }
362    pub fn quiet_for(&self) -> std::time::Duration {
363        self.last
364            .lock()
365            .unwrap_or_else(|p| p.into_inner())
366            .elapsed()
367    }
368}
369
370/// The deadline for a network PROBE — an `ls-remote` asked before the real
371/// work, not the work itself. Capped at 30s below [`check_timeout`]: a
372/// probe answers in a second or two when the network is there at all, and
373/// a healthy `amont.timeout` of ten minutes is sized for a test suite, not
374/// for deciding whether the remote is reachable. Shrinking `amont.timeout`
375/// below the cap shrinks this too, and `0` keeps meaning no deadline —
376/// somebody who disabled the clock disabled all of it.
377pub fn network_probe_budget() -> u64 {
378    match check_timeout() {
379        0 => 0,
380        t => t.min(30),
381    }
382}
383
384/// Which clock killed a command.
385#[derive(Debug, Clone, Copy, PartialEq, Eq)]
386pub enum Why {
387    /// The wall-clock ceiling, `amont.timeout`, in seconds.
388    Ceiling(u64),
389    /// The silence budget, `amont.idleTimeout`, in seconds.
390    Silence(u64),
391}
392
393/// A command killed by a clock — what happened, said with enough to tell
394/// "slow" from "stuck", which is the whole reason there are two clocks.
395#[derive(Debug, Clone, Copy)]
396pub struct Killed {
397    pub why: Why,
398    /// How long it had been running.
399    pub ran_secs: u64,
400    /// How long since its last output; `None` when the output was not ours
401    /// to observe (inherited stdio).
402    pub quiet_secs: Option<u64>,
403}
404
405/// What became of a command run under the deadline.
406pub enum Ran {
407    Status(std::process::ExitStatus),
408    /// Killed by a clock; see [`Killed`].
409    TimedOut(Killed),
410}
411
412/// `cmd.status()`, bounded by [`check_timeout`].
413///
414/// Without a bound, one hung tool — a linter deadlocked on a lock file, a
415/// plugin doing network I/O — blocked the commit FOREVER, and it hung inside
416/// the index-fidelity hold: the user's unstaged changes parked in `$GIT_DIR`,
417/// their tree showing staged content only, for as long as they were willing
418/// to wait. The learned response to that is `--no-verify`, permanently —
419/// which disarms every check to escape one.
420///
421/// The kill reaches the direct child only. A grandchild that detached
422/// survives, orphaned — but the COMMIT is no longer hostage to it, which is
423/// the property that matters.
424pub fn status_within(cmd: &mut Command) -> std::io::Result<Ran> {
425    status_within_secs(cmd, check_timeout())
426}
427
428/// [`status_within`] with an explicit ceiling — the testable seam. The
429/// output is inherited, so nobody sees the bytes and the silence budget
430/// cannot apply; the ceiling is the only clock.
431pub fn status_within_secs(cmd: &mut Command, budget_secs: u64) -> std::io::Result<Ran> {
432    if budget_secs == 0 {
433        return cmd.status().map(Ran::Status);
434    }
435    let mut child = cmd.spawn()?;
436    wait_within(&mut child, budget_secs, 0, None)
437}
438
439/// Spawn `cmd` with both streams piped, hand every chunk to `on_output` as
440/// it arrives, and wait under BOTH clocks — the reader threads are what
441/// make the silence budget observable. The shared runner behind the
442/// streamed, captured and discarded variants.
443fn run_observed(
444    cmd: &mut Command,
445    on_output: impl Fn(&[u8]) + Send + Sync + 'static,
446) -> std::io::Result<Ran> {
447    cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
448    let activity = Activity::new();
449    let on_output = std::sync::Arc::new(on_output);
450    let mut child = cmd.spawn()?;
451    // The silence is the CHILD's, so its clock starts when the child does:
452    // a spawn that itself took a second on a loaded machine is not a
453    // second the tool spent saying nothing.
454    activity.touch();
455    let mut readers = Vec::new();
456    for pipe in [
457        child
458            .stdout
459            .take()
460            .map(|p| Box::new(p) as Box<dyn std::io::Read + Send>),
461        child
462            .stderr
463            .take()
464            .map(|p| Box::new(p) as Box<dyn std::io::Read + Send>),
465    ]
466    .into_iter()
467    .flatten()
468    {
469        let activity = std::sync::Arc::clone(&activity);
470        let on_output = std::sync::Arc::clone(&on_output);
471        readers.push(std::thread::spawn(move || {
472            let mut pipe = pipe;
473            let mut chunk = [0u8; 4096];
474            loop {
475                match std::io::Read::read(&mut pipe, &mut chunk) {
476                    Ok(0) | Err(_) => break,
477                    Ok(n) => {
478                        activity.touch();
479                        on_output(&chunk[..n]);
480                    }
481                }
482            }
483        }));
484    }
485    let ran = wait_within(&mut child, check_timeout(), idle_timeout(), Some(&activity));
486    for r in readers {
487        let _ = r.join();
488    }
489    ran
490}
491
492/// [`status_within`], with the child's stdout and stderr CAPTURED into the
493/// calling check's slot instead of inherited — the other half of one-check-
494/// one-block: a linter's twelve lines used to land on the shared terminal
495/// between two other checks' lines. Falls back to plain [`status_within`]
496/// when no slot is installed on this thread (`amont.progress false`, or a
497/// spawn outside a stage), which is byte-for-byte the old behaviour.
498///
499/// stdout and stderr merge in ARRIVAL order inside the block, which is what
500/// the terminal showed before. The readers are threads, not processes, and
501/// they are joined before the status is returned so a block can never grow
502/// after its check finished.
503pub fn status_streamed(cmd: &mut Command) -> std::io::Result<Ran> {
504    let Some((stage, idx)) = crate::live::current_sink() else {
505        return status_within(cmd);
506    };
507    if crate::live::watching() {
508        // The block lands on a real terminal but the tool sees a pipe and
509        // would strip its colors; the big three opt-in knobs put them back.
510        cmd.env("FORCE_COLOR", "1")
511            .env("CLICOLOR_FORCE", "1")
512            .env("CARGO_TERM_COLOR", "always");
513    }
514    run_observed(cmd, move |bytes| stage.append_raw(idx, bytes))
515}
516
517/// Run to completion under the `amont.timeout` deadline with stdout and
518/// stderr CAPTURED into a string the caller can parse — what the audit
519/// checks need: their verdict lives in the tool's output, not its exit
520/// code alone. Arrival-ordered merge of both streams, like
521/// [`status_streamed`]'s blocks. `None` when the child cannot be spawned.
522pub fn capture_within(cmd: &mut Command) -> Option<(Ran, String)> {
523    let text = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
524    let sink = std::sync::Arc::clone(&text);
525    let ran = run_observed(cmd, move |bytes| {
526        sink.lock()
527            .unwrap_or_else(|p| p.into_inner())
528            .push_str(&String::from_utf8_lossy(bytes));
529    })
530    .ok()?;
531    let text = std::sync::Arc::try_unwrap(text)
532        .map(|m| m.into_inner().unwrap_or_else(|p| p.into_inner()))
533        .unwrap_or_default();
534    Some((ran, text))
535}
536
537/// The two-clock wait over an already-spawned child — shared by every
538/// runner. `wall_secs` is the ceiling, `idle_secs` the silence budget; each
539/// `0` means that clock is off, and the silence budget is also off when
540/// there is no [`Activity`] to consult (inherited stdio).
541pub(crate) fn wait_within(
542    child: &mut std::process::Child,
543    wall_secs: u64,
544    idle_secs: u64,
545    activity: Option<&Activity>,
546) -> std::io::Result<Ran> {
547    let started = std::time::Instant::now();
548    let ceiling = (wall_secs > 0).then(|| started + std::time::Duration::from_secs(wall_secs));
549    let silence = match activity {
550        Some(_) if idle_secs > 0 => Some(std::time::Duration::from_secs(idle_secs)),
551        _ => None,
552    };
553    if ceiling.is_none() && silence.is_none() {
554        return child.wait().map(Ran::Status);
555    }
556    loop {
557        if let Some(status) = child.try_wait()? {
558            return Ok(Ran::Status(status));
559        }
560        let now = std::time::Instant::now();
561        let quiet = activity.map(|a| a.quiet_for());
562        let why = judge(
563            now.duration_since(started),
564            quiet,
565            ceiling.map(|_| wall_secs),
566            silence.map(|_| idle_secs),
567        );
568        if let Some(why) = why {
569            let _ = child.kill();
570            let _ = child.wait();
571            return Ok(Ran::TimedOut(Killed {
572                why,
573                ran_secs: now.duration_since(started).as_secs(),
574                quiet_secs: quiet.map(|q| q.as_secs()),
575            }));
576        }
577        std::thread::sleep(std::time::Duration::from_millis(25));
578    }
579}
580
581/// Which clock, if any, has fired — the decision, with no process or
582/// clock of its own so it can be tested to the second.
583///
584/// `ran` is how long the command has been running; `quiet` how long since
585/// it last wrote, `None` when nobody is watching its output. `ceiling` and
586/// `silence` are the two budgets in seconds, `None` when that clock is off.
587/// The ceiling wins when both have fired: it is the larger claim, and the
588/// message for it carries the silence figure anyway.
589pub fn judge(
590    ran: std::time::Duration,
591    quiet: Option<std::time::Duration>,
592    ceiling: Option<u64>,
593    silence: Option<u64>,
594) -> Option<Why> {
595    if let Some(wall) = ceiling {
596        if ran >= std::time::Duration::from_secs(wall) {
597            return Some(Why::Ceiling(wall));
598        }
599    }
600    if let (Some(idle), Some(q)) = (silence, quiet) {
601        if q >= std::time::Duration::from_secs(idle) {
602            return Some(Why::Silence(idle));
603        }
604    }
605    None
606}
607
608/// Say a command was killed, by which clock, and what that tells you.
609///
610/// The two clocks exist to answer two different questions, so the message
611/// answers the one that was asked: silence means stuck — look at the tool;
612/// the ceiling with recent output means slow — raise the ceiling.
613pub fn say_timed_out(what: &str, k: Killed) {
614    match k.why {
615        Why::Silence(budget) => fail(&format!(
616            "{} printed nothing for {} and was killed after {} — a tool this quiet is \
617             usually stuck, not slow. {} raises the silence budget (0 disables)",
618            hl(what),
619            human_secs(budget),
620            human_secs(k.ran_secs),
621            hl("git config amont.idleTimeout <secs>")
622        )),
623        Why::Ceiling(budget) => {
624            let verdict = match k.quiet_secs {
625                Some(q) if q < 30 => format!(
626                    " It was still printing ({} since its last line): slow, not stuck.",
627                    human_secs(q)
628                ),
629                Some(q) => format!(" Its last output was {} ago.", human_secs(q)),
630                None => String::new(),
631            };
632            fail(&format!(
633                "{} timed out: ran for {} and was killed at the ceiling. {} raises it \
634                 (0 disables).{verdict}",
635                hl(what),
636                human_secs(budget),
637                hl("git config amont.timeout <secs>")
638            ))
639        }
640    }
641}
642
643/// [`status_within`], collapsed to "did it exit 0" — the shape the one-shot
644/// tool spawns want. A timeout says so, names `what`, and reads as failure.
645pub fn bounded_success(cmd: &mut Command, what: &str) -> bool {
646    match status_streamed(cmd) {
647        Ok(Ran::Status(s)) => s.success(),
648        Ok(Ran::TimedOut(b)) => {
649            say_timed_out(what, b);
650            false
651        }
652        Err(_) => false,
653    }
654}
655
656/// Run `argv` from `root`, inheriting stdio. True when it exits 0.
657pub fn run(root: &str, argv: &[String], extra: &[String]) -> bool {
658    let Some((program, rest)) = argv.split_first() else {
659        return true;
660    };
661    let mut cmd = Command::new(program);
662    cmd.args(rest)
663        .args(extra)
664        .current_dir(root)
665        .stdin(Stdio::null());
666    strip_git_env(&mut cmd);
667    bounded_success(&mut cmd, program)
668}
669
670/// As [`run`], but with the tool's own output discarded.
671///
672/// For a pass whose only job is to decide something — prettier's `--check`,
673/// ruff's `--fix` sweep — where the offenders are printed once, by the pass
674/// that reports them, rather than twice.
675pub fn run_quiet(root: &str, argv: &[String], extra: &[String]) -> bool {
676    let Some((program, rest)) = argv.split_first() else {
677        return true;
678    };
679    let mut cmd = Command::new(program);
680    cmd.args(rest)
681        .args(extra)
682        .current_dir(root)
683        .stdin(Stdio::null());
684    strip_git_env(&mut cmd);
685    // Deliberately NOT the streamed runner: this helper's contract is that
686    // the output is discarded, and capture would resurrect it into the
687    // block. Observed and dropped instead of `/dev/null`, so the silence
688    // clock still sees whether the tool is alive.
689    match run_observed(&mut cmd, |_| {}) {
690        Ok(Ran::Status(s)) => s.success(),
691        Ok(Ran::TimedOut(b)) => {
692            say_timed_out(program, b);
693            false
694        }
695        Err(_) => false,
696    }
697}
698
699/// Whether the user asked for checks to repair what they find.
700///
701/// OFF by default. `git config amont.fix true` turns it on, per repository,
702/// because a hook that edits your files without being asked is a larger
703/// surprise than one that complains — and because with index fidelity in place
704/// the repair lands in the commit you are making, which is a bigger claim to
705/// make on somebody's behalf than printing an error.
706pub fn fixing_enabled() -> bool {
707    // Never while the file set is not the index — see `NOT_THE_INDEX`.
708    !not_the_index() && fixing_requested()
709}
710
711/// What the CONFIG says, ignoring whether the current run may act on it.
712///
713/// Split out so `run_all` can tell the difference between "fixing is off" and
714/// "you asked for fixing and this mode will not do it", and say the second out
715/// loud instead of silently ignoring the key.
716pub fn fixing_requested() -> bool {
717    crate::config::boolean_or("amont.fix", false)
718}
719
720/// What a re-stage actually did. THREE answers, because the old `bool`
721/// conflated two of them and the conflation shipped unformatted code.
722///
723/// `prettier.rs` read `if run_quiet(write) && restage(&files) { … Fixed }`. When
724/// `git add` FAILED, `restage` returned `false` — indistinguishable from
725/// "nothing needed staging" — so control fell through to a second `--check`
726/// pass, which inspected the NOW-FORMATTED WORKING TREE, passed, printed
727/// "Prettier passed" and returned `Outcome::Passed`. The index still held the
728/// unformatted content, so the commit contained unformatted code and the hook
729/// said it had passed. `manifest.rs` had the same shape.
730#[derive(Debug, Clone, PartialEq, Eq)]
731pub enum Restaged {
732    /// No path differed from the index — nothing to do, and nothing wrong.
733    Nothing,
734    /// `git add` succeeded; the index now holds the repair.
735    Staged,
736    /// `git add` failed, carrying the paths it could not stage. The index
737    /// holds content the fixer has already replaced on disk, so this MUST be
738    /// loud at every call site — and naming the files is the difference
739    /// between a message somebody can act on and one they cannot.
740    Failed(Vec<String>),
741}
742
743/// Serialises this process's own `git add` calls.
744///
745/// pre-commit runs its checks concurrently (`dispatch.rs`), and up to three of
746/// them can re-stage. git takes `$GIT_DIR/index.lock` exclusively, so two
747/// concurrent `git add`s in the same repository make one of them fail — which,
748/// before `Restaged`, was silently read as "nothing moved". Holding this across
749/// the `git add` removes self-contention entirely; the retry below is only for
750/// OTHER processes.
751static INDEX_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
752
753/// Re-stage exactly the paths a fixer rewrote, and say what happened.
754///
755/// Safe ONLY because the pre-commit stage holds unstaged changes aside: the
756/// tree contains the staged content and nothing else, so anything a formatter
757/// touched is by definition part of this commit. Without that, re-staging would
758/// sweep in work the author deliberately kept back.
759pub fn restage(paths: &[String]) -> Restaged {
760    // Belt and braces alongside `fixing_enabled`: a future fixer that forgets
761    // the gate still cannot turn `amont run --all-files` into `git add .`.
762    if not_the_index() {
763        return Restaged::Nothing;
764    }
765    let changed: Vec<String> = paths
766        .iter()
767        .filter(|p| !git::succeeds(&["diff", "--quiet", "--", p]))
768        .cloned()
769        .collect();
770    if changed.is_empty() {
771        return Restaged::Nothing;
772    }
773    let mut args = vec!["add", "--"];
774    args.extend(changed.iter().map(String::as_str));
775
776    let _serialised = INDEX_LOCK.lock().unwrap_or_else(|e| e.into_inner());
777    // Another PROCESS can hold `index.lock` — a `git status` from an editor, a
778    // second hook in a linked worktree. Back off and retry rather than
779    // reporting a transient collision as a failed repair. `git add` of the same
780    // paths is idempotent: it records the paths' current worktree content, so
781    // running it twice records the same thing twice and cannot double-stage.
782    const BACKOFF_MS: [u64; 3] = [50, 150, 400];
783    if git::succeeds(&args) {
784        return Restaged::Staged;
785    }
786    for wait in BACKOFF_MS {
787        std::thread::sleep(std::time::Duration::from_millis(wait));
788        if git::succeeds(&args) {
789            return Restaged::Staged;
790        }
791    }
792    Restaged::Failed(changed)
793}
794
795pub fn ok(msg: &str) {
796    crate::live::say(&format!("{} {msg}", valid_sign()));
797}
798pub fn fail(msg: &str) {
799    crate::live::say(&format!("{} {msg}", error_sign()));
800}
801pub fn warn(msg: &str) {
802    crate::live::say(&format!("{} {msg}", warning_sign()));
803}
804/// A line with no sign of its own — what a check's direct `println!` becomes,
805/// so it lands in the check's block instead of interleaving. See `live::say`.
806pub fn say(msg: &str) {
807    crate::live::say(msg);
808}
809
810/// Orange, for the fragments these hooks highlight.
811pub fn hl(s: &str) -> String {
812    crate::ui::highlight(s)
813}
814
815#[cfg(test)]
816mod tests {
817
818    /// The two clocks, decided to the second. A chatty command outlives any
819    /// silence budget however long it runs; a silent one dies at the budget
820    /// however short; a command nobody watches answers to the ceiling only.
821    #[test]
822    fn the_clocks_judge_silence_and_ceiling_separately() {
823        use super::{judge, Why};
824        use std::time::Duration as D;
825        let s = D::from_secs;
826        // Chatty and long: past a five-second silence budget, still fine.
827        assert_eq!(judge(s(900), Some(s(0)), Some(3600), Some(5)), None);
828        assert_eq!(judge(s(900), Some(s(4)), Some(3600), Some(5)), None);
829        // Silent for the budget: killed, and the silence is blamed.
830        assert_eq!(
831            judge(s(30), Some(s(5)), Some(3600), Some(5)),
832            Some(Why::Silence(5))
833        );
834        // Unobserved output: the silence clock cannot run at all.
835        assert_eq!(judge(s(900), None, Some(3600), Some(5)), None);
836        // The ceiling fires on elapsed time whatever the output is doing.
837        assert_eq!(
838            judge(s(3600), Some(s(0)), Some(3600), Some(120)),
839            Some(Why::Ceiling(3600))
840        );
841        // Both fired at once: the ceiling is the answer.
842        assert_eq!(
843            judge(s(3600), Some(s(600)), Some(3600), Some(120)),
844            Some(Why::Ceiling(3600))
845        );
846        // Both off: nothing ever fires.
847        assert_eq!(judge(s(86_400), Some(s(86_400)), None, None), None);
848        // Only silence on: no ceiling, however long it runs.
849        assert_eq!(judge(s(86_400), Some(s(1)), None, Some(120)), None);
850    }
851
852    /// The deadline kills what outlives it and reports what finished.
853    #[cfg(unix)]
854    #[test]
855    fn the_deadline_kills_a_sleeper_and_spares_a_finisher() {
856        let started = std::time::Instant::now();
857        let mut slow = Command::new(program("sleep"));
858        slow.arg("300").stdin(Stdio::null());
859        match status_within_secs(&mut slow, 1) {
860            Ok(Ran::TimedOut(Killed {
861                why: Why::Ceiling(1),
862                quiet_secs: None,
863                ..
864            })) => {}
865            other => panic!("expected TimedOut(1), got {:?}", other.map(|_| "ran")),
866        }
867        assert!(
868            started.elapsed() < std::time::Duration::from_secs(60),
869            "the kill did not happen at the deadline"
870        );
871
872        let mut quick = Command::new(program("true"));
873        quick.stdin(Stdio::null());
874        match status_within_secs(&mut quick, 60) {
875            Ok(Ran::Status(s)) => assert!(s.success()),
876            other => panic!("expected a clean exit, got {:?}", other.map(|_| "?")),
877        }
878    }
879
880    use super::*;
881
882    #[test]
883    fn which_finds_a_real_binary_and_not_a_fake_one() {
884        assert!(which("git").is_some());
885        assert!(which("definitely-not-a-real-binary-xyz").is_none());
886    }
887
888    /// On Windows a tool can exist BOTH as an extensionless shell script and as
889    /// a .cmd/.exe in the same directory; only the latter is executable by
890    /// CreateProcess, so the extension forms must win.
891    #[test]
892    #[cfg(windows)]
893    fn windows_prefers_an_executable_extension_over_a_bare_file() {
894        let dir = std::env::temp_dir().join("amont-which-order");
895        let _ = std::fs::create_dir_all(&dir);
896        std::fs::write(dir.join("faketool"), "#!/bin/sh\n").unwrap();
897        std::fs::write(dir.join("faketool.cmd"), "@echo off\n").unwrap();
898        // The path is PASSED, never installed into this process: see
899        // `which_on`. The old spelling swapped the real PATH out from under
900        // every other test in this binary for the length of the call.
901        let found = which_on(dir.as_os_str(), "faketool").unwrap();
902        assert!(found.ends_with(".cmd"), "got {found}");
903        let _ = std::fs::remove_dir_all(&dir);
904    }
905
906    /// "Nothing moved" and "`git add` FAILED" are different answers, and the
907    /// old `bool` gave the same one for both.
908    ///
909    /// That conflation is what shipped unformatted code: `prettier.rs` read
910    /// `if wrote && restage(&files)`, so a failed `git add` fell through to a
911    /// second `--check` against the now-formatted WORKING TREE, which passed —
912    /// while the INDEX still held the unformatted content the commit would
913    /// carry.
914    ///
915    /// An absolute path outside any repository is a `git add` git will always
916    /// refuse, which is the only way to reach the failing branch without
917    /// sabotaging a real index.
918    #[test]
919    fn restage_distinguishes_nothing_from_failure() {
920        // `restage` runs `git add` in the PROCESS cwd, so this test depends
921        // on that cwd as surely as one that moves it — see `crate::TEST_CWD`.
922        // Without the lock it ran inside whatever fixture `gate_stamp` had
923        // moved into, and took that repository's index.lock out from under
924        // its own commit.
925        let _cwd = crate::TEST_CWD.lock().unwrap_or_else(|p| p.into_inner());
926        let outside = std::env::temp_dir()
927            .join("amont-restage-outside-any-repo")
928            .to_string_lossy()
929            .into_owned();
930        assert_eq!(
931            restage(std::slice::from_ref(&outside)),
932            Restaged::Failed(vec![outside]),
933            "a `git add` git refuses must report Failed, never Nothing"
934        );
935        assert_eq!(
936            restage(&[]),
937            Restaged::Nothing,
938            "no paths is nothing to do, and nothing wrong"
939        );
940    }
941
942    /// No check may hand `Command` a bare program name.
943    ///
944    /// `Command::new` does NO PATHEXT resolution, so `Command::new("npm")`
945    /// cannot execute `npm.cmd` and `Command::new("uvx")` cannot execute
946    /// `uvx.exe`: the spawn fails with "program not found" and a
947    /// `Severity::Block` check reports an installed tool as broken. That is the
948    /// incident `program()` exists for, and it kept recurring — `yamllint` and
949    /// three sites in `python_tools` were still doing it, THREE OF THEM after
950    /// `which()` had already succeeded and discarded the answer.
951    ///
952    /// A source scan rather than a runtime assertion because the failure only
953    /// reproduces on Windows, and the whole point is to catch the next one on
954    /// every platform. Comment lines are skipped: `program()`'s own doc quotes
955    /// the offending call. The needle is assembled from two pieces so this
956    /// module — which the scan also reads — does not match itself.
957    #[test]
958    fn no_hook_spawns_a_bare_program_name() {
959        let needle = concat!("Command", "::new(");
960        let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/src/hooks");
961        let mut scanned = 0usize;
962        for entry in std::fs::read_dir(dir).expect("hooks dir").flatten() {
963            let path = entry.path();
964            if path.extension().and_then(|e| e.to_str()) != Some("rs") {
965                continue;
966            }
967            scanned += 1;
968            let src = std::fs::read_to_string(&path).expect("read a hook module");
969            for (n, line) in src.lines().enumerate() {
970                if line.trim_start().starts_with("//") {
971                    continue;
972                }
973                let Some(after) = line.split_once(needle) else {
974                    continue;
975                };
976                assert!(
977                    !after.1.starts_with('"'),
978                    "{}:{} spawns a bare name — route it through `program()` or \
979                     the path `which()` already resolved: {}",
980                    path.display(),
981                    n + 1,
982                    line.trim()
983                );
984            }
985        }
986        assert!(
987            scanned > 10,
988            "the scan found almost nothing: {scanned} files"
989        );
990    }
991
992    #[test]
993    fn first_existing_picks_the_earliest_present_name() {
994        let dir = std::env::temp_dir().join("amont-first-existing-test");
995        let _ = std::fs::create_dir_all(&dir);
996        let root = dir.to_string_lossy().into_owned();
997        let _ = std::fs::write(dir.join("second"), "x");
998        assert_eq!(
999            first_existing(&root, &["first", "second", "third"]).as_deref(),
1000            Some("second")
1001        );
1002        assert_eq!(first_existing(&root, &["nope"]), None);
1003        let _ = std::fs::remove_dir_all(&dir);
1004    }
1005}