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/// Every path the index holds — what the repository CARRIES, as opposed to
106/// what the current change touches.
107///
108/// The one honest source for a [`Scope`](crate::check::Scope) opt-in marker.
109/// [`staged_files`] answers a different question, and answering the opt-in one
110/// with it makes a `+marker` row fire only when the marker itself is in the
111/// change — which is never, in ordinary work.
112///
113/// `ls-files` reads the INDEX, not `HEAD`, so a marker being added by this very
114/// commit already counts. A marker sitting untracked on disk does not, which is
115/// the same rule the manifest itself lives by: commit it, or it is not real.
116///
117/// Fails OPEN, unlike `staged_files`, and the asymmetry is deliberate. There,
118/// an empty list means the checks judge nothing and say so. Here, an empty list
119/// would silently switch every gated check OFF — a check that has quietly never
120/// run is the one failure this design is arranged against — so a git failure
121/// reports the check as opted in and lets the command itself be the judge. A
122/// command that then finds no project fails to spawn, which is `Unavailable`:
123/// a warning, never a block.
124/// `None` when git would not answer — which is NOT the same as an empty
125/// repository, and the caller must not flatten the two. An empty `Vec` opts
126/// every gated check OUT; `None` means "unverified", and the gate opts them IN.
127pub fn tracked_files() -> Option<Vec<String>> {
128    static TRACKED: OnceLock<Option<Vec<String>>> = OnceLock::new();
129    TRACKED
130        .get_or_init(|| match git::stdout_paths(&["ls-files"]) {
131            Some(files) => Some(files),
132            None => {
133                warn(
134                    "git would not list the repository's files — opt-in gated checks \
135                     will run rather than be skipped on an unverified answer",
136                );
137                None
138            }
139        })
140        .clone()
141}
142
143/// Repo root, or "." when git cannot say.
144///
145/// **For CHECK BODIES ONLY.** The fallback is safe there and nowhere else: git
146/// invokes a hook with the working tree as the current directory, so a check
147/// that reaches this line is already standing in the repository, and "." is the
148/// right answer rather than a guess.
149///
150/// Anything a user types — `amont agents-md`, `install`, `trust`, `restore`
151/// — can be typed from any directory on the machine, and there the fallback is
152/// not a fallback but a wrong answer that reads as a right one. Use
153/// [`repo_root_checked`] at every command entry point.
154pub fn repo_root() -> String {
155    // Cached: the answer is a property of the process's repository, and
156    // every check asked it through its own subprocess.
157    static ROOT: OnceLock<String> = OnceLock::new();
158    ROOT.get_or_init(|| {
159        git::stdout(&["rev-parse", "--show-toplevel"]).unwrap_or_else(|| ".".into())
160    })
161    .clone()
162}
163
164/// Repo root, or an error naming the problem.
165///
166/// The same question as [`repo_root`] without the "." — because "." is a
167/// PLAUSIBLE root, and that is what made it dangerous. `amont agents-md`
168/// run outside a repository did not fail; it resolved the root to the current
169/// directory and wrote `./AGENTS.md` into whatever directory the user happened
170/// to be standing in, then printed `wrote ./AGENTS.md` as if that were the
171/// answer. Same shape in `install`'s two prompts, in `trust` (which then
172/// looked for a manifest, and would have recorded trust, under `.`) and in
173/// `restore`.
174///
175/// Every one of those is a command somebody types, and a command somebody
176/// types is a command they can type from `~`. There is no correct behaviour
177/// available to this function when git cannot answer, so it does not invent
178/// one.
179pub fn repo_root_checked() -> Result<String, String> {
180    git::stdout(&["rev-parse", "--show-toplevel"])
181        .filter(|s| !s.is_empty())
182        .ok_or_else(|| "not inside a git repository".to_string())
183}
184
185/// Resolve a tool, preferring the repo's PINNED copy so the hook matches CI.
186///
187///
188/// Order: `<root>/node_modules/.bin/<tool>`, then the MAIN worktree's (a linked
189/// worktree has no node_modules of its own — this is why the shell version
190/// consulted the git common dir), then PATH.
191pub fn resolve_tool(root: &str, tool: &str) -> Option<Vec<String>> {
192    // Same extension problem as `which`: an npm-installed binary is `eslint.cmd`
193    // on Windows, so the bare name misses the repo's PINNED copy and the hook
194    // silently falls through to an ambient one.
195    if let Some(p) = in_bin_dir(&format!("{root}/node_modules/.bin"), tool) {
196        return Some(vec![p]);
197    }
198    if let Some(common) = git::stdout(&["rev-parse", "--path-format=absolute", "--git-common-dir"])
199    {
200        if let Some(main) = Path::new(&common).parent() {
201            if let Some(p) = in_bin_dir(&main.join("node_modules/.bin").to_string_lossy(), tool) {
202                return Some(vec![p]);
203            }
204        }
205    }
206    if let Some(full) = which(tool) {
207        return Some(vec![full]);
208    }
209    // `npx --no-install`: never silently download a random latest version — a
210    // hook that quietly pulls a different linter than CI uses is worse than one
211    // that skips.
212    if which("npx").is_some()
213        && Command::new(program("npx"))
214            .args(["--no-install", tool, "--version"])
215            .current_dir(root)
216            .stdin(Stdio::null())
217            .stdout(Stdio::null())
218            .stderr(Stdio::null())
219            .status()
220            .map(|s| s.success())
221            .unwrap_or(false)
222    {
223        return Some(vec![
224            program("npx"),
225            "--no-install".to_string(),
226            tool.to_string(),
227        ]);
228    }
229    None
230}
231
232/// First match for `tool` on PATH.
233///
234/// Windows executables carry an extension — `git` is `git.exe`, an npm-installed
235/// `eslint` is `eslint.cmd` — so the bare name finds nothing there. PATHEXT is
236/// the OS's own list of what counts as executable; fall back to the usual set
237/// when it is unset. Found by the Windows CI job on its first run, where
238/// `which("git")` returned None on a machine that plainly has git.
239pub fn which(tool: &str) -> Option<String> {
240    which_on(&std::env::var_os("PATH")?, tool)
241}
242
243/// [`which`] against an EXPLICIT path list — the seam its own test needs.
244///
245/// The test that pins the Windows extension order used to `set_var("PATH")`
246/// around the call, which is process-global: for the length of that call
247/// every OTHER test in the binary — 340 of them, running in parallel, many
248/// spawning git — had a PATH containing one fake tool and nothing else. A
249/// git spawned in that window fails with "not found", which is not a
250/// transient `git::retrying` may retry (correctly: it is a hard error), so
251/// the caller reads it as git's ANSWER. In `gate_stamp` that answer is
252/// "nothing is stamped". Passing the path in deletes the shared state
253/// rather than guarding it — a lock only protects the callers who remember
254/// to take it, and every future test here would have to remember.
255pub fn which_on(path: &std::ffi::OsStr, tool: &str) -> Option<String> {
256    let exts: Vec<String> = if cfg!(windows) {
257        std::env::var("PATHEXT")
258            .unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".into())
259            .split(';')
260            .filter(|e| !e.is_empty())
261            .map(|e| e.to_lowercase())
262            .collect()
263    } else {
264        Vec::new()
265    };
266    for dir in std::env::split_paths(path) {
267        // On Windows the EXTENSION forms come first. A node install ships both
268        // `npm` (an extensionless shell script, for MSYS) and `npm.cmd` in the
269        // same directory; preferring the bare name hands CreateProcess a shell
270        // script it cannot execute — "%1 is not a valid Win32 application" —
271        // and the hook reports an installed tool as broken.
272        for e in &exts {
273            let c = dir.join(format!("{tool}{e}"));
274            if c.is_file() {
275                return Some(c.to_string_lossy().into_owned());
276            }
277        }
278        let bare = dir.join(tool);
279        if bare.is_file() {
280            return Some(bare.to_string_lossy().into_owned());
281        }
282    }
283    None
284}
285
286/// `<dir>/<tool>`, trying the Windows executable extensions too.
287fn in_bin_dir(dir: &str, tool: &str) -> Option<String> {
288    let bare = Path::new(dir).join(tool);
289    if bare.is_file() {
290        return Some(bare.to_string_lossy().into_owned());
291    }
292    if cfg!(windows) {
293        for e in [".cmd", ".exe", ".bat", ".ps1"] {
294            let c = Path::new(dir).join(format!("{tool}{e}"));
295            if c.is_file() {
296                return Some(c.to_string_lossy().into_owned());
297            }
298        }
299    }
300    None
301}
302
303/// Resolve a tool name to a full path for spawning.
304///
305/// `Command::new("npm")` cannot execute `npm.cmd`: Rust does no PATHEXT
306/// resolution, so on Windows every bare-name spawn fails with "program not
307/// found" and the hook reports the tool as broken rather than absent. Found by
308/// the Windows job on its first FULL-suite run — the smoke never spawned a
309/// tool, so it could not have surfaced this.
310///
311/// Falls back to the name unchanged, so a caller still gets a sensible error.
312pub fn program(name: &str) -> String {
313    which(name).unwrap_or_else(|| name.to_string())
314}
315
316/// The first of `names` that exists at the repo root — how these hooks decide
317/// a repo has opted into a tool.
318pub fn first_existing(root: &str, names: &[&str]) -> Option<String> {
319    names
320        .iter()
321        .find(|n| Path::new(root).join(n).exists())
322        .map(|n| (*n).to_string())
323}
324
325/// Strip git's own environment before handing a Command to another tool.
326///
327/// git exports GIT_DIR, GIT_INDEX_FILE, GIT_WORK_TREE and friends to every
328/// hook. Those OVERRIDE the working directory, so any tool that shells out to
329/// git operates on the hook's repository no matter where it was launched.
330///
331/// That is not hypothetical: `pre-push-cargo-test` runs a project's test suite,
332/// and this repo's own suite creates throwaway repos and commits to them. With
333/// GIT_DIR inherited, `git commit` in a test wrote into the REAL repository —
334/// an actual stray commit, authored by the test fixture, pushed to a branch.
335///
336/// A test suite should behave exactly as it does when run by hand, which means
337/// seeing no git environment at all.
338pub fn strip_git_env(cmd: &mut Command) {
339    for (k, _) in std::env::vars_os() {
340        let key = k.to_string_lossy();
341        if key.starts_with("GIT_") {
342            cmd.env_remove(&k);
343        }
344    }
345}
346
347/// The wall-clock CEILING for one check's spawned command, in seconds.
348///
349/// `amont.timeout`, default 3600. This used to be 600 and to be the only
350/// clock, which made it answer two different questions with one number: "is
351/// this tool stuck?" and "is this suite slow?". A stuck tool is silent, and
352/// [`idle_timeout`] catches it in minutes; what is left for the ceiling is
353/// the tool that keeps printing and never finishes, which is rare enough to
354/// afford an hour. `0` disables. Read once per process: twenty concurrent
355/// checks must not each spawn a `git config` to learn the same number.
356pub fn check_timeout(settings: &crate::config::Settings) -> u64 {
357    *settings.timeout.get_or_init(|| {
358        crate::config::integer_or(settings, "amont.timeout", 3600, 0..=86_400) as u64
359    })
360}
361
362/// The SILENCE budget: how long a spawned command may go without writing a
363/// byte before it is judged stuck, in seconds.
364///
365/// `amont.idleTimeout`, default 120. A hang is silent; a slow test suite
366/// talks — `cargo test` prints a line per test. Not every one does (vitest
367/// without a terminal prints only its summary), so where CPU can be measured
368/// the budget counts silence AND an idle process tree — see [`Activity`] and
369/// ADR-0008. Killing on silence catches
370/// the captive portal, the deadlocked lock file and the tool waiting on a
371/// prompt nobody will answer FASTER than a ten-minute wall clock did, while
372/// letting a chatty twenty-five-minute suite finish. Only applies where the
373/// output is observed (the captured runners); a command inheriting the
374/// terminal directly answers to the ceiling alone. `0` disables.
375pub fn idle_timeout(settings: &crate::config::Settings) -> u64 {
376    *settings.idle.get_or_init(|| {
377        crate::config::integer_or(settings, "amont.idleTimeout", 120, 0..=86_400) as u64
378    })
379}
380
381/// Whether a silent check that is measurably working on CPU is kept alive
382/// past the silence budget — `amont.idleCpuCredit`, default true (ADR-0008,
383/// `hooks.liveness`). `false` restores the silence-only rule everywhere.
384/// Read once per `Settings`, like the two clocks.
385pub fn idle_cpu_credit(settings: &crate::config::Settings) -> bool {
386    *settings
387        .idle_cpu
388        .get_or_init(|| crate::config::boolean_or(settings, "amont.idleCpuCredit", true))
389}
390
391/// How long a command may sit in a DECLARED wait — a line that says it is
392/// blocked on a lock ([`crate::hooks::wait::marker`]) — before it is killed,
393/// in seconds. `amont.lockWait`, default 600; `0` means until the ceiling.
394/// The silence clock does not run during such a wait: the tool has said
395/// what it is doing, and a lock held by another cargo for a minute on a
396/// loaded machine is not a hang (ADR-0009). Read once per `Settings`.
397pub fn lock_wait(settings: &crate::config::Settings) -> LockWait {
398    match *settings.lock_wait.get_or_init(|| {
399        crate::config::integer_or(settings, "amont.lockWait", 600, 0..=86_400) as u64
400    }) {
401        0 => LockWait::UntilCeiling,
402        s => LockWait::Secs(s),
403    }
404}
405
406/// The budget for a declared wait, as [`lock_wait`] reads it.
407#[derive(Debug, Clone, Copy, PartialEq, Eq)]
408pub enum LockWait {
409    Secs(u64),
410    /// `amont.lockWait 0`: the ceiling alone bounds a declared wait — and
411    /// when the ceiling is off too, the extended silence budget does, so
412    /// nothing is ever unbounded.
413    UntilCeiling,
414}
415
416/// `secs` as people read it: `12s`, `8m12s`, `1h02m`.
417pub fn human_secs(secs: u64) -> String {
418    match secs {
419        s if s < 60 => format!("{s}s"),
420        s if s < 3600 => format!("{}m{:02}s", s / 60, s % 60),
421        s => format!("{}h{:02}m", s / 3600, (s % 3600) / 60),
422    }
423}
424
425/// How much of a tool's last stderr line a message may quote.
426pub const LAST_LINE_CHARS: usize = 200;
427
428/// A work window of at least this many thousandths of one core counts as the
429/// tree doing something (ADR-0008): 0.1 core. A hang — a prompt, a lock, a
430/// dead network — sits near zero; a test suite runs at whole cores.
431pub const BUSY_MILLI_CORES: u32 = 100;
432
433/// What the CPU side of [`Activity`] knows. Stored as a `u8`.
434#[derive(Debug, Clone, Copy, PartialEq, Eq)]
435pub enum CpuState {
436    /// Not sampled: `amont.idleCpuCredit false`, no silence budget, or a
437    /// platform that cannot measure. The silence-only rule applies.
438    Off = 0,
439    /// Sampling, but nothing measured yet (the check has not been quiet
440    /// long enough, or it has only a baseline).
441    Waiting = 1,
442    /// Consecutive complete snapshots are being compared.
443    Measuring = 2,
444    /// The last snapshot was incomplete; the next complete one re-baselines.
445    Unavailable = 3,
446}
447
448/// What a spawned command has been doing, shared between the reader threads
449/// that see its bytes, the CPU sampler, the wait loop that judges it, and the
450/// progress displays. Every field is an atomic offset from `base`, so the
451/// 80 ms repaint and the 25 ms wait loop read it without a lock.
452///
453/// Two clocks, kept apart on purpose: `last_out` is when it last WROTE (what
454/// the messages report as "last output"), `last_busy` is the start of the
455/// last window in which its process tree did measurable CPU work. The kill
456/// decision uses the later of the two ([`Activity::still_for`]).
457pub struct Activity {
458    base: std::time::Instant,
459    last_out: std::sync::atomic::AtomicU64,
460    last_busy: std::sync::atomic::AtomicU64,
461    /// Offset + 1 of the start of the current unbroken run of complete
462    /// measurements; 0 when there is none.
463    measured_since: std::sync::atomic::AtomicU64,
464    /// Offset + 1 of the end of the last complete window; 0 when none.
465    last_measured: std::sync::atomic::AtomicU64,
466    rate_milli: std::sync::atomic::AtomicU32,
467    interval_ms: std::sync::atomic::AtomicU32,
468    cpu: std::sync::atomic::AtomicU8,
469    /// Offset + 1 of the start of the current declared wait (ADR-0009); 0
470    /// when the last stderr line was not a wait marker.
471    wait_since: std::sync::atomic::AtomicU64,
472    /// [`crate::hooks::wait::WaitKind::code`] of that wait; 0 when none.
473    wait_kind: std::sync::atomic::AtomicU8,
474    /// Nanoseconds spent in declared waits that have ENDED — what the note
475    /// after a slow-but-passing check reports.
476    waited_ns: std::sync::atomic::AtomicU64,
477    /// The code of the most recent wait, kept after it ends, so the note can
478    /// name what was waited for.
479    last_wait_kind: std::sync::atomic::AtomicU8,
480    /// The silence budget the wait loop is applying right now, in seconds,
481    /// after the host's load stretched it; 0 while it is the configured one
482    /// (ADR-0009). What the displays count toward.
483    budget_secs: std::sync::atomic::AtomicU32,
484    /// The load that stretched it: average × 1000, cores, factor × 1000.
485    load_avg_milli: std::sync::atomic::AtomicU32,
486    load_cores: std::sync::atomic::AtomicU32,
487    load_factor_milli: std::sync::atomic::AtomicU32,
488    /// The last complete, non-empty stderr line, colour stripped and
489    /// clipped: what a kill message may quote, and what decides a retry.
490    last_line: std::sync::Mutex<String>,
491}
492
493impl Activity {
494    pub fn new() -> std::sync::Arc<Activity> {
495        std::sync::Arc::new(Activity {
496            base: std::time::Instant::now(),
497            last_out: Default::default(),
498            last_busy: Default::default(),
499            measured_since: Default::default(),
500            last_measured: Default::default(),
501            rate_milli: Default::default(),
502            interval_ms: Default::default(),
503            cpu: std::sync::atomic::AtomicU8::new(CpuState::Off as u8),
504            wait_since: Default::default(),
505            wait_kind: Default::default(),
506            waited_ns: Default::default(),
507            last_wait_kind: Default::default(),
508            budget_secs: Default::default(),
509            load_avg_milli: Default::default(),
510            load_cores: Default::default(),
511            load_factor_milli: Default::default(),
512            last_line: std::sync::Mutex::new(String::new()),
513        })
514    }
515
516    /// What the most recent declared wait, ended or not, was for.
517    pub fn last_wait_kind(&self) -> Option<crate::hooks::wait::WaitKind> {
518        crate::hooks::wait::WaitKind::from_code(
519            self.last_wait_kind
520                .load(std::sync::atomic::Ordering::Relaxed),
521        )
522    }
523
524    /// One complete stderr line arrived. A wait marker starts a declared
525    /// wait, or continues the one in progress (cargo prints `package cache`
526    /// and then `build directory`; the budget covers the run of them, not
527    /// each); any other line ends it. The line is kept for the messages.
528    pub fn stderr_line(&self, raw: &str) {
529        use std::sync::atomic::Ordering::Relaxed;
530        let line = crate::hooks::wait::strip_csi(raw);
531        if !line.trim().is_empty() {
532            let mut keep = self.last_line.lock().unwrap_or_else(|p| p.into_inner());
533            keep.clear();
534            keep.extend(line.trim().chars().take(LAST_LINE_CHARS));
535        }
536        match crate::hooks::wait::marker(&line) {
537            Some(kind) => {
538                self.wait_kind.store(kind.code(), Relaxed);
539                self.last_wait_kind.store(kind.code(), Relaxed);
540                // `compare_exchange` from 0: a second marker keeps the
541                // original start.
542                let _ = self
543                    .wait_since
544                    .compare_exchange(0, self.now() + 1, Relaxed, Relaxed);
545            }
546            None => self.end_wait(),
547        }
548    }
549
550    /// The tool wrote something that is not a wait marker: whatever wait
551    /// was in progress is over, and its length is banked for the note.
552    pub fn end_wait(&self) {
553        use std::sync::atomic::Ordering::Relaxed;
554        let since = self.wait_since.swap(0, Relaxed);
555        if since != 0 {
556            let spent = self.now().saturating_sub(since - 1);
557            self.waited_ns.fetch_add(spent, Relaxed);
558        }
559        self.wait_kind.store(0, Relaxed);
560    }
561
562    /// The declared wait in progress, and how long it has lasted.
563    pub fn waiting(&self) -> Option<(crate::hooks::wait::WaitKind, std::time::Duration)> {
564        use std::sync::atomic::Ordering::Relaxed;
565        let since = self.wait_since.load(Relaxed);
566        if since == 0 {
567            return None;
568        }
569        let kind = crate::hooks::wait::WaitKind::from_code(self.wait_kind.load(Relaxed))?;
570        Some((kind, self.since(since - 1)))
571    }
572
573    /// Time spent in declared waits that have ended, plus the one in
574    /// progress: what a passing check waited for in total.
575    pub fn waited_total(&self) -> std::time::Duration {
576        use std::sync::atomic::Ordering::Relaxed;
577        let banked = std::time::Duration::from_nanos(self.waited_ns.load(Relaxed));
578        banked + self.waiting().map_or(std::time::Duration::ZERO, |(_, d)| d)
579    }
580
581    /// The last non-empty stderr line, if any.
582    pub fn last_line(&self) -> Option<String> {
583        let keep = self.last_line.lock().unwrap_or_else(|p| p.into_inner());
584        (!keep.is_empty()).then(|| keep.clone())
585    }
586
587    /// The wait loop read the host's load and is applying `budget_secs`.
588    pub fn set_load(&self, budget_secs: u64, load: crate::load::Load, factor_milli: u32) {
589        use std::sync::atomic::Ordering::Relaxed;
590        self.budget_secs
591            .store(u32::try_from(budget_secs).unwrap_or(u32::MAX), Relaxed);
592        self.load_avg_milli.store(load.avg1_milli, Relaxed);
593        self.load_cores.store(load.cores, Relaxed);
594        self.load_factor_milli.store(factor_milli, Relaxed);
595    }
596
597    /// The load-stretched budget in force, with the load behind it, when
598    /// the host's load stretched it at all.
599    pub fn load_scale(&self) -> Option<(u64, crate::load::Load, u32)> {
600        use std::sync::atomic::Ordering::Relaxed;
601        let factor = self.load_factor_milli.load(Relaxed);
602        if factor <= 1000 {
603            return None;
604        }
605        Some((
606            u64::from(self.budget_secs.load(Relaxed)),
607            crate::load::Load {
608                avg1_milli: self.load_avg_milli.load(Relaxed),
609                cores: self.load_cores.load(Relaxed),
610            },
611            factor,
612        ))
613    }
614    fn offset(&self, at: std::time::Instant) -> u64 {
615        u64::try_from(at.saturating_duration_since(self.base).as_nanos()).unwrap_or(u64::MAX)
616    }
617    fn now(&self) -> u64 {
618        self.offset(std::time::Instant::now())
619    }
620    fn since(&self, offset: u64) -> std::time::Duration {
621        std::time::Duration::from_nanos(self.now().saturating_sub(offset))
622    }
623    /// It wrote something.
624    pub fn touch(&self) {
625        self.last_out
626            .fetch_max(self.now(), std::sync::atomic::Ordering::Relaxed);
627    }
628    /// How long since it last wrote a byte — the true output silence, what
629    /// the displays report as "last output".
630    pub fn quiet_for(&self) -> std::time::Duration {
631        self.since(self.last_out.load(std::sync::atomic::Ordering::Relaxed))
632    }
633    /// The silence the clock counts: zero while the command is in a
634    /// declared wait (it has said what it is doing), the output silence
635    /// otherwise. What [`Activity::still_for`] and the sampler's start gate
636    /// read; the displays keep [`Activity::quiet_for`].
637    pub fn silence_for(&self) -> std::time::Duration {
638        if self.waiting().is_some() {
639            std::time::Duration::ZERO
640        } else {
641            self.quiet_for()
642        }
643    }
644    /// How long it has been BOTH silent and idle on CPU — the number the
645    /// silence budget is judged against. Equals [`Activity::silence_for`]
646    /// whenever CPU is not sampled.
647    pub fn still_for(&self) -> std::time::Duration {
648        let busy = self.last_busy.load(std::sync::atomic::Ordering::Relaxed);
649        self.silence_for().min(self.since(busy))
650    }
651    pub fn cpu_state(&self) -> CpuState {
652        match self.cpu.load(std::sync::atomic::Ordering::Relaxed) {
653            1 => CpuState::Waiting,
654            2 => CpuState::Measuring,
655            3 => CpuState::Unavailable,
656            _ => CpuState::Off,
657        }
658    }
659    fn set_cpu_state(&self, s: CpuState) {
660        self.cpu
661            .store(s as u8, std::sync::atomic::Ordering::Relaxed);
662    }
663    /// The last measured rate in thousandths of a core, while it is fresh:
664    /// `None` once two sampling intervals have passed without a complete
665    /// window, so a display never keeps showing "busy" on stale data.
666    pub fn fresh_rate(&self) -> Option<u32> {
667        if self.cpu_state() != CpuState::Measuring {
668            return None;
669        }
670        let end = self
671            .last_measured
672            .load(std::sync::atomic::Ordering::Relaxed);
673        if end == 0 {
674            return None;
675        }
676        let every = u64::from(self.interval_ms.load(std::sync::atomic::Ordering::Relaxed));
677        let stale = std::time::Duration::from_millis(2 * every.max(1));
678        (self.since(end - 1) <= stale)
679            .then(|| self.rate_milli.load(std::sync::atomic::Ordering::Relaxed))
680    }
681    /// What the CPU side can honestly say at a kill. "Measured idle" names
682    /// the unbroken span of complete measurements it rests on — sampling
683    /// starts only after a stretch of silence, so that span is always shorter
684    /// than the silence itself, and nothing is claimed about the rest.
685    pub fn verdict(&self) -> CpuVerdict {
686        match self.cpu_state() {
687            CpuState::Off => return CpuVerdict::NotSampled,
688            CpuState::Waiting | CpuState::Unavailable => return CpuVerdict::Unmeasured,
689            CpuState::Measuring => {}
690        }
691        if let Some(rate) = self.fresh_rate().filter(|r| *r >= BUSY_MILLI_CORES) {
692            return CpuVerdict::BusyAtKill(rate);
693        }
694        let since = self
695            .measured_since
696            .load(std::sync::atomic::Ordering::Relaxed);
697        match (since, self.fresh_rate()) {
698            (s, Some(_)) if s != 0 => CpuVerdict::MeasuredIdle(self.since(s - 1).as_secs()),
699            _ => CpuVerdict::Unmeasured,
700        }
701    }
702    /// Feed one sampler observation in. `interval` is the sampling period,
703    /// kept so displays can tell a fresh rate from a stale one.
704    pub fn record(
705        &self,
706        obs: crate::proctree::Observation,
707        at: std::time::Instant,
708        interval: std::time::Duration,
709    ) {
710        use std::sync::atomic::Ordering::Relaxed;
711        self.interval_ms.store(
712            u32::try_from(interval.as_millis()).unwrap_or(u32::MAX),
713            Relaxed,
714        );
715        match obs {
716            crate::proctree::Observation::Baseline => {
717                self.measured_since.store(self.offset(at) + 1, Relaxed);
718                self.set_cpu_state(CpuState::Waiting);
719            }
720            crate::proctree::Observation::Window(w) => {
721                let milli = w.milli_cores();
722                self.rate_milli.store(milli, Relaxed);
723                self.last_measured.store(self.offset(w.end) + 1, Relaxed);
724                if milli >= BUSY_MILLI_CORES {
725                    // The window's START: a burst buys one window, not a
726                    // whole new budget.
727                    self.last_busy.fetch_max(self.offset(w.start), Relaxed);
728                }
729                if w.gapped {
730                    // Busy is still busy, but a gap is not a measurement:
731                    // the measured-idle span starts over at the gap's end,
732                    // so the claim never covers what was not seen.
733                    self.measured_since.store(self.offset(w.end) + 1, Relaxed);
734                }
735                self.set_cpu_state(CpuState::Measuring);
736            }
737            // A skipped sample changes nothing: the rate ages and goes
738            // stale on its own if the next complete one is late.
739            crate::proctree::Observation::Skipped => {}
740            crate::proctree::Observation::Unmeasured => {
741                self.measured_since.store(0, Relaxed);
742                self.set_cpu_state(CpuState::Unavailable);
743            }
744        }
745    }
746    /// Sampling is on for this command.
747    pub fn enable_cpu(&self) {
748        self.set_cpu_state(CpuState::Waiting);
749    }
750    /// Which silence budget applies right now, from what the sampler can
751    /// claim: see [`CpuGate`].
752    pub fn gate(&self) -> CpuGate {
753        match self.verdict() {
754            CpuVerdict::NotSampled => CpuGate::NotSampled,
755            CpuVerdict::MeasuredIdle(_) | CpuVerdict::BusyAtKill(_) => CpuGate::Measured,
756            CpuVerdict::Unmeasured => CpuGate::Unmeasured,
757        }
758    }
759}
760
761/// What the kill decision may rest on from the CPU side (ADR-0009). Not
762/// sampled at all: the silence-only rule, as ever. Measured: the silence
763/// budget, because "silent and idle" was actually observed. Unmeasured —
764/// sampling is on but the last sample was late, partial, or has not come
765/// yet: the EXTENDED budget, `amont.idleTimeout × amont.idleLoadScale`,
766/// which is bounded even when the ceiling is off, and never the bare
767/// silence budget, because that would kill on a measurement nobody made.
768#[derive(Debug, Clone, Copy, PartialEq, Eq)]
769pub enum CpuGate {
770    NotSampled,
771    Measured,
772    Unmeasured,
773}
774
775/// What the CPU sampler could say when a command was killed.
776#[derive(Debug, Clone, Copy, PartialEq, Eq)]
777pub enum CpuVerdict {
778    /// Not sampled here (knob off, no budget, platform): silence alone.
779    NotSampled,
780    /// An unbroken run of complete measurements, this many seconds long and
781    /// ending at the kill, all under [`BUSY_MILLI_CORES`].
782    MeasuredIdle(u64),
783    /// Sampling was on but could not measure the whole budget.
784    Unmeasured,
785    /// Its tree was measurably busy at the kill, at this many thousandths
786    /// of a core.
787    BusyAtKill(u32),
788}
789
790/// `milli` thousandths of a core as people read it: `~3.9 cores`.
791pub fn cores(milli: u32) -> String {
792    format!("~{}.{} cores", milli / 1000, (milli % 1000) / 100)
793}
794
795/// The deadline for a network PROBE — an `ls-remote` asked before the real
796/// work, not the work itself. Capped at 30s below [`check_timeout`]: a
797/// probe answers in a second or two when the network is there at all, and
798/// a healthy `amont.timeout` of ten minutes is sized for a test suite, not
799/// for deciding whether the remote is reachable. Shrinking `amont.timeout`
800/// below the cap shrinks this too, and `0` keeps meaning no deadline —
801/// somebody who disabled the clock disabled all of it.
802pub fn network_probe_budget(settings: &crate::config::Settings) -> u64 {
803    match check_timeout(settings) {
804        0 => 0,
805        t => t.min(30),
806    }
807}
808
809/// Which clock killed a command.
810#[derive(Debug, Clone, Copy, PartialEq, Eq)]
811pub enum Why {
812    /// The wall-clock ceiling, `amont.timeout`, in seconds.
813    Ceiling(u64),
814    /// The silence budget, `amont.idleTimeout`, in seconds.
815    Silence(u64),
816    /// A declared wait outlived `amont.lockWait` (or, with that at 0 and
817    /// the ceiling off, the extended silence budget), in seconds.
818    Waited(crate::hooks::wait::WaitKind, u64),
819}
820
821/// A command killed by a clock — what happened, said with enough to tell
822/// "slow" from "stuck", which is the whole reason there are two clocks.
823#[derive(Debug, Clone)]
824pub struct Killed {
825    pub why: Why,
826    /// How long it had been running.
827    pub ran_secs: u64,
828    /// How long since its last output; `None` when the output was not ours
829    /// to observe (inherited stdio).
830    pub quiet_secs: Option<u64>,
831    /// What its CPU was doing, as far as it was measured.
832    pub cpu: CpuVerdict,
833    /// The silence budget it ran under, in seconds (0 = off). With
834    /// `quiet_secs` it says whether CPU work is what kept a silent command
835    /// alive past that budget.
836    pub idle_secs: u64,
837    /// The declared wait it was in at the kill, and for how long.
838    pub waiting: Option<(crate::hooks::wait::WaitKind, u64)>,
839    /// Its last non-empty stderr line, colour stripped and clipped to
840    /// [`LAST_LINE_CHARS`]: what decides a retry, and what the message may
841    /// quote.
842    pub last_line: Option<String>,
843    /// The host's load at the kill, with the factor it stretched the
844    /// silence budget by, when it stretched it at all.
845    pub load: Option<(crate::load::Load, u32)>,
846}
847
848/// The budgets one wait is judged under — the inputs to [`judge`] that do
849/// not move while the command runs.
850#[derive(Debug, Clone, Copy, PartialEq, Eq)]
851pub struct Clocks {
852    /// `amont.timeout`; `None` when off.
853    pub ceiling: Option<u64>,
854    /// `amont.idleTimeout`; `None` when off or when nobody watches the
855    /// output.
856    pub silence: Option<u64>,
857    /// `amont.lockWait`.
858    pub lock_wait: LockWait,
859    /// The bound a declared wait falls back to when `lock_wait` says "until
860    /// the ceiling" and the ceiling is off: the extended silence budget,
861    /// `None` when silence is off too.
862    pub extended: Option<u64>,
863    /// `amont.idleLoadScale`: how far the host's load may stretch
864    /// `silence`; 1 means not at all.
865    pub scale: u64,
866}
867
868impl Clocks {
869    /// The ceiling alone: inherited stdio, where nobody sees the bytes.
870    pub fn ceiling_only(wall_secs: u64) -> Clocks {
871        Clocks {
872            ceiling: (wall_secs > 0).then_some(wall_secs),
873            silence: None,
874            lock_wait: LockWait::UntilCeiling,
875            extended: None,
876            scale: 1,
877        }
878    }
879
880    /// Every clock, from the settings. `idle_secs` is the silence budget
881    /// the caller resolved (0 = off).
882    pub fn from_settings(settings: &crate::config::Settings, idle_secs: u64) -> Clocks {
883        let wall = check_timeout(settings);
884        let scale = idle_load_scale(settings);
885        Clocks {
886            ceiling: (wall > 0).then_some(wall),
887            silence: (idle_secs > 0).then_some(idle_secs),
888            lock_wait: lock_wait(settings),
889            extended: (idle_secs > 0).then(|| idle_secs.saturating_mul(scale)),
890            scale,
891        }
892    }
893}
894
895/// How often the wait loop re-reads the host's load.
896const LOAD_EVERY: std::time::Duration = std::time::Duration::from_secs(5);
897
898/// `amont.idleLoadScale`: how far the silence budget may stretch, as a
899/// factor — under load (ADR-0009, the load-scaled budget) and whenever the
900/// CPU could not be measured (the extended budget, `idleTimeout × this`).
901/// Default 4, `1` disables the stretch, range 1..=16. A HOST key: read from
902/// global or system config only, never from a repository, because it
903/// describes the machine and two repositories must not disagree about it.
904pub fn idle_load_scale(settings: &crate::config::Settings) -> u64 {
905    *settings.idle_load_scale.get_or_init(|| {
906        crate::config::host_integer_or(settings, "amont.idleLoadScale", 4, 1..=16) as u64
907    })
908}
909
910/// What became of a command run under the deadline.
911pub enum Ran {
912    Status(std::process::ExitStatus),
913    /// Killed by a clock; see [`Killed`].
914    TimedOut(Killed),
915}
916
917/// `cmd.status()`, bounded by [`check_timeout`].
918///
919/// Without a bound, one hung tool — a linter deadlocked on a lock file, a
920/// plugin doing network I/O — blocked the commit FOREVER, and it hung inside
921/// the index-fidelity hold: the user's unstaged changes parked in `$GIT_DIR`,
922/// their tree showing staged content only, for as long as they were willing
923/// to wait. The learned response to that is `--no-verify`, permanently —
924/// which disarms every check to escape one.
925///
926/// The kill reaches the direct child only. A grandchild that detached
927/// survives, orphaned — but the COMMIT is no longer hostage to it, which is
928/// the property that matters.
929pub fn status_within(
930    settings: &crate::config::Settings,
931    cmd: &mut Command,
932) -> std::io::Result<Ran> {
933    // The same host-slot wait as the observed runners: a check run without
934    // a live stage (one check by name, `amont.progress false`) still queues.
935    if crate::host_slots::before_spawn(settings) {
936        cmd.env(crate::host_slots::HELD_ENV, "held");
937    }
938    status_within_secs(cmd, check_timeout(settings))
939}
940
941/// [`status_within`] with an explicit ceiling — the testable seam. The
942/// output is inherited, so nobody sees the bytes and the silence budget
943/// cannot apply; the ceiling is the only clock.
944pub fn status_within_secs(cmd: &mut Command, budget_secs: u64) -> std::io::Result<Ran> {
945    if budget_secs == 0 {
946        return cmd.status().map(Ran::Status);
947    }
948    let mut child = cmd.spawn()?;
949    wait_within(&mut child, Clocks::ceiling_only(budget_secs), None)
950}
951
952/// Spawn `cmd` with both streams piped, hand every chunk to `on_output` as
953/// it arrives, and wait under BOTH clocks — the reader threads are what
954/// make the silence budget observable. The shared runner behind the
955/// streamed, captured and discarded variants.
956fn run_observed(
957    settings: &crate::config::Settings,
958    cmd: &mut Command,
959    on_output: impl Fn(&[u8]) + Send + Sync + 'static,
960    ceiling_left: Option<u64>,
961) -> std::io::Result<Ran> {
962    // A heavy check's first tool waits here for a host slot (ADR-0009);
963    // its clocks start at the spawn below, after the wait.
964    if crate::host_slots::before_spawn(settings) {
965        cmd.env(crate::host_slots::HELD_ENV, "held");
966    }
967    cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
968    let activity = Activity::new();
969    let on_output = std::sync::Arc::new(on_output);
970    let mut child = cmd.spawn()?;
971    // The silence is the CHILD's, so its clock starts when the child does:
972    // a spawn that itself took a second on a loaded machine is not a
973    // second the tool spent saying nothing.
974    activity.touch();
975    let mut readers = Vec::new();
976    // stderr is where cargo and uv print their status, so only its lines
977    // are read for wait markers: a test that prints the words on stdout
978    // must not pause its own clock. A stdout chunk still ENDS a wait — the
979    // tool is visibly alive.
980    let pipes = [
981        (
982            child
983                .stdout
984                .take()
985                .map(|p| Box::new(p) as Box<dyn std::io::Read + Send>),
986            false,
987        ),
988        (
989            child
990                .stderr
991                .take()
992                .map(|p| Box::new(p) as Box<dyn std::io::Read + Send>),
993            true,
994        ),
995    ];
996    for (pipe, is_stderr) in pipes {
997        let Some(pipe) = pipe else { continue };
998        let activity = std::sync::Arc::clone(&activity);
999        let on_output = std::sync::Arc::clone(&on_output);
1000        readers.push(std::thread::spawn(move || {
1001            let mut pipe = pipe;
1002            let mut chunk = [0u8; 4096];
1003            let mut framer = crate::hooks::wait::LineFramer::default();
1004            loop {
1005                match std::io::Read::read(&mut pipe, &mut chunk) {
1006                    Ok(0) | Err(_) => break,
1007                    Ok(n) => {
1008                        activity.touch();
1009                        if is_stderr {
1010                            for line in framer.feed(&chunk[..n]) {
1011                                activity.stderr_line(&line);
1012                            }
1013                        } else {
1014                            activity.end_wait();
1015                        }
1016                        on_output(&chunk[..n]);
1017                    }
1018                }
1019            }
1020        }));
1021    }
1022    // The displays read the same clocks the kill decision does.
1023    let _attached = crate::live::current_sink()
1024        .map(|(stage, idx)| stage.attach(idx, std::sync::Arc::clone(&activity)));
1025    let idle = idle_timeout(settings);
1026    let sampler = (idle > 0 && idle_cpu_credit(settings) && crate::proctree::SUPPORTED)
1027        .then(|| CpuSampler::start(child.id(), std::sync::Arc::clone(&activity), idle));
1028    let mut clocks = Clocks::from_settings(settings, idle);
1029    if let Some(left) = ceiling_left {
1030        // A retry runs under what the first attempt left of the ceiling:
1031        // both attempts together answer to one `amont.timeout`.
1032        clocks.ceiling = Some(left.max(1));
1033    }
1034    let ran = wait_within(&mut child, clocks, Some(&activity));
1035    if let Some(s) = sampler {
1036        s.stop();
1037    }
1038    for r in readers {
1039        let _ = r.join();
1040    }
1041    // A check that passed after sitting on a lock says so once: the
1042    // reader of a slow commit learns where the minutes went, and a run that
1043    // would once have been killed leaves a trace of why it no longer is.
1044    if let Ok(Ran::Status(_)) = &ran {
1045        let waited = activity.waited_total();
1046        if waited >= std::time::Duration::from_secs(1) {
1047            let what = activity
1048                .last_wait_kind()
1049                .map_or("a lock", crate::hooks::wait::WaitKind::describe);
1050            say(&format!(
1051                "  (waited {} for {what})",
1052                human_secs(waited.as_secs())
1053            ));
1054        }
1055    }
1056    ran
1057}
1058
1059/// The CPU half of the silence budget (ADR-0008): a thread that, while the
1060/// command is silent, snapshots its process tree and records whether it is
1061/// doing measurable work. It runs BESIDE the wait loop, never inside it —
1062/// the loop only reads what this publishes — so nothing a snapshot does can
1063/// delay the ceiling.
1064struct CpuSampler {
1065    stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
1066    handle: std::thread::JoinHandle<()>,
1067}
1068
1069impl CpuSampler {
1070    fn start(pid: u32, activity: std::sync::Arc<Activity>, idle_secs: u64) -> CpuSampler {
1071        activity.enable_cpu();
1072        let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1073        let flag = std::sync::Arc::clone(&stop);
1074        let handle = std::thread::Builder::new()
1075            .name("amont-cpu".into())
1076            .spawn(move || sample_loop(pid, &activity, idle_secs, &flag))
1077            .expect("spawn the CPU sampler thread");
1078        CpuSampler { stop, handle }
1079    }
1080
1081    /// Ask it to stop and wait a bounded second for it. A snapshot is itself
1082    /// bounded, so it always stops sooner; if it somehow did not, it is left
1083    /// to finish on its own rather than holding the check.
1084    fn stop(self) {
1085        self.stop.store(true, std::sync::atomic::Ordering::Relaxed);
1086        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1);
1087        while !self.handle.is_finished() && std::time::Instant::now() < deadline {
1088            std::thread::sleep(std::time::Duration::from_millis(10));
1089        }
1090        if self.handle.is_finished() {
1091            let _ = self.handle.join();
1092        }
1093    }
1094}
1095
1096/// When sampling starts and how often it repeats, from the silence budget:
1097/// quiet for `max(250 ms, min(30 s, budget/3))`, then every
1098/// `clamp(budget/4, 250 ms, 10 s)` — so even a one-second budget is sampled
1099/// several times before it runs out.
1100pub fn sampling_schedule(idle_secs: u64) -> (std::time::Duration, std::time::Duration) {
1101    let ms = std::time::Duration::from_millis;
1102    let budget = std::time::Duration::from_secs(idle_secs);
1103    let first = (budget / 3)
1104        .min(std::time::Duration::from_secs(30))
1105        .max(ms(250));
1106    let every = (budget / 4).clamp(ms(250), std::time::Duration::from_secs(10));
1107    (first, every)
1108}
1109
1110fn sample_loop(
1111    pid: u32,
1112    activity: &Activity,
1113    idle_secs: u64,
1114    stop: &std::sync::atomic::AtomicBool,
1115) {
1116    use std::sync::atomic::Ordering::Relaxed;
1117    let (first, every) = sampling_schedule(idle_secs);
1118    let mut limits = crate::proctree::Limits::default();
1119    // A diagnostic, and the seam the timing tests use to force a partial
1120    // snapshot: cap the walk at this many processes.
1121    if let Some(n) = std::env::var("AMONT_CPU_MAX_PROCS")
1122        .ok()
1123        .and_then(|v| v.trim().parse::<usize>().ok())
1124    {
1125        limits.max_procs = n;
1126    }
1127    let trace = std::env::var_os("AMONT_CPU_TRACE");
1128    let mut tracker = crate::proctree::Tracker::default();
1129    let nap = |d: std::time::Duration| {
1130        let until = std::time::Instant::now() + d;
1131        while !stop.load(Relaxed) && std::time::Instant::now() < until {
1132            std::thread::sleep(std::time::Duration::from_millis(50));
1133        }
1134    };
1135    while !stop.load(Relaxed) {
1136        // The silence the clock counts, not the raw output silence: during
1137        // a declared wait there is nothing to prove, and no CPU is sampled.
1138        if activity.silence_for() < first {
1139            // Talking: nothing to prove, and the next quiet stretch starts
1140            // from a fresh baseline rather than a stale one.
1141            tracker = crate::proctree::Tracker::default();
1142            nap(std::time::Duration::from_millis(100));
1143            continue;
1144        }
1145        let snap = crate::proctree::snapshot(pid, &tracker.seen(), &limits);
1146        let at = std::time::Instant::now();
1147        let traced = trace.as_ref().map(|_| match &snap {
1148            crate::proctree::Snapshot::Complete(procs) => procs.clone(),
1149            _ => Vec::new(),
1150        });
1151        let obs = tracker.observe(at, snap);
1152        if let (Some(path), Some(procs)) = (&trace, traced) {
1153            trace_sample(path, pid, &procs, &obs);
1154        }
1155        activity.record(obs, at, every);
1156        nap(every);
1157    }
1158}
1159
1160/// `AMONT_CPU_TRACE=<file>`: append what each sample saw — one
1161/// `pid start ppid cpu_ns` line per process of a complete snapshot, then a
1162/// `# root <pid> <observation>` line. A diagnostic, and the seam the timing
1163/// tests use to know a worker was seen before they orphan it (they match
1164/// the leading pid).
1165fn trace_sample(
1166    path: &std::ffi::OsStr,
1167    root: u32,
1168    procs: &[crate::proctree::Proc],
1169    obs: &crate::proctree::Observation,
1170) {
1171    use std::io::Write;
1172    let Ok(mut f) = std::fs::OpenOptions::new()
1173        .create(true)
1174        .append(true)
1175        .open(path)
1176    else {
1177        return;
1178    };
1179    let mut text = String::new();
1180    for p in procs {
1181        text.push_str(&format!(
1182            "{} {} {} {}\n",
1183            p.id.pid, p.id.start, p.ppid, p.cpu_ns
1184        ));
1185    }
1186    let obs = match obs {
1187        crate::proctree::Observation::Window(w) => {
1188            format!("window {} milli-cores", w.milli_cores())
1189        }
1190        other => format!("{other:?}").to_lowercase(),
1191    };
1192    text.push_str(&format!("# root {root} {obs}\n"));
1193    let _ = f.write_all(text.as_bytes());
1194}
1195
1196/// [`status_within`], with the child's stdout and stderr CAPTURED into the
1197/// calling check's slot instead of inherited — the other half of one-check-
1198/// one-block: a linter's twelve lines used to land on the shared terminal
1199/// between two other checks' lines. Falls back to plain [`status_within`]
1200/// when no slot is installed on this thread (`amont.progress false`, or a
1201/// spawn outside a stage), which is byte-for-byte the old behaviour.
1202///
1203/// stdout and stderr merge in ARRIVAL order inside the block, which is what
1204/// the terminal showed before. The readers are threads, not processes, and
1205/// they are joined before the status is returned so a block can never grow
1206/// after its check finished.
1207pub fn status_streamed(
1208    settings: &crate::config::Settings,
1209    cmd: &mut Command,
1210    retry: Retry,
1211) -> std::io::Result<Ran> {
1212    let Some((stage, idx)) = crate::live::current_sink() else {
1213        return status_within(settings, cmd);
1214    };
1215    if crate::live::watching() {
1216        // The block lands on a real terminal but the tool sees a pipe and
1217        // would strip its colors; the big three opt-in knobs put them back.
1218        cmd.env("FORCE_COLOR", "1")
1219            .env("CLICOLOR_FORCE", "1")
1220            .env("CARGO_TERM_COLOR", "always");
1221    }
1222    let first = run_observed(
1223        settings,
1224        cmd,
1225        {
1226            let stage = std::sync::Arc::clone(&stage);
1227            move |bytes| stage.append_raw(idx, bytes)
1228        },
1229        None,
1230    );
1231    let Ok(Ran::TimedOut(k)) = &first else {
1232        return first;
1233    };
1234    let Some(left) = retry_budget(retry, k, check_timeout(settings)) else {
1235        return first;
1236    };
1237    // The check's name (`clippy`), which is what the reader knows; the
1238    // program (`cargo`) only when no check is running on this thread.
1239    let what = crate::host_slots::current_check()
1240        .unwrap_or_else(|| cmd.get_program().to_string_lossy().into_owned());
1241    say(&format!(
1242        "{} was killed while it looked like it was waiting ({}); retrying once{}.",
1243        hl(&what),
1244        k.last_line.as_deref().unwrap_or(""),
1245        match left {
1246            Some(s) => format!(" with {} left", human_secs(s)),
1247            None => String::new(),
1248        }
1249    ));
1250    run_observed(
1251        settings,
1252        cmd,
1253        move |bytes| stage.append_raw(idx, bytes),
1254        left,
1255    )
1256}
1257
1258/// Whether a check's tool may be run once more after a kill (ADR-0009).
1259#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1260pub enum Retry {
1261    /// A built-in check: its tools are safe to run twice.
1262    Once,
1263    /// A declared external: a user's command may not be safe to run twice.
1264    Never,
1265}
1266
1267/// Whether a kill earns the one retry, and with how much ceiling left
1268/// (`Some(None)`: the ceiling is off). Only after a SILENCE kill whose last
1269/// line reads like a wait — never after a declared wait outlived its own
1270/// budget, nor at the ceiling — and only while the ceiling has at least one
1271/// silence budget left. Pure.
1272pub fn retry_budget(retry: Retry, k: &Killed, ceiling: u64) -> Option<Option<u64>> {
1273    if retry == Retry::Never || !matches!(k.why, Why::Silence(_)) {
1274        return None;
1275    }
1276    if !k
1277        .last_line
1278        .as_deref()
1279        .is_some_and(crate::hooks::wait::looks_like_wait)
1280    {
1281        return None;
1282    }
1283    if ceiling == 0 {
1284        return Some(None);
1285    }
1286    let left = ceiling.saturating_sub(k.ran_secs);
1287    (left >= k.idle_secs.max(1)).then_some(Some(left))
1288}
1289
1290/// Run to completion under the `amont.timeout` deadline with stdout and
1291/// stderr CAPTURED into a string the caller can parse — what the audit
1292/// checks need: their verdict lives in the tool's output, not its exit
1293/// code alone. Arrival-ordered merge of both streams, like
1294/// [`status_streamed`]'s blocks. `None` when the child cannot be spawned.
1295pub fn capture_within(
1296    settings: &crate::config::Settings,
1297    cmd: &mut Command,
1298) -> Option<(Ran, String)> {
1299    let text = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
1300    let sink = std::sync::Arc::clone(&text);
1301    let ran = run_observed(
1302        settings,
1303        cmd,
1304        move |bytes| {
1305            sink.lock()
1306                .unwrap_or_else(|p| p.into_inner())
1307                .push_str(&String::from_utf8_lossy(bytes));
1308        },
1309        None,
1310    )
1311    .ok()?;
1312    let text = std::sync::Arc::try_unwrap(text)
1313        .map(|m| m.into_inner().unwrap_or_else(|p| p.into_inner()))
1314        .unwrap_or_default();
1315    Some((ran, text))
1316}
1317
1318/// The wait over an already-spawned child — shared by every runner. The
1319/// silence budget in `clocks` applies only when there is an [`Activity`] to
1320/// consult (piped output); with inherited stdio the ceiling is the only
1321/// clock, and with that off too the child is simply waited for.
1322pub(crate) fn wait_within(
1323    child: &mut std::process::Child,
1324    clocks: Clocks,
1325    activity: Option<&Activity>,
1326) -> std::io::Result<Ran> {
1327    let started = std::time::Instant::now();
1328    let clocks = match activity {
1329        Some(_) => clocks,
1330        None => Clocks {
1331            silence: None,
1332            extended: None,
1333            ..clocks
1334        },
1335    };
1336    if clocks.ceiling.is_none() && clocks.silence.is_none() {
1337        return child.wait().map(Ran::Status);
1338    }
1339    // The clocks as judged: `silence` stretched by the host's load, re-read
1340    // every few seconds. The configured value stays in `clocks` for the
1341    // message.
1342    let mut live = clocks;
1343    let mut load: Option<(crate::load::Load, u32)> = None;
1344    let mut next_load = started;
1345    loop {
1346        if let Some(status) = child.try_wait()? {
1347            return Ok(Ran::Status(status));
1348        }
1349        let now = std::time::Instant::now();
1350        if let (Some(idle), Some(a)) = (clocks.silence, activity) {
1351            if clocks.scale > 1 && now >= next_load {
1352                next_load = now + LOAD_EVERY;
1353                if let Some(l) = crate::load::read() {
1354                    let factor = l.factor_milli(clocks.scale);
1355                    let applied = crate::load::scaled_budget(idle, l, clocks.scale, clocks.ceiling);
1356                    live.silence = Some(applied);
1357                    a.set_load(applied, l, factor);
1358                    load = (factor > 1000).then_some((l, factor));
1359                }
1360            }
1361        }
1362        // Judged on "silent AND idle on CPU"; equal to plain silence when CPU
1363        // is not sampled, and zero during a declared wait.
1364        let quiet = activity.map(|a| a.still_for());
1365        let waiting = activity.and_then(Activity::waiting);
1366        let gate = activity.map_or(CpuGate::NotSampled, Activity::gate);
1367        let why = judge(now.duration_since(started), quiet, waiting, gate, &live);
1368        if let Some(why) = why {
1369            let _ = child.kill();
1370            let _ = child.wait();
1371            return Ok(Ran::TimedOut(Killed {
1372                why,
1373                ran_secs: now.duration_since(started).as_secs(),
1374                // The true OUTPUT silence, for the message — not the
1375                // still-time the verdict was judged on.
1376                quiet_secs: activity.map(|a| a.quiet_for().as_secs()),
1377                cpu: activity.map_or(CpuVerdict::NotSampled, Activity::verdict),
1378                idle_secs: clocks.silence.unwrap_or(0),
1379                waiting: waiting.map(|(k, d)| (k, d.as_secs())),
1380                last_line: activity.and_then(Activity::last_line),
1381                load,
1382            }));
1383        }
1384        std::thread::sleep(std::time::Duration::from_millis(25));
1385    }
1386}
1387
1388/// Which clock, if any, has fired — the decision, with no process or
1389/// clock of its own so it can be tested to the second.
1390///
1391/// `ran` is how long the command has been running; `quiet` how long it has
1392/// been silent (and idle, where CPU is sampled), `None` when nobody is
1393/// watching its output; `waiting` the declared wait in progress, with its
1394/// length. The ceiling wins when several have fired: it is the larger
1395/// claim, and the message for it carries the other figures anyway. A
1396/// declared wait answers to `lock_wait`, never to the silence budget — the
1397/// tool has said what it is doing — and with `lock_wait` deferring to a
1398/// ceiling that is off, to the extended silence budget, so that nothing is
1399/// unbounded. `cpu` picks which silence budget applies: the plain one when
1400/// idleness was measured or is not sampled at all, the extended one while
1401/// the sampler cannot say ([`CpuGate`]).
1402pub fn judge(
1403    ran: std::time::Duration,
1404    quiet: Option<std::time::Duration>,
1405    waiting: Option<(crate::hooks::wait::WaitKind, std::time::Duration)>,
1406    cpu: CpuGate,
1407    clocks: &Clocks,
1408) -> Option<Why> {
1409    if let Some(wall) = clocks.ceiling {
1410        if ran >= std::time::Duration::from_secs(wall) {
1411            return Some(Why::Ceiling(wall));
1412        }
1413    }
1414    if let Some((kind, waited)) = waiting {
1415        let budget = match clocks.lock_wait {
1416            LockWait::Secs(s) => Some(s),
1417            LockWait::UntilCeiling if clocks.ceiling.is_some() => None,
1418            LockWait::UntilCeiling => clocks.extended,
1419        };
1420        return match budget {
1421            Some(b) if waited >= std::time::Duration::from_secs(b) => Some(Why::Waited(kind, b)),
1422            _ => None,
1423        };
1424    }
1425    let budget = match cpu {
1426        CpuGate::NotSampled | CpuGate::Measured => clocks.silence,
1427        CpuGate::Unmeasured => clocks.extended.or(clocks.silence),
1428    };
1429    if let (Some(idle), Some(q)) = (budget, quiet) {
1430        if q >= std::time::Duration::from_secs(idle) {
1431            return Some(Why::Silence(idle));
1432        }
1433    }
1434    None
1435}
1436
1437/// Say a command was killed, by which clock, and what that tells you.
1438///
1439/// The two clocks exist to answer two different questions, so the message
1440/// answers the one that was asked: silence means stuck — look at the tool;
1441/// the ceiling with recent output means slow — raise the ceiling.
1442pub fn say_timed_out(what: &str, k: Killed) {
1443    match k.why {
1444        Why::Silence(budget) => {
1445            // Why the budget is not the configured one, when it is not:
1446            // the extended budget while CPU could not be measured, or the
1447            // configured one stretched by the host's load.
1448            let stretched = match (k.cpu, k.load) {
1449                _ if budget == k.idle_secs => String::new(),
1450                (CpuVerdict::Unmeasured, _) => format!(
1451                    " (the extended budget, {} × {}: its CPU could not be measured)",
1452                    human_secs(k.idle_secs),
1453                    hl("amont.idleLoadScale")
1454                ),
1455                (_, Some((load, factor))) => format!(
1456                    " ({} stretched {} by a load average of {} on {} cores — {})",
1457                    human_secs(k.idle_secs),
1458                    crate::load::factor_text(factor),
1459                    load.avg1_text(),
1460                    load.cores,
1461                    hl("amont.idleLoadScale")
1462                ),
1463                _ => String::new(),
1464            };
1465            fail(&match k.cpu {
1466                CpuVerdict::MeasuredIdle(covered) => format!(
1467                    "{} printed nothing for {}{stretched} and did no measurable CPU work \
1468                     (< 0.1 core) in the last {} of it; killed after {} — a tool this idle \
1469                     is stuck, not slow. {} raises the silence budget (0 disables)",
1470                    hl(what),
1471                    human_secs(budget),
1472                    human_secs(covered.max(1)),
1473                    human_secs(k.ran_secs),
1474                    hl("git config amont.idleTimeout <secs>")
1475                ),
1476                _ => format!(
1477                    "{} printed nothing for {}{stretched}{} and was killed after {} — a tool \
1478                     this quiet is usually stuck, not slow. {} raises the silence budget (0 \
1479                     disables)",
1480                    hl(what),
1481                    human_secs(budget),
1482                    if k.cpu == CpuVerdict::Unmeasured && stretched.is_empty() {
1483                        " (CPU not measured)"
1484                    } else {
1485                        ""
1486                    },
1487                    human_secs(k.ran_secs),
1488                    hl("git config amont.idleTimeout <secs>")
1489                ),
1490            })
1491        }
1492        Why::Waited(kind, budget) => fail(&format!(
1493            "{} waited {} for {} and was killed after {} — {}. {} raises the wait budget \
1494             (0: until the ceiling)",
1495            hl(what),
1496            human_secs(budget),
1497            kind.describe(),
1498            human_secs(k.ran_secs),
1499            kind.holders(),
1500            hl("git config amont.lockWait <secs>")
1501        )),
1502        Why::Ceiling(budget) => {
1503            let verdict = match (k.quiet_secs, k.cpu) {
1504                (_, _) if k.waiting.is_some() => {
1505                    let (kind, w) = k.waiting.expect("checked");
1506                    format!(
1507                        " It was waiting for {} for the last {} — {}.",
1508                        kind.describe(),
1509                        human_secs(w),
1510                        kind.holders()
1511                    )
1512                }
1513                (Some(q), CpuVerdict::BusyAtKill(m)) if k.idle_secs > 0 && q >= k.idle_secs => {
1514                    format!(
1515                        " It printed nothing for the last {} but kept its CPU busy ({}), so the \
1516                     silence budget did not stop it: a busy loop, or a tool that prints only \
1517                     at the end (give it a per-file reporter). {} kills quiet runs at the \
1518                     silence budget whatever their CPU.",
1519                        human_secs(q),
1520                        cores(m),
1521                        hl("git config amont.idleCpuCredit false")
1522                    )
1523                }
1524                (Some(q), CpuVerdict::Unmeasured) if k.idle_secs > 0 && q >= k.idle_secs => {
1525                    format!(
1526                        " It printed nothing for the last {} and its CPU could not be measured, \
1527                         so only the extended silence budget applied. {} kills quiet runs at \
1528                         the silence budget whatever their CPU.",
1529                        human_secs(q),
1530                        hl("git config amont.idleCpuCredit false")
1531                    )
1532                }
1533                (Some(q), _) if q < 30 => format!(
1534                    " It was still printing ({} since its last line): slow, not stuck.",
1535                    human_secs(q)
1536                ),
1537                (Some(q), _) => format!(" Its last output was {} ago.", human_secs(q)),
1538                (None, _) => String::new(),
1539            };
1540            fail(&format!(
1541                "{} timed out: ran for {} and was killed at the ceiling. {} raises it \
1542                 (0 disables).{verdict}",
1543                hl(what),
1544                human_secs(budget),
1545                hl("git config amont.timeout <secs>")
1546            ))
1547        }
1548    }
1549}
1550
1551/// [`status_within`], collapsed to "did it exit 0" — the shape the one-shot
1552/// tool spawns want. A timeout says so, names `what`, and reads as failure.
1553pub fn bounded_success(settings: &crate::config::Settings, cmd: &mut Command, what: &str) -> bool {
1554    match status_streamed(settings, cmd, Retry::Once) {
1555        Ok(Ran::Status(s)) => s.success(),
1556        Ok(Ran::TimedOut(b)) => {
1557            say_timed_out(what, b);
1558            false
1559        }
1560        Err(_) => false,
1561    }
1562}
1563
1564/// Run `argv` from `root`, inheriting stdio. True when it exits 0.
1565pub fn run(
1566    settings: &crate::config::Settings,
1567    root: &str,
1568    argv: &[String],
1569    extra: &[String],
1570) -> bool {
1571    let Some((program, rest)) = argv.split_first() else {
1572        return true;
1573    };
1574    let mut cmd = Command::new(program);
1575    cmd.args(rest)
1576        .args(extra)
1577        .current_dir(root)
1578        .stdin(Stdio::null());
1579    strip_git_env(&mut cmd);
1580    bounded_success(settings, &mut cmd, program)
1581}
1582
1583/// As [`run`], but with the tool's own output discarded.
1584///
1585/// For a pass whose only job is to decide something — prettier's `--check`,
1586/// ruff's `--fix` sweep — where the offenders are printed once, by the pass
1587/// that reports them, rather than twice.
1588pub fn run_quiet(
1589    settings: &crate::config::Settings,
1590    root: &str,
1591    argv: &[String],
1592    extra: &[String],
1593) -> bool {
1594    let Some((program, rest)) = argv.split_first() else {
1595        return true;
1596    };
1597    let mut cmd = Command::new(program);
1598    cmd.args(rest)
1599        .args(extra)
1600        .current_dir(root)
1601        .stdin(Stdio::null());
1602    strip_git_env(&mut cmd);
1603    // Deliberately NOT the streamed runner: this helper's contract is that
1604    // the output is discarded, and capture would resurrect it into the
1605    // block. Observed and dropped instead of `/dev/null`, so the silence
1606    // clock still sees whether the tool is alive.
1607    match run_observed(settings, &mut cmd, |_| {}, None) {
1608        Ok(Ran::Status(s)) => s.success(),
1609        Ok(Ran::TimedOut(b)) => {
1610            say_timed_out(program, b);
1611            false
1612        }
1613        Err(_) => false,
1614    }
1615}
1616
1617/// Whether the user asked for checks to repair what they find.
1618///
1619/// OFF by default. `git config amont.fix true` turns it on, per repository,
1620/// because a hook that edits your files without being asked is a larger
1621/// surprise than one that complains — and because with index fidelity in place
1622/// the repair lands in the commit you are making, which is a bigger claim to
1623/// make on somebody's behalf than printing an error.
1624pub fn fixing_enabled(settings: &crate::config::Settings) -> bool {
1625    // Never while the file set is not the index — see `NOT_THE_INDEX`.
1626    !not_the_index() && fixing_requested(settings)
1627}
1628
1629/// What the CONFIG says, ignoring whether the current run may act on it.
1630///
1631/// Split out so `run_all` can tell the difference between "fixing is off" and
1632/// "you asked for fixing and this mode will not do it", and say the second out
1633/// loud instead of silently ignoring the key.
1634pub fn fixing_requested(settings: &crate::config::Settings) -> bool {
1635    *settings
1636        .fixing
1637        .get_or_init(|| crate::config::boolean_or(settings, "amont.fix", false))
1638}
1639
1640/// What a re-stage actually did. THREE answers, because the old `bool`
1641/// conflated two of them and the conflation shipped unformatted code.
1642///
1643/// `prettier.rs` read `if run_quiet(write) && restage(&files) { … Fixed }`. When
1644/// `git add` FAILED, `restage` returned `false` — indistinguishable from
1645/// "nothing needed staging" — so control fell through to a second `--check`
1646/// pass, which inspected the NOW-FORMATTED WORKING TREE, passed, printed
1647/// "Prettier passed" and returned `Outcome::Passed`. The index still held the
1648/// unformatted content, so the commit contained unformatted code and the hook
1649/// said it had passed. `manifest.rs` had the same shape.
1650#[derive(Debug, Clone, PartialEq, Eq)]
1651pub enum Restaged {
1652    /// No path differed from the index — nothing to do, and nothing wrong.
1653    Nothing,
1654    /// `git add` succeeded; the index now holds the repair.
1655    Staged,
1656    /// `git add` failed, carrying the paths it could not stage. The index
1657    /// holds content the fixer has already replaced on disk, so this MUST be
1658    /// loud at every call site — and naming the files is the difference
1659    /// between a message somebody can act on and one they cannot.
1660    Failed(Vec<String>),
1661}
1662
1663/// Serialises this process's own `git add` calls.
1664///
1665/// pre-commit runs its checks concurrently (`dispatch.rs`), and up to three of
1666/// them can re-stage. git takes `$GIT_DIR/index.lock` exclusively, so two
1667/// concurrent `git add`s in the same repository make one of them fail — which,
1668/// before `Restaged`, was silently read as "nothing moved". Holding this across
1669/// the `git add` removes self-contention entirely; the retry below is only for
1670/// OTHER processes.
1671static INDEX_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1672
1673/// Re-stage exactly the paths a fixer rewrote, and say what happened.
1674///
1675/// Safe ONLY because the pre-commit stage holds unstaged changes aside: the
1676/// tree contains the staged content and nothing else, so anything a formatter
1677/// touched is by definition part of this commit. Without that, re-staging would
1678/// sweep in work the author deliberately kept back.
1679pub fn restage(paths: &[String]) -> Restaged {
1680    // Belt and braces alongside `fixing_enabled`: a future fixer that forgets
1681    // the gate still cannot turn `amont run --all-files` into `git add .`.
1682    if not_the_index() {
1683        return Restaged::Nothing;
1684    }
1685    let changed: Vec<String> = paths
1686        .iter()
1687        .filter(|p| !git::succeeds(&["diff", "--quiet", "--", p]))
1688        .cloned()
1689        .collect();
1690    if changed.is_empty() {
1691        return Restaged::Nothing;
1692    }
1693    let mut args = vec!["add", "--"];
1694    args.extend(changed.iter().map(String::as_str));
1695
1696    let _serialised = INDEX_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1697    // Another PROCESS can hold `index.lock` — a `git status` from an editor, a
1698    // second hook in a linked worktree. Back off and retry rather than
1699    // reporting a transient collision as a failed repair. `git add` of the same
1700    // paths is idempotent: it records the paths' current worktree content, so
1701    // running it twice records the same thing twice and cannot double-stage.
1702    const BACKOFF_MS: [u64; 3] = [50, 150, 400];
1703    if git::succeeds(&args) {
1704        return Restaged::Staged;
1705    }
1706    for wait in BACKOFF_MS {
1707        std::thread::sleep(std::time::Duration::from_millis(wait));
1708        if git::succeeds(&args) {
1709            return Restaged::Staged;
1710        }
1711    }
1712    Restaged::Failed(changed)
1713}
1714
1715/// A check passed. THE funnel for every success line, which is what lets
1716/// `amont.quiet` swallow them in one place — see [`crate::live::quiet`].
1717pub fn ok(settings: &crate::config::Settings, msg: &str) {
1718    if crate::live::quiet(settings) {
1719        return;
1720    }
1721    crate::live::say(&format!("{} {msg}", valid_sign()));
1722}
1723pub fn fail(msg: &str) {
1724    crate::live::say(&format!("{} {msg}", error_sign()));
1725}
1726pub fn warn(msg: &str) {
1727    crate::live::say(&format!("{} {msg}", warning_sign()));
1728}
1729/// A line with no sign of its own — what a check's direct `println!` becomes,
1730/// so it lands in the check's block instead of interleaving. See `live::say`.
1731pub fn say(msg: &str) {
1732    crate::live::say(msg);
1733}
1734
1735/// Orange, for the fragments these hooks highlight.
1736pub fn hl(s: &str) -> String {
1737    crate::ui::highlight(s)
1738}
1739
1740#[cfg(test)]
1741mod tests {
1742
1743    /// A window of `milli` thousandths of a core between `start` and `end`
1744    /// (a zero-length window still reports `milli`: `milli_cores` floors the
1745    /// wall time at 1 ns).
1746    fn window(
1747        start: std::time::Instant,
1748        end: std::time::Instant,
1749        milli: u64,
1750    ) -> crate::proctree::Observation {
1751        let wall = u64::try_from(end.duration_since(start).as_nanos())
1752            .unwrap_or(u64::MAX)
1753            .max(1);
1754        let gain = wall * milli / 1000;
1755        crate::proctree::Observation::Window(crate::proctree::Window {
1756            start,
1757            end,
1758            gain_ns: gain,
1759            gapped: false,
1760        })
1761    }
1762
1763    /// A gapped window resets the measured-idle span to its own end: the
1764    /// claim never covers the gap, while a busy gapped window still counts
1765    /// as busy.
1766    #[test]
1767    fn a_gapped_window_is_never_claimed_as_measured_idle() {
1768        use super::{Activity, CpuVerdict};
1769        use crate::proctree::{Observation, Window};
1770        let s = std::time::Duration::from_secs;
1771        let every = s(10);
1772        let a = Activity::new();
1773        a.enable_cpu();
1774        let t0 = std::time::Instant::now() - s(30);
1775        a.record(Observation::Baseline, t0, every);
1776        a.record(Observation::Skipped, t0 + s(10), every);
1777        let gap_end = std::time::Instant::now();
1778        a.record(
1779            Observation::Window(Window {
1780                start: t0,
1781                end: gap_end,
1782                gain_ns: 0,
1783                gapped: true,
1784            }),
1785            gap_end,
1786            every,
1787        );
1788        // Idle, but the span rests only on what followed the gap: 0 s.
1789        assert_eq!(a.verdict(), CpuVerdict::MeasuredIdle(0));
1790        assert_eq!(a.gate(), super::CpuGate::Measured);
1791
1792        let busy = Activity::new();
1793        busy.enable_cpu();
1794        busy.record(Observation::Baseline, t0, every);
1795        busy.record(Observation::Skipped, t0 + s(10), every);
1796        let now = std::time::Instant::now();
1797        busy.record(window_gapped(t0, now, 3000), now, every);
1798        assert_eq!(busy.verdict(), CpuVerdict::BusyAtKill(3000));
1799        assert!(busy.still_for() < s(1));
1800    }
1801
1802    fn window_gapped(
1803        start: std::time::Instant,
1804        end: std::time::Instant,
1805        milli: u64,
1806    ) -> crate::proctree::Observation {
1807        match window(start, end, milli) {
1808            crate::proctree::Observation::Window(w) => {
1809                crate::proctree::Observation::Window(crate::proctree::Window { gapped: true, ..w })
1810            }
1811            o => o,
1812        }
1813    }
1814
1815    /// Even the shortest budget is sampled several times before it runs out,
1816    /// and the default one starts at 30 s and repeats every 10 s.
1817    #[test]
1818    fn the_sampling_schedule_follows_the_budget_within_floors() {
1819        use super::sampling_schedule;
1820        let ms = std::time::Duration::from_millis;
1821        let third = |secs: u64| std::time::Duration::from_secs(secs) / 3;
1822        assert_eq!(sampling_schedule(1), (third(1), ms(250)));
1823        assert_eq!(sampling_schedule(2), (third(2), ms(500)));
1824        assert_eq!(sampling_schedule(120), (ms(30_000), ms(10_000)));
1825        assert_eq!(sampling_schedule(0), (ms(250), ms(250)));
1826    }
1827
1828    /// Without CPU data the still-time IS the silence; a busy window pulls it
1829    /// back to the window's start, and the output clock is left alone.
1830    #[test]
1831    fn busy_work_resets_the_still_time_but_not_the_output_silence() {
1832        use super::Activity;
1833        let a = Activity::new();
1834        a.touch();
1835        std::thread::sleep(std::time::Duration::from_millis(60));
1836        let quiet = a.quiet_for();
1837        assert!(a.still_for() >= quiet.saturating_sub(std::time::Duration::from_millis(5)));
1838        a.enable_cpu();
1839        let now = std::time::Instant::now();
1840        a.record(
1841            window(now - std::time::Duration::from_millis(10), now, 2000),
1842            now,
1843            std::time::Duration::from_secs(1),
1844        );
1845        assert!(a.still_for() < std::time::Duration::from_millis(40));
1846        assert!(a.quiet_for() >= std::time::Duration::from_millis(60));
1847    }
1848
1849    /// What the kill message may claim. "Measured idle" names the unbroken
1850    /// span of complete measurements behind it; a broken run is unmeasured;
1851    /// a fresh busy window is reported as busy; no sampling says nothing.
1852    #[test]
1853    fn the_cpu_verdict_claims_only_what_was_measured() {
1854        use super::{Activity, CpuVerdict};
1855        use crate::proctree::Observation;
1856        let s = std::time::Duration::from_secs;
1857        let every = s(10);
1858
1859        let off = Activity::new();
1860        assert_eq!(off.verdict(), CpuVerdict::NotSampled);
1861
1862        let waiting = Activity::new();
1863        waiting.enable_cpu();
1864        assert_eq!(waiting.verdict(), CpuVerdict::Unmeasured);
1865
1866        let now = std::time::Instant::now();
1867        let before = now - s(1);
1868        // A real second of complete, idle measurement: the claim names it.
1869        let idle = Activity::new();
1870        idle.enable_cpu();
1871        let t0 = std::time::Instant::now();
1872        idle.record(Observation::Baseline, t0, every);
1873        std::thread::sleep(std::time::Duration::from_millis(1050));
1874        let t1 = std::time::Instant::now();
1875        idle.record(window(t0, t1, 20), t1, every);
1876        assert_eq!(idle.verdict(), CpuVerdict::MeasuredIdle(1));
1877
1878        let busy = Activity::new();
1879        busy.enable_cpu();
1880        busy.record(Observation::Baseline, before, every);
1881        busy.record(window(before, now, 3900), now, every);
1882        assert_eq!(busy.verdict(), CpuVerdict::BusyAtKill(3900));
1883
1884        let broken = Activity::new();
1885        broken.enable_cpu();
1886        broken.record(Observation::Baseline, before, every);
1887        broken.record(window(before, now, 20), now, every);
1888        broken.record(Observation::Unmeasured, now, every);
1889        assert_eq!(broken.verdict(), CpuVerdict::Unmeasured);
1890        assert_eq!(broken.gate(), super::CpuGate::Unmeasured);
1891        assert_eq!(off.gate(), super::CpuGate::NotSampled);
1892        assert_eq!(idle.gate(), super::CpuGate::Measured);
1893        assert_eq!(busy.gate(), super::CpuGate::Measured);
1894    }
1895
1896    /// A rate older than two sampling intervals is not shown, and cannot
1897    /// back a "busy" or "idle" claim.
1898    #[test]
1899    fn a_stale_rate_expires() {
1900        use super::{Activity, CpuVerdict};
1901        use crate::proctree::Observation;
1902        let a = Activity::new();
1903        a.enable_cpu();
1904        let then = std::time::Instant::now();
1905        let tick = std::time::Duration::from_millis(5);
1906        let earlier = then - std::time::Duration::from_secs(1);
1907        a.record(Observation::Baseline, earlier, tick);
1908        a.record(window(earlier, then, 3000), then, tick);
1909        std::thread::sleep(std::time::Duration::from_millis(40));
1910        assert_eq!(a.fresh_rate(), None);
1911        assert_eq!(a.verdict(), CpuVerdict::Unmeasured);
1912    }
1913
1914    #[test]
1915    fn cores_read_as_one_decimal() {
1916        assert_eq!(super::cores(3900), "~3.9 cores");
1917        assert_eq!(super::cores(420), "~0.4 cores");
1918        assert_eq!(super::cores(100), "~0.1 cores");
1919    }
1920
1921    /// The two clocks, decided to the second. A chatty command outlives any
1922    /// silence budget however long it runs; a silent one dies at the budget
1923    /// however short; a command nobody watches answers to the ceiling only.
1924    #[test]
1925    fn the_clocks_judge_silence_and_ceiling_separately() {
1926        use super::{judge, CpuGate, Why};
1927        use std::time::Duration as D;
1928        let s = D::from_secs;
1929        let c = |ceiling: Option<u64>, silence: Option<u64>| super::Clocks {
1930            ceiling,
1931            silence,
1932            lock_wait: super::LockWait::Secs(600),
1933            extended: silence,
1934            scale: 1,
1935        };
1936        // Chatty and long: past a five-second silence budget, still fine.
1937        assert_eq!(
1938            judge(
1939                s(900),
1940                Some(s(0)),
1941                None,
1942                CpuGate::NotSampled,
1943                &c(Some(3600), Some(5))
1944            ),
1945            None
1946        );
1947        assert_eq!(
1948            judge(
1949                s(900),
1950                Some(s(4)),
1951                None,
1952                CpuGate::NotSampled,
1953                &c(Some(3600), Some(5))
1954            ),
1955            None
1956        );
1957        // Silent for the budget: killed, and the silence is blamed.
1958        assert_eq!(
1959            judge(
1960                s(30),
1961                Some(s(5)),
1962                None,
1963                CpuGate::NotSampled,
1964                &c(Some(3600), Some(5))
1965            ),
1966            Some(Why::Silence(5))
1967        );
1968        // Unobserved output: the silence clock cannot run at all.
1969        assert_eq!(
1970            judge(
1971                s(900),
1972                None,
1973                None,
1974                CpuGate::NotSampled,
1975                &c(Some(3600), Some(5))
1976            ),
1977            None
1978        );
1979        // The ceiling fires on elapsed time whatever the output is doing.
1980        assert_eq!(
1981            judge(
1982                s(3600),
1983                Some(s(0)),
1984                None,
1985                CpuGate::NotSampled,
1986                &c(Some(3600), Some(120))
1987            ),
1988            Some(Why::Ceiling(3600))
1989        );
1990        // Both fired at once: the ceiling is the answer.
1991        assert_eq!(
1992            judge(
1993                s(3600),
1994                Some(s(600)),
1995                None,
1996                CpuGate::NotSampled,
1997                &c(Some(3600), Some(120))
1998            ),
1999            Some(Why::Ceiling(3600))
2000        );
2001        // Both off: nothing ever fires.
2002        assert_eq!(
2003            judge(
2004                s(86_400),
2005                Some(s(86_400)),
2006                None,
2007                CpuGate::NotSampled,
2008                &c(None, None)
2009            ),
2010            None
2011        );
2012        // Only silence on: no ceiling, however long it runs.
2013        assert_eq!(
2014            judge(
2015                s(86_400),
2016                Some(s(1)),
2017                None,
2018                CpuGate::NotSampled,
2019                &c(None, Some(120))
2020            ),
2021            None
2022        );
2023    }
2024
2025    /// A declared wait answers to `amont.lockWait` and never to the silence
2026    /// budget; at 0 it answers to the ceiling, and with the ceiling off too,
2027    /// to the extended silence budget — never to nothing.
2028    #[test]
2029    fn a_declared_wait_answers_to_its_own_budget() {
2030        use super::{judge, Clocks, CpuGate, LockWait, Why};
2031        use crate::hooks::wait::{CargoLockWhat, WaitKind};
2032        use std::time::Duration as D;
2033        let s = D::from_secs;
2034        let kind = WaitKind::CargoLock(CargoLockWhat::BuildDirectory);
2035        let clocks = Clocks {
2036            ceiling: Some(3600),
2037            silence: Some(120),
2038            lock_wait: LockWait::Secs(600),
2039            extended: Some(480),
2040            scale: 4,
2041        };
2042        // Silent for ten times the budget, but in a declared wait: fine.
2043        assert_eq!(
2044            judge(
2045                s(1300),
2046                Some(s(0)),
2047                Some((kind, s(599))),
2048                CpuGate::Measured,
2049                &clocks
2050            ),
2051            None
2052        );
2053        // The wait outlives its budget: killed, and the wait is blamed.
2054        assert_eq!(
2055            judge(
2056                s(1300),
2057                Some(s(0)),
2058                Some((kind, s(600))),
2059                CpuGate::Measured,
2060                &clocks
2061            ),
2062            Some(Why::Waited(kind, 600))
2063        );
2064        // The ceiling still wins.
2065        assert_eq!(
2066            judge(
2067                s(3600),
2068                Some(s(0)),
2069                Some((kind, s(3000))),
2070                CpuGate::Measured,
2071                &clocks
2072            ),
2073            Some(Why::Ceiling(3600))
2074        );
2075        // lockWait 0 with a ceiling: the ceiling alone bounds the wait.
2076        let until = Clocks {
2077            lock_wait: LockWait::UntilCeiling,
2078            ..clocks
2079        };
2080        assert_eq!(
2081            judge(
2082                s(3000),
2083                Some(s(0)),
2084                Some((kind, s(2900))),
2085                CpuGate::Measured,
2086                &until
2087            ),
2088            None
2089        );
2090        // lockWait 0 and no ceiling: the extended budget bounds it.
2091        let open = Clocks {
2092            ceiling: None,
2093            ..until
2094        };
2095        assert_eq!(
2096            judge(
2097                s(3000),
2098                Some(s(0)),
2099                Some((kind, s(479))),
2100                CpuGate::Measured,
2101                &open
2102            ),
2103            None
2104        );
2105        assert_eq!(
2106            judge(
2107                s(3000),
2108                Some(s(0)),
2109                Some((kind, s(480))),
2110                CpuGate::Measured,
2111                &open
2112            ),
2113            Some(Why::Waited(kind, 480))
2114        );
2115    }
2116
2117    /// One retry, only after a silence kill whose last line reads like a
2118    /// wait, only for a built-in, only with a silence budget of ceiling
2119    /// left, and under what is left.
2120    #[test]
2121    fn a_wait_like_silence_kill_earns_one_retry_under_the_remaining_ceiling() {
2122        use super::{retry_budget, CpuVerdict, Killed, Retry, Why};
2123        use crate::hooks::wait::{CargoLockWhat, WaitKind};
2124        let k = |why: Why, line: &str, ran: u64| Killed {
2125            why,
2126            ran_secs: ran,
2127            quiet_secs: Some(ran),
2128            cpu: CpuVerdict::MeasuredIdle(1),
2129            idle_secs: 120,
2130            waiting: None,
2131            last_line: Some(line.to_string()),
2132            load: None,
2133        };
2134        let wait = "waiting for lock on x";
2135        assert_eq!(
2136            retry_budget(Retry::Once, &k(Why::Silence(120), wait, 120), 3600),
2137            Some(Some(3480))
2138        );
2139        assert_eq!(
2140            retry_budget(Retry::Once, &k(Why::Silence(120), wait, 120), 0),
2141            Some(None)
2142        );
2143        assert_eq!(
2144            retry_budget(Retry::Never, &k(Why::Silence(120), wait, 120), 3600),
2145            None
2146        );
2147        assert_eq!(
2148            retry_budget(
2149                Retry::Once,
2150                &k(Why::Silence(120), "test_lock.py::x", 120),
2151                3600
2152            ),
2153            None
2154        );
2155        assert_eq!(
2156            retry_budget(Retry::Once, &k(Why::Ceiling(3600), wait, 3600), 3600),
2157            None
2158        );
2159        let waited = Why::Waited(WaitKind::CargoLock(CargoLockWhat::BuildDirectory), 600);
2160        assert_eq!(retry_budget(Retry::Once, &k(waited, wait, 700), 3600), None);
2161        // Less than one silence budget of ceiling left: no retry.
2162        assert_eq!(
2163            retry_budget(Retry::Once, &k(Why::Silence(120), wait, 3500), 3600),
2164            None
2165        );
2166    }
2167
2168    /// While the CPU is unmeasured the silence budget is the extended one:
2169    /// no kill before `idle × scale`, a kill at it even with the ceiling
2170    /// off; measured and not-sampled keep the plain budget.
2171    #[test]
2172    fn an_unmeasured_cpu_answers_to_the_extended_budget() {
2173        use super::{judge, Clocks, CpuGate, LockWait, Why};
2174        use std::time::Duration as D;
2175        let s = D::from_secs;
2176        let clocks = Clocks {
2177            ceiling: None,
2178            silence: Some(120),
2179            lock_wait: LockWait::Secs(600),
2180            extended: Some(480),
2181            scale: 4,
2182        };
2183        assert_eq!(
2184            judge(s(500), Some(s(300)), None, CpuGate::Unmeasured, &clocks),
2185            None
2186        );
2187        assert_eq!(
2188            judge(s(500), Some(s(479)), None, CpuGate::Unmeasured, &clocks),
2189            None
2190        );
2191        assert_eq!(
2192            judge(s(500), Some(s(480)), None, CpuGate::Unmeasured, &clocks),
2193            Some(Why::Silence(480))
2194        );
2195        assert_eq!(
2196            judge(s(500), Some(s(120)), None, CpuGate::Measured, &clocks),
2197            Some(Why::Silence(120))
2198        );
2199        assert_eq!(
2200            judge(s(500), Some(s(120)), None, CpuGate::NotSampled, &clocks),
2201            Some(Why::Silence(120))
2202        );
2203        // No extended budget configured (silence off): nothing fires.
2204        let off = Clocks {
2205            silence: None,
2206            extended: None,
2207            ..clocks
2208        };
2209        assert_eq!(
2210            judge(s(86_400), Some(s(86_400)), None, CpuGate::Unmeasured, &off),
2211            None
2212        );
2213    }
2214
2215    /// A marker line starts a wait that a second marker continues and any
2216    /// other line ends; the silence the clock counts is zero meanwhile, the
2217    /// output silence is not, and the time is banked for the note.
2218    #[test]
2219    fn a_marker_line_pauses_the_silence_clock_until_another_line() {
2220        use super::Activity;
2221        use crate::hooks::wait::{CargoLockWhat, WaitKind};
2222        let a = Activity::new();
2223        a.touch();
2224        assert_eq!(a.waiting(), None);
2225        a.stderr_line("    Blocking waiting for file lock on package cache");
2226        std::thread::sleep(std::time::Duration::from_millis(30));
2227        let (kind, waited) = a.waiting().expect("a wait is in progress");
2228        assert_eq!(kind, WaitKind::CargoLock(CargoLockWhat::PackageCache));
2229        assert!(waited >= std::time::Duration::from_millis(30));
2230        assert_eq!(a.silence_for(), std::time::Duration::ZERO);
2231        assert_eq!(a.still_for(), std::time::Duration::ZERO);
2232        assert!(a.quiet_for() >= std::time::Duration::from_millis(30));
2233        // A second marker keeps the original start, and names the new lock.
2234        a.stderr_line("\u{1b}[1m    Blocking\u{1b}[0m waiting for file lock on build directory");
2235        let (kind, again) = a.waiting().expect("still waiting");
2236        assert_eq!(kind, WaitKind::CargoLock(CargoLockWhat::BuildDirectory));
2237        assert!(again >= waited);
2238        // Any other line ends it, and the time spent is banked. The reader
2239        // touches the output clock before it frames, as here.
2240        a.touch();
2241        a.stderr_line("    Checking foo v0.1.0");
2242        assert_eq!(a.waiting(), None);
2243        assert!(a.waited_total() >= std::time::Duration::from_millis(30));
2244        assert_eq!(a.last_wait_kind(), Some(kind));
2245        assert_eq!(a.last_line().as_deref(), Some("Checking foo v0.1.0"));
2246        assert!(a.silence_for() < std::time::Duration::from_millis(20));
2247        // A stdout chunk ends a wait too.
2248        a.stderr_line("Waiting to acquire write lock for `x`");
2249        assert!(a.waiting().is_some());
2250        a.end_wait();
2251        assert_eq!(a.waiting(), None);
2252    }
2253
2254    /// The deadline kills what outlives it and reports what finished.
2255    #[cfg(unix)]
2256    #[test]
2257    fn the_deadline_kills_a_sleeper_and_spares_a_finisher() {
2258        let started = std::time::Instant::now();
2259        let mut slow = Command::new(program("sleep"));
2260        slow.arg("300").stdin(Stdio::null());
2261        match status_within_secs(&mut slow, 1) {
2262            Ok(Ran::TimedOut(Killed {
2263                why: Why::Ceiling(1),
2264                quiet_secs: None,
2265                ..
2266            })) => {}
2267            other => panic!("expected TimedOut(1), got {:?}", other.map(|_| "ran")),
2268        }
2269        assert!(
2270            started.elapsed() < std::time::Duration::from_secs(60),
2271            "the kill did not happen at the deadline"
2272        );
2273
2274        let mut quick = Command::new(program("true"));
2275        quick.stdin(Stdio::null());
2276        match status_within_secs(&mut quick, 60) {
2277            Ok(Ran::Status(s)) => assert!(s.success()),
2278            other => panic!("expected a clean exit, got {:?}", other.map(|_| "?")),
2279        }
2280    }
2281
2282    use super::*;
2283
2284    #[test]
2285    fn which_finds_a_real_binary_and_not_a_fake_one() {
2286        assert!(which("git").is_some());
2287        assert!(which("definitely-not-a-real-binary-xyz").is_none());
2288    }
2289
2290    /// On Windows a tool can exist BOTH as an extensionless shell script and as
2291    /// a .cmd/.exe in the same directory; only the latter is executable by
2292    /// CreateProcess, so the extension forms must win.
2293    #[test]
2294    #[cfg(windows)]
2295    fn windows_prefers_an_executable_extension_over_a_bare_file() {
2296        let dir = std::env::temp_dir().join("amont-which-order");
2297        let _ = std::fs::create_dir_all(&dir);
2298        std::fs::write(dir.join("faketool"), "#!/bin/sh\n").unwrap();
2299        std::fs::write(dir.join("faketool.cmd"), "@echo off\n").unwrap();
2300        // The path is PASSED, never installed into this process: see
2301        // `which_on`. The old spelling swapped the real PATH out from under
2302        // every other test in this binary for the length of the call.
2303        let found = which_on(dir.as_os_str(), "faketool").unwrap();
2304        assert!(found.ends_with(".cmd"), "got {found}");
2305        let _ = std::fs::remove_dir_all(&dir);
2306    }
2307
2308    /// "Nothing moved" and "`git add` FAILED" are different answers, and the
2309    /// old `bool` gave the same one for both.
2310    ///
2311    /// That conflation is what shipped unformatted code: `prettier.rs` read
2312    /// `if wrote && restage(&files)`, so a failed `git add` fell through to a
2313    /// second `--check` against the now-formatted WORKING TREE, which passed —
2314    /// while the INDEX still held the unformatted content the commit would
2315    /// carry.
2316    ///
2317    /// An absolute path outside any repository is a `git add` git will always
2318    /// refuse, which is the only way to reach the failing branch without
2319    /// sabotaging a real index.
2320    #[test]
2321    fn restage_distinguishes_nothing_from_failure() {
2322        // `restage` runs `git add` in the PROCESS cwd, so this test depends
2323        // on that cwd as surely as one that moves it — see `crate::TEST_CWD`.
2324        // Without the lock it ran inside whatever fixture `gate_stamp` had
2325        // moved into, and took that repository's index.lock out from under
2326        // its own commit.
2327        let _cwd = crate::TEST_CWD.lock().unwrap_or_else(|p| p.into_inner());
2328        let outside = std::env::temp_dir()
2329            .join("amont-restage-outside-any-repo")
2330            .to_string_lossy()
2331            .into_owned();
2332        assert_eq!(
2333            restage(std::slice::from_ref(&outside)),
2334            Restaged::Failed(vec![outside]),
2335            "a `git add` git refuses must report Failed, never Nothing"
2336        );
2337        assert_eq!(
2338            restage(&[]),
2339            Restaged::Nothing,
2340            "no paths is nothing to do, and nothing wrong"
2341        );
2342    }
2343
2344    /// No check may hand `Command` a bare program name.
2345    ///
2346    /// `Command::new` does NO PATHEXT resolution, so `Command::new("npm")`
2347    /// cannot execute `npm.cmd` and `Command::new("uvx")` cannot execute
2348    /// `uvx.exe`: the spawn fails with "program not found" and a
2349    /// `Severity::Block` check reports an installed tool as broken. That is the
2350    /// incident `program()` exists for, and it kept recurring — `yamllint` and
2351    /// three sites in `python_tools` were still doing it, THREE OF THEM after
2352    /// `which()` had already succeeded and discarded the answer.
2353    ///
2354    /// A source scan rather than a runtime assertion because the failure only
2355    /// reproduces on Windows, and the whole point is to catch the next one on
2356    /// every platform. Comment lines are skipped: `program()`'s own doc quotes
2357    /// the offending call. The needle is assembled from two pieces so this
2358    /// module — which the scan also reads — does not match itself.
2359    #[test]
2360    fn no_hook_spawns_a_bare_program_name() {
2361        let needle = concat!("Command", "::new(");
2362        let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/src/hooks");
2363        let mut scanned = 0usize;
2364        for entry in std::fs::read_dir(dir).expect("hooks dir").flatten() {
2365            let path = entry.path();
2366            if path.extension().and_then(|e| e.to_str()) != Some("rs") {
2367                continue;
2368            }
2369            scanned += 1;
2370            let src = std::fs::read_to_string(&path).expect("read a hook module");
2371            for (n, line) in src.lines().enumerate() {
2372                if line.trim_start().starts_with("//") {
2373                    continue;
2374                }
2375                let Some(after) = line.split_once(needle) else {
2376                    continue;
2377                };
2378                assert!(
2379                    !after.1.starts_with('"'),
2380                    "{}:{} spawns a bare name — route it through `program()` or \
2381                     the path `which()` already resolved: {}",
2382                    path.display(),
2383                    n + 1,
2384                    line.trim()
2385                );
2386            }
2387        }
2388        assert!(
2389            scanned > 10,
2390            "the scan found almost nothing: {scanned} files"
2391        );
2392    }
2393
2394    #[test]
2395    fn first_existing_picks_the_earliest_present_name() {
2396        let dir = std::env::temp_dir().join("amont-first-existing-test");
2397        let _ = std::fs::create_dir_all(&dir);
2398        let root = dir.to_string_lossy().into_owned();
2399        let _ = std::fs::write(dir.join("second"), "x");
2400        assert_eq!(
2401            first_existing(&root, &["first", "second", "third"]).as_deref(),
2402            Some("second")
2403        );
2404        assert_eq!(first_existing(&root, &["nope"]), None);
2405        let _ = std::fs::remove_dir_all(&dir);
2406    }
2407}