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/// `secs` as people read it: `12s`, `8m12s`, `1h02m`.
392pub fn human_secs(secs: u64) -> String {
393    match secs {
394        s if s < 60 => format!("{s}s"),
395        s if s < 3600 => format!("{}m{:02}s", s / 60, s % 60),
396        s => format!("{}h{:02}m", s / 3600, (s % 3600) / 60),
397    }
398}
399
400/// A work window of at least this many thousandths of one core counts as the
401/// tree doing something (ADR-0008): 0.1 core. A hang — a prompt, a lock, a
402/// dead network — sits near zero; a test suite runs at whole cores.
403pub const BUSY_MILLI_CORES: u32 = 100;
404
405/// What the CPU side of [`Activity`] knows. Stored as a `u8`.
406#[derive(Debug, Clone, Copy, PartialEq, Eq)]
407pub enum CpuState {
408    /// Not sampled: `amont.idleCpuCredit false`, no silence budget, or a
409    /// platform that cannot measure. The silence-only rule applies.
410    Off = 0,
411    /// Sampling, but nothing measured yet (the check has not been quiet
412    /// long enough, or it has only a baseline).
413    Waiting = 1,
414    /// Consecutive complete snapshots are being compared.
415    Measuring = 2,
416    /// The last snapshot was incomplete; the next complete one re-baselines.
417    Unavailable = 3,
418}
419
420/// What a spawned command has been doing, shared between the reader threads
421/// that see its bytes, the CPU sampler, the wait loop that judges it, and the
422/// progress displays. Every field is an atomic offset from `base`, so the
423/// 80 ms repaint and the 25 ms wait loop read it without a lock.
424///
425/// Two clocks, kept apart on purpose: `last_out` is when it last WROTE (what
426/// the messages report as "last output"), `last_busy` is the start of the
427/// last window in which its process tree did measurable CPU work. The kill
428/// decision uses the later of the two ([`Activity::still_for`]).
429pub struct Activity {
430    base: std::time::Instant,
431    last_out: std::sync::atomic::AtomicU64,
432    last_busy: std::sync::atomic::AtomicU64,
433    /// Offset + 1 of the start of the current unbroken run of complete
434    /// measurements; 0 when there is none.
435    measured_since: std::sync::atomic::AtomicU64,
436    /// Offset + 1 of the end of the last complete window; 0 when none.
437    last_measured: std::sync::atomic::AtomicU64,
438    rate_milli: std::sync::atomic::AtomicU32,
439    interval_ms: std::sync::atomic::AtomicU32,
440    cpu: std::sync::atomic::AtomicU8,
441}
442
443impl Activity {
444    pub fn new() -> std::sync::Arc<Activity> {
445        std::sync::Arc::new(Activity {
446            base: std::time::Instant::now(),
447            last_out: Default::default(),
448            last_busy: Default::default(),
449            measured_since: Default::default(),
450            last_measured: Default::default(),
451            rate_milli: Default::default(),
452            interval_ms: Default::default(),
453            cpu: std::sync::atomic::AtomicU8::new(CpuState::Off as u8),
454        })
455    }
456    fn offset(&self, at: std::time::Instant) -> u64 {
457        u64::try_from(at.saturating_duration_since(self.base).as_nanos()).unwrap_or(u64::MAX)
458    }
459    fn now(&self) -> u64 {
460        self.offset(std::time::Instant::now())
461    }
462    fn since(&self, offset: u64) -> std::time::Duration {
463        std::time::Duration::from_nanos(self.now().saturating_sub(offset))
464    }
465    /// It wrote something.
466    pub fn touch(&self) {
467        self.last_out
468            .fetch_max(self.now(), std::sync::atomic::Ordering::Relaxed);
469    }
470    /// How long since it last wrote a byte.
471    pub fn quiet_for(&self) -> std::time::Duration {
472        self.since(self.last_out.load(std::sync::atomic::Ordering::Relaxed))
473    }
474    /// How long it has been BOTH silent and idle on CPU — the number the
475    /// silence budget is judged against. Equals [`Activity::quiet_for`]
476    /// whenever CPU is not sampled.
477    pub fn still_for(&self) -> std::time::Duration {
478        let busy = self.last_busy.load(std::sync::atomic::Ordering::Relaxed);
479        self.quiet_for().min(self.since(busy))
480    }
481    pub fn cpu_state(&self) -> CpuState {
482        match self.cpu.load(std::sync::atomic::Ordering::Relaxed) {
483            1 => CpuState::Waiting,
484            2 => CpuState::Measuring,
485            3 => CpuState::Unavailable,
486            _ => CpuState::Off,
487        }
488    }
489    fn set_cpu_state(&self, s: CpuState) {
490        self.cpu
491            .store(s as u8, std::sync::atomic::Ordering::Relaxed);
492    }
493    /// The last measured rate in thousandths of a core, while it is fresh:
494    /// `None` once two sampling intervals have passed without a complete
495    /// window, so a display never keeps showing "busy" on stale data.
496    pub fn fresh_rate(&self) -> Option<u32> {
497        if self.cpu_state() != CpuState::Measuring {
498            return None;
499        }
500        let end = self
501            .last_measured
502            .load(std::sync::atomic::Ordering::Relaxed);
503        if end == 0 {
504            return None;
505        }
506        let every = u64::from(self.interval_ms.load(std::sync::atomic::Ordering::Relaxed));
507        let stale = std::time::Duration::from_millis(2 * every.max(1));
508        (self.since(end - 1) <= stale)
509            .then(|| self.rate_milli.load(std::sync::atomic::Ordering::Relaxed))
510    }
511    /// What the CPU side can honestly say at a kill. "Measured idle" names
512    /// the unbroken span of complete measurements it rests on — sampling
513    /// starts only after a stretch of silence, so that span is always shorter
514    /// than the silence itself, and nothing is claimed about the rest.
515    pub fn verdict(&self) -> CpuVerdict {
516        match self.cpu_state() {
517            CpuState::Off => return CpuVerdict::NotSampled,
518            CpuState::Waiting | CpuState::Unavailable => return CpuVerdict::Unmeasured,
519            CpuState::Measuring => {}
520        }
521        if let Some(rate) = self.fresh_rate().filter(|r| *r >= BUSY_MILLI_CORES) {
522            return CpuVerdict::BusyAtKill(rate);
523        }
524        let since = self
525            .measured_since
526            .load(std::sync::atomic::Ordering::Relaxed);
527        match (since, self.fresh_rate()) {
528            (s, Some(_)) if s != 0 => CpuVerdict::MeasuredIdle(self.since(s - 1).as_secs()),
529            _ => CpuVerdict::Unmeasured,
530        }
531    }
532    /// Feed one sampler observation in. `interval` is the sampling period,
533    /// kept so displays can tell a fresh rate from a stale one.
534    pub fn record(
535        &self,
536        obs: crate::proctree::Observation,
537        at: std::time::Instant,
538        interval: std::time::Duration,
539    ) {
540        use std::sync::atomic::Ordering::Relaxed;
541        self.interval_ms.store(
542            u32::try_from(interval.as_millis()).unwrap_or(u32::MAX),
543            Relaxed,
544        );
545        match obs {
546            crate::proctree::Observation::Baseline => {
547                self.measured_since.store(self.offset(at) + 1, Relaxed);
548                self.set_cpu_state(CpuState::Waiting);
549            }
550            crate::proctree::Observation::Window(w) => {
551                let milli = w.milli_cores();
552                self.rate_milli.store(milli, Relaxed);
553                self.last_measured.store(self.offset(w.end) + 1, Relaxed);
554                if milli >= BUSY_MILLI_CORES {
555                    // The window's START: a burst buys one window, not a
556                    // whole new budget.
557                    self.last_busy.fetch_max(self.offset(w.start), Relaxed);
558                }
559                self.set_cpu_state(CpuState::Measuring);
560            }
561            crate::proctree::Observation::Unmeasured => {
562                self.measured_since.store(0, Relaxed);
563                self.set_cpu_state(CpuState::Unavailable);
564            }
565        }
566    }
567    /// Sampling is on for this command.
568    pub fn enable_cpu(&self) {
569        self.set_cpu_state(CpuState::Waiting);
570    }
571}
572
573/// What the CPU sampler could say when a command was killed.
574#[derive(Debug, Clone, Copy, PartialEq, Eq)]
575pub enum CpuVerdict {
576    /// Not sampled here (knob off, no budget, platform): silence alone.
577    NotSampled,
578    /// An unbroken run of complete measurements, this many seconds long and
579    /// ending at the kill, all under [`BUSY_MILLI_CORES`].
580    MeasuredIdle(u64),
581    /// Sampling was on but could not measure the whole budget.
582    Unmeasured,
583    /// Its tree was measurably busy at the kill, at this many thousandths
584    /// of a core.
585    BusyAtKill(u32),
586}
587
588/// `milli` thousandths of a core as people read it: `~3.9 cores`.
589pub fn cores(milli: u32) -> String {
590    format!("~{}.{} cores", milli / 1000, (milli % 1000) / 100)
591}
592
593/// The deadline for a network PROBE — an `ls-remote` asked before the real
594/// work, not the work itself. Capped at 30s below [`check_timeout`]: a
595/// probe answers in a second or two when the network is there at all, and
596/// a healthy `amont.timeout` of ten minutes is sized for a test suite, not
597/// for deciding whether the remote is reachable. Shrinking `amont.timeout`
598/// below the cap shrinks this too, and `0` keeps meaning no deadline —
599/// somebody who disabled the clock disabled all of it.
600pub fn network_probe_budget(settings: &crate::config::Settings) -> u64 {
601    match check_timeout(settings) {
602        0 => 0,
603        t => t.min(30),
604    }
605}
606
607/// Which clock killed a command.
608#[derive(Debug, Clone, Copy, PartialEq, Eq)]
609pub enum Why {
610    /// The wall-clock ceiling, `amont.timeout`, in seconds.
611    Ceiling(u64),
612    /// The silence budget, `amont.idleTimeout`, in seconds.
613    Silence(u64),
614}
615
616/// A command killed by a clock — what happened, said with enough to tell
617/// "slow" from "stuck", which is the whole reason there are two clocks.
618#[derive(Debug, Clone, Copy)]
619pub struct Killed {
620    pub why: Why,
621    /// How long it had been running.
622    pub ran_secs: u64,
623    /// How long since its last output; `None` when the output was not ours
624    /// to observe (inherited stdio).
625    pub quiet_secs: Option<u64>,
626    /// What its CPU was doing, as far as it was measured.
627    pub cpu: CpuVerdict,
628    /// The silence budget it ran under, in seconds (0 = off). With
629    /// `quiet_secs` it says whether CPU work is what kept a silent command
630    /// alive past that budget.
631    pub idle_secs: u64,
632}
633
634/// What became of a command run under the deadline.
635pub enum Ran {
636    Status(std::process::ExitStatus),
637    /// Killed by a clock; see [`Killed`].
638    TimedOut(Killed),
639}
640
641/// `cmd.status()`, bounded by [`check_timeout`].
642///
643/// Without a bound, one hung tool — a linter deadlocked on a lock file, a
644/// plugin doing network I/O — blocked the commit FOREVER, and it hung inside
645/// the index-fidelity hold: the user's unstaged changes parked in `$GIT_DIR`,
646/// their tree showing staged content only, for as long as they were willing
647/// to wait. The learned response to that is `--no-verify`, permanently —
648/// which disarms every check to escape one.
649///
650/// The kill reaches the direct child only. A grandchild that detached
651/// survives, orphaned — but the COMMIT is no longer hostage to it, which is
652/// the property that matters.
653pub fn status_within(
654    settings: &crate::config::Settings,
655    cmd: &mut Command,
656) -> std::io::Result<Ran> {
657    status_within_secs(cmd, check_timeout(settings))
658}
659
660/// [`status_within`] with an explicit ceiling — the testable seam. The
661/// output is inherited, so nobody sees the bytes and the silence budget
662/// cannot apply; the ceiling is the only clock.
663pub fn status_within_secs(cmd: &mut Command, budget_secs: u64) -> std::io::Result<Ran> {
664    if budget_secs == 0 {
665        return cmd.status().map(Ran::Status);
666    }
667    let mut child = cmd.spawn()?;
668    wait_within(&mut child, budget_secs, 0, None)
669}
670
671/// Spawn `cmd` with both streams piped, hand every chunk to `on_output` as
672/// it arrives, and wait under BOTH clocks — the reader threads are what
673/// make the silence budget observable. The shared runner behind the
674/// streamed, captured and discarded variants.
675fn run_observed(
676    settings: &crate::config::Settings,
677    cmd: &mut Command,
678    on_output: impl Fn(&[u8]) + Send + Sync + 'static,
679) -> std::io::Result<Ran> {
680    cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
681    let activity = Activity::new();
682    let on_output = std::sync::Arc::new(on_output);
683    let mut child = cmd.spawn()?;
684    // The silence is the CHILD's, so its clock starts when the child does:
685    // a spawn that itself took a second on a loaded machine is not a
686    // second the tool spent saying nothing.
687    activity.touch();
688    let mut readers = Vec::new();
689    for pipe in [
690        child
691            .stdout
692            .take()
693            .map(|p| Box::new(p) as Box<dyn std::io::Read + Send>),
694        child
695            .stderr
696            .take()
697            .map(|p| Box::new(p) as Box<dyn std::io::Read + Send>),
698    ]
699    .into_iter()
700    .flatten()
701    {
702        let activity = std::sync::Arc::clone(&activity);
703        let on_output = std::sync::Arc::clone(&on_output);
704        readers.push(std::thread::spawn(move || {
705            let mut pipe = pipe;
706            let mut chunk = [0u8; 4096];
707            loop {
708                match std::io::Read::read(&mut pipe, &mut chunk) {
709                    Ok(0) | Err(_) => break,
710                    Ok(n) => {
711                        activity.touch();
712                        on_output(&chunk[..n]);
713                    }
714                }
715            }
716        }));
717    }
718    // The displays read the same clocks the kill decision does.
719    let _attached = crate::live::current_sink()
720        .map(|(stage, idx)| stage.attach(idx, std::sync::Arc::clone(&activity)));
721    let idle = idle_timeout(settings);
722    let sampler = (idle > 0 && idle_cpu_credit(settings) && crate::proctree::SUPPORTED)
723        .then(|| CpuSampler::start(child.id(), std::sync::Arc::clone(&activity), idle));
724    let ran = wait_within(&mut child, check_timeout(settings), idle, Some(&activity));
725    if let Some(s) = sampler {
726        s.stop();
727    }
728    for r in readers {
729        let _ = r.join();
730    }
731    ran
732}
733
734/// The CPU half of the silence budget (ADR-0008): a thread that, while the
735/// command is silent, snapshots its process tree and records whether it is
736/// doing measurable work. It runs BESIDE the wait loop, never inside it —
737/// the loop only reads what this publishes — so nothing a snapshot does can
738/// delay the ceiling.
739struct CpuSampler {
740    stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
741    handle: std::thread::JoinHandle<()>,
742}
743
744impl CpuSampler {
745    fn start(pid: u32, activity: std::sync::Arc<Activity>, idle_secs: u64) -> CpuSampler {
746        activity.enable_cpu();
747        let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
748        let flag = std::sync::Arc::clone(&stop);
749        let handle = std::thread::Builder::new()
750            .name("amont-cpu".into())
751            .spawn(move || sample_loop(pid, &activity, idle_secs, &flag))
752            .expect("spawn the CPU sampler thread");
753        CpuSampler { stop, handle }
754    }
755
756    /// Ask it to stop and wait a bounded second for it. A snapshot is itself
757    /// bounded, so it always stops sooner; if it somehow did not, it is left
758    /// to finish on its own rather than holding the check.
759    fn stop(self) {
760        self.stop.store(true, std::sync::atomic::Ordering::Relaxed);
761        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1);
762        while !self.handle.is_finished() && std::time::Instant::now() < deadline {
763            std::thread::sleep(std::time::Duration::from_millis(10));
764        }
765        if self.handle.is_finished() {
766            let _ = self.handle.join();
767        }
768    }
769}
770
771/// When sampling starts and how often it repeats, from the silence budget:
772/// quiet for `max(250 ms, min(30 s, budget/3))`, then every
773/// `clamp(budget/4, 250 ms, 10 s)` — so even a one-second budget is sampled
774/// several times before it runs out.
775pub fn sampling_schedule(idle_secs: u64) -> (std::time::Duration, std::time::Duration) {
776    let ms = std::time::Duration::from_millis;
777    let budget = std::time::Duration::from_secs(idle_secs);
778    let first = (budget / 3)
779        .min(std::time::Duration::from_secs(30))
780        .max(ms(250));
781    let every = (budget / 4).clamp(ms(250), std::time::Duration::from_secs(10));
782    (first, every)
783}
784
785fn sample_loop(
786    pid: u32,
787    activity: &Activity,
788    idle_secs: u64,
789    stop: &std::sync::atomic::AtomicBool,
790) {
791    use std::sync::atomic::Ordering::Relaxed;
792    let (first, every) = sampling_schedule(idle_secs);
793    let limits = crate::proctree::Limits::default();
794    let trace = std::env::var_os("AMONT_CPU_TRACE");
795    let mut tracker = crate::proctree::Tracker::default();
796    let nap = |d: std::time::Duration| {
797        let until = std::time::Instant::now() + d;
798        while !stop.load(Relaxed) && std::time::Instant::now() < until {
799            std::thread::sleep(std::time::Duration::from_millis(50));
800        }
801    };
802    while !stop.load(Relaxed) {
803        if activity.quiet_for() < first {
804            // Talking: nothing to prove, and the next quiet stretch starts
805            // from a fresh baseline rather than a stale one.
806            tracker = crate::proctree::Tracker::default();
807            nap(std::time::Duration::from_millis(100));
808            continue;
809        }
810        let snap = crate::proctree::snapshot(pid, &tracker.seen(), &limits);
811        let at = std::time::Instant::now();
812        let traced = trace.as_ref().map(|_| match &snap {
813            crate::proctree::Snapshot::Complete(procs) => procs.clone(),
814            _ => Vec::new(),
815        });
816        let obs = tracker.observe(at, snap);
817        if let (Some(path), Some(procs)) = (&trace, traced) {
818            trace_sample(path, pid, &procs, &obs);
819        }
820        activity.record(obs, at, every);
821        nap(every);
822    }
823}
824
825/// `AMONT_CPU_TRACE=<file>`: append what each sample saw — one
826/// `pid start ppid cpu_ns` line per process of a complete snapshot, then a
827/// `# root <pid> <observation>` line. A diagnostic, and the seam the timing
828/// tests use to know a worker was seen before they orphan it (they match
829/// the leading pid).
830fn trace_sample(
831    path: &std::ffi::OsStr,
832    root: u32,
833    procs: &[crate::proctree::Proc],
834    obs: &crate::proctree::Observation,
835) {
836    use std::io::Write;
837    let Ok(mut f) = std::fs::OpenOptions::new()
838        .create(true)
839        .append(true)
840        .open(path)
841    else {
842        return;
843    };
844    let mut text = String::new();
845    for p in procs {
846        text.push_str(&format!(
847            "{} {} {} {}\n",
848            p.id.pid, p.id.start, p.ppid, p.cpu_ns
849        ));
850    }
851    let obs = match obs {
852        crate::proctree::Observation::Window(w) => {
853            format!("window {} milli-cores", w.milli_cores())
854        }
855        other => format!("{other:?}").to_lowercase(),
856    };
857    text.push_str(&format!("# root {root} {obs}\n"));
858    let _ = f.write_all(text.as_bytes());
859}
860
861/// [`status_within`], with the child's stdout and stderr CAPTURED into the
862/// calling check's slot instead of inherited — the other half of one-check-
863/// one-block: a linter's twelve lines used to land on the shared terminal
864/// between two other checks' lines. Falls back to plain [`status_within`]
865/// when no slot is installed on this thread (`amont.progress false`, or a
866/// spawn outside a stage), which is byte-for-byte the old behaviour.
867///
868/// stdout and stderr merge in ARRIVAL order inside the block, which is what
869/// the terminal showed before. The readers are threads, not processes, and
870/// they are joined before the status is returned so a block can never grow
871/// after its check finished.
872pub fn status_streamed(
873    settings: &crate::config::Settings,
874    cmd: &mut Command,
875) -> std::io::Result<Ran> {
876    let Some((stage, idx)) = crate::live::current_sink() else {
877        return status_within(settings, cmd);
878    };
879    if crate::live::watching() {
880        // The block lands on a real terminal but the tool sees a pipe and
881        // would strip its colors; the big three opt-in knobs put them back.
882        cmd.env("FORCE_COLOR", "1")
883            .env("CLICOLOR_FORCE", "1")
884            .env("CARGO_TERM_COLOR", "always");
885    }
886    run_observed(settings, cmd, move |bytes| stage.append_raw(idx, bytes))
887}
888
889/// Run to completion under the `amont.timeout` deadline with stdout and
890/// stderr CAPTURED into a string the caller can parse — what the audit
891/// checks need: their verdict lives in the tool's output, not its exit
892/// code alone. Arrival-ordered merge of both streams, like
893/// [`status_streamed`]'s blocks. `None` when the child cannot be spawned.
894pub fn capture_within(
895    settings: &crate::config::Settings,
896    cmd: &mut Command,
897) -> Option<(Ran, String)> {
898    let text = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
899    let sink = std::sync::Arc::clone(&text);
900    let ran = run_observed(settings, cmd, move |bytes| {
901        sink.lock()
902            .unwrap_or_else(|p| p.into_inner())
903            .push_str(&String::from_utf8_lossy(bytes));
904    })
905    .ok()?;
906    let text = std::sync::Arc::try_unwrap(text)
907        .map(|m| m.into_inner().unwrap_or_else(|p| p.into_inner()))
908        .unwrap_or_default();
909    Some((ran, text))
910}
911
912/// The two-clock wait over an already-spawned child — shared by every
913/// runner. `wall_secs` is the ceiling, `idle_secs` the silence budget; each
914/// `0` means that clock is off, and the silence budget is also off when
915/// there is no [`Activity`] to consult (inherited stdio).
916pub(crate) fn wait_within(
917    child: &mut std::process::Child,
918    wall_secs: u64,
919    idle_secs: u64,
920    activity: Option<&Activity>,
921) -> std::io::Result<Ran> {
922    let started = std::time::Instant::now();
923    let ceiling = (wall_secs > 0).then(|| started + std::time::Duration::from_secs(wall_secs));
924    let silence = match activity {
925        Some(_) if idle_secs > 0 => Some(std::time::Duration::from_secs(idle_secs)),
926        _ => None,
927    };
928    if ceiling.is_none() && silence.is_none() {
929        return child.wait().map(Ran::Status);
930    }
931    loop {
932        if let Some(status) = child.try_wait()? {
933            return Ok(Ran::Status(status));
934        }
935        let now = std::time::Instant::now();
936        // Judged on "silent AND idle on CPU"; equal to plain silence when CPU
937        // is not sampled.
938        let quiet = activity.map(|a| a.still_for());
939        let why = judge(
940            now.duration_since(started),
941            quiet,
942            ceiling.map(|_| wall_secs),
943            silence.map(|_| idle_secs),
944        );
945        if let Some(why) = why {
946            let _ = child.kill();
947            let _ = child.wait();
948            return Ok(Ran::TimedOut(Killed {
949                why,
950                ran_secs: now.duration_since(started).as_secs(),
951                // The true OUTPUT silence, for the message — not the
952                // still-time the verdict was judged on.
953                quiet_secs: activity.map(|a| a.quiet_for().as_secs()),
954                cpu: activity.map_or(CpuVerdict::NotSampled, Activity::verdict),
955                idle_secs,
956            }));
957        }
958        std::thread::sleep(std::time::Duration::from_millis(25));
959    }
960}
961
962/// Which clock, if any, has fired — the decision, with no process or
963/// clock of its own so it can be tested to the second.
964///
965/// `ran` is how long the command has been running; `quiet` how long since
966/// it last wrote, `None` when nobody is watching its output. `ceiling` and
967/// `silence` are the two budgets in seconds, `None` when that clock is off.
968/// The ceiling wins when both have fired: it is the larger claim, and the
969/// message for it carries the silence figure anyway.
970pub fn judge(
971    ran: std::time::Duration,
972    quiet: Option<std::time::Duration>,
973    ceiling: Option<u64>,
974    silence: Option<u64>,
975) -> Option<Why> {
976    if let Some(wall) = ceiling {
977        if ran >= std::time::Duration::from_secs(wall) {
978            return Some(Why::Ceiling(wall));
979        }
980    }
981    if let (Some(idle), Some(q)) = (silence, quiet) {
982        if q >= std::time::Duration::from_secs(idle) {
983            return Some(Why::Silence(idle));
984        }
985    }
986    None
987}
988
989/// Say a command was killed, by which clock, and what that tells you.
990///
991/// The two clocks exist to answer two different questions, so the message
992/// answers the one that was asked: silence means stuck — look at the tool;
993/// the ceiling with recent output means slow — raise the ceiling.
994pub fn say_timed_out(what: &str, k: Killed) {
995    match k.why {
996        Why::Silence(budget) => {
997            fail(&match k.cpu {
998                CpuVerdict::MeasuredIdle(covered) => format!(
999                    "{} printed nothing for {} and did no measurable CPU work (< 0.1 core) in \
1000                     the last {} of it; killed after {} — a tool this idle is stuck, not slow. \
1001                     {} raises the silence budget (0 disables)",
1002                    hl(what),
1003                    human_secs(budget),
1004                    human_secs(covered.max(1)),
1005                    human_secs(k.ran_secs),
1006                    hl("git config amont.idleTimeout <secs>")
1007                ),
1008                _ => {
1009                    format!(
1010                "{} printed nothing for {}{} and was killed after {} — a tool this quiet is \
1011                 usually stuck, not slow. {} raises the silence budget (0 disables)",
1012                hl(what),
1013                human_secs(budget),
1014                if k.cpu == CpuVerdict::Unmeasured { " (CPU not measured)" } else { "" },
1015                human_secs(k.ran_secs),
1016                hl("git config amont.idleTimeout <secs>")
1017            )
1018                }
1019            })
1020        }
1021        Why::Ceiling(budget) => {
1022            let verdict = match (k.quiet_secs, k.cpu) {
1023                (Some(q), CpuVerdict::BusyAtKill(m)) if k.idle_secs > 0 && q >= k.idle_secs => {
1024                    format!(
1025                        " It printed nothing for the last {} but kept its CPU busy ({}), so the \
1026                     silence budget did not stop it: a busy loop, or a tool that prints only \
1027                     at the end (give it a per-file reporter). {} kills quiet runs at the \
1028                     silence budget whatever their CPU.",
1029                        human_secs(q),
1030                        cores(m),
1031                        hl("git config amont.idleCpuCredit false")
1032                    )
1033                }
1034                (Some(q), _) if q < 30 => format!(
1035                    " It was still printing ({} since its last line): slow, not stuck.",
1036                    human_secs(q)
1037                ),
1038                (Some(q), _) => format!(" Its last output was {} ago.", human_secs(q)),
1039                (None, _) => String::new(),
1040            };
1041            fail(&format!(
1042                "{} timed out: ran for {} and was killed at the ceiling. {} raises it \
1043                 (0 disables).{verdict}",
1044                hl(what),
1045                human_secs(budget),
1046                hl("git config amont.timeout <secs>")
1047            ))
1048        }
1049    }
1050}
1051
1052/// [`status_within`], collapsed to "did it exit 0" — the shape the one-shot
1053/// tool spawns want. A timeout says so, names `what`, and reads as failure.
1054pub fn bounded_success(settings: &crate::config::Settings, cmd: &mut Command, what: &str) -> bool {
1055    match status_streamed(settings, cmd) {
1056        Ok(Ran::Status(s)) => s.success(),
1057        Ok(Ran::TimedOut(b)) => {
1058            say_timed_out(what, b);
1059            false
1060        }
1061        Err(_) => false,
1062    }
1063}
1064
1065/// Run `argv` from `root`, inheriting stdio. True when it exits 0.
1066pub fn run(
1067    settings: &crate::config::Settings,
1068    root: &str,
1069    argv: &[String],
1070    extra: &[String],
1071) -> bool {
1072    let Some((program, rest)) = argv.split_first() else {
1073        return true;
1074    };
1075    let mut cmd = Command::new(program);
1076    cmd.args(rest)
1077        .args(extra)
1078        .current_dir(root)
1079        .stdin(Stdio::null());
1080    strip_git_env(&mut cmd);
1081    bounded_success(settings, &mut cmd, program)
1082}
1083
1084/// As [`run`], but with the tool's own output discarded.
1085///
1086/// For a pass whose only job is to decide something — prettier's `--check`,
1087/// ruff's `--fix` sweep — where the offenders are printed once, by the pass
1088/// that reports them, rather than twice.
1089pub fn run_quiet(
1090    settings: &crate::config::Settings,
1091    root: &str,
1092    argv: &[String],
1093    extra: &[String],
1094) -> bool {
1095    let Some((program, rest)) = argv.split_first() else {
1096        return true;
1097    };
1098    let mut cmd = Command::new(program);
1099    cmd.args(rest)
1100        .args(extra)
1101        .current_dir(root)
1102        .stdin(Stdio::null());
1103    strip_git_env(&mut cmd);
1104    // Deliberately NOT the streamed runner: this helper's contract is that
1105    // the output is discarded, and capture would resurrect it into the
1106    // block. Observed and dropped instead of `/dev/null`, so the silence
1107    // clock still sees whether the tool is alive.
1108    match run_observed(settings, &mut cmd, |_| {}) {
1109        Ok(Ran::Status(s)) => s.success(),
1110        Ok(Ran::TimedOut(b)) => {
1111            say_timed_out(program, b);
1112            false
1113        }
1114        Err(_) => false,
1115    }
1116}
1117
1118/// Whether the user asked for checks to repair what they find.
1119///
1120/// OFF by default. `git config amont.fix true` turns it on, per repository,
1121/// because a hook that edits your files without being asked is a larger
1122/// surprise than one that complains — and because with index fidelity in place
1123/// the repair lands in the commit you are making, which is a bigger claim to
1124/// make on somebody's behalf than printing an error.
1125pub fn fixing_enabled(settings: &crate::config::Settings) -> bool {
1126    // Never while the file set is not the index — see `NOT_THE_INDEX`.
1127    !not_the_index() && fixing_requested(settings)
1128}
1129
1130/// What the CONFIG says, ignoring whether the current run may act on it.
1131///
1132/// Split out so `run_all` can tell the difference between "fixing is off" and
1133/// "you asked for fixing and this mode will not do it", and say the second out
1134/// loud instead of silently ignoring the key.
1135pub fn fixing_requested(settings: &crate::config::Settings) -> bool {
1136    *settings
1137        .fixing
1138        .get_or_init(|| crate::config::boolean_or(settings, "amont.fix", false))
1139}
1140
1141/// What a re-stage actually did. THREE answers, because the old `bool`
1142/// conflated two of them and the conflation shipped unformatted code.
1143///
1144/// `prettier.rs` read `if run_quiet(write) && restage(&files) { … Fixed }`. When
1145/// `git add` FAILED, `restage` returned `false` — indistinguishable from
1146/// "nothing needed staging" — so control fell through to a second `--check`
1147/// pass, which inspected the NOW-FORMATTED WORKING TREE, passed, printed
1148/// "Prettier passed" and returned `Outcome::Passed`. The index still held the
1149/// unformatted content, so the commit contained unformatted code and the hook
1150/// said it had passed. `manifest.rs` had the same shape.
1151#[derive(Debug, Clone, PartialEq, Eq)]
1152pub enum Restaged {
1153    /// No path differed from the index — nothing to do, and nothing wrong.
1154    Nothing,
1155    /// `git add` succeeded; the index now holds the repair.
1156    Staged,
1157    /// `git add` failed, carrying the paths it could not stage. The index
1158    /// holds content the fixer has already replaced on disk, so this MUST be
1159    /// loud at every call site — and naming the files is the difference
1160    /// between a message somebody can act on and one they cannot.
1161    Failed(Vec<String>),
1162}
1163
1164/// Serialises this process's own `git add` calls.
1165///
1166/// pre-commit runs its checks concurrently (`dispatch.rs`), and up to three of
1167/// them can re-stage. git takes `$GIT_DIR/index.lock` exclusively, so two
1168/// concurrent `git add`s in the same repository make one of them fail — which,
1169/// before `Restaged`, was silently read as "nothing moved". Holding this across
1170/// the `git add` removes self-contention entirely; the retry below is only for
1171/// OTHER processes.
1172static INDEX_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1173
1174/// Re-stage exactly the paths a fixer rewrote, and say what happened.
1175///
1176/// Safe ONLY because the pre-commit stage holds unstaged changes aside: the
1177/// tree contains the staged content and nothing else, so anything a formatter
1178/// touched is by definition part of this commit. Without that, re-staging would
1179/// sweep in work the author deliberately kept back.
1180pub fn restage(paths: &[String]) -> Restaged {
1181    // Belt and braces alongside `fixing_enabled`: a future fixer that forgets
1182    // the gate still cannot turn `amont run --all-files` into `git add .`.
1183    if not_the_index() {
1184        return Restaged::Nothing;
1185    }
1186    let changed: Vec<String> = paths
1187        .iter()
1188        .filter(|p| !git::succeeds(&["diff", "--quiet", "--", p]))
1189        .cloned()
1190        .collect();
1191    if changed.is_empty() {
1192        return Restaged::Nothing;
1193    }
1194    let mut args = vec!["add", "--"];
1195    args.extend(changed.iter().map(String::as_str));
1196
1197    let _serialised = INDEX_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1198    // Another PROCESS can hold `index.lock` — a `git status` from an editor, a
1199    // second hook in a linked worktree. Back off and retry rather than
1200    // reporting a transient collision as a failed repair. `git add` of the same
1201    // paths is idempotent: it records the paths' current worktree content, so
1202    // running it twice records the same thing twice and cannot double-stage.
1203    const BACKOFF_MS: [u64; 3] = [50, 150, 400];
1204    if git::succeeds(&args) {
1205        return Restaged::Staged;
1206    }
1207    for wait in BACKOFF_MS {
1208        std::thread::sleep(std::time::Duration::from_millis(wait));
1209        if git::succeeds(&args) {
1210            return Restaged::Staged;
1211        }
1212    }
1213    Restaged::Failed(changed)
1214}
1215
1216/// A check passed. THE funnel for every success line, which is what lets
1217/// `amont.quiet` swallow them in one place — see [`crate::live::quiet`].
1218pub fn ok(settings: &crate::config::Settings, msg: &str) {
1219    if crate::live::quiet(settings) {
1220        return;
1221    }
1222    crate::live::say(&format!("{} {msg}", valid_sign()));
1223}
1224pub fn fail(msg: &str) {
1225    crate::live::say(&format!("{} {msg}", error_sign()));
1226}
1227pub fn warn(msg: &str) {
1228    crate::live::say(&format!("{} {msg}", warning_sign()));
1229}
1230/// A line with no sign of its own — what a check's direct `println!` becomes,
1231/// so it lands in the check's block instead of interleaving. See `live::say`.
1232pub fn say(msg: &str) {
1233    crate::live::say(msg);
1234}
1235
1236/// Orange, for the fragments these hooks highlight.
1237pub fn hl(s: &str) -> String {
1238    crate::ui::highlight(s)
1239}
1240
1241#[cfg(test)]
1242mod tests {
1243
1244    /// A window of `milli` thousandths of a core between `start` and `end`
1245    /// (a zero-length window still reports `milli`: `milli_cores` floors the
1246    /// wall time at 1 ns).
1247    fn window(
1248        start: std::time::Instant,
1249        end: std::time::Instant,
1250        milli: u64,
1251    ) -> crate::proctree::Observation {
1252        let wall = u64::try_from(end.duration_since(start).as_nanos())
1253            .unwrap_or(u64::MAX)
1254            .max(1);
1255        let gain = wall * milli / 1000;
1256        crate::proctree::Observation::Window(crate::proctree::Window {
1257            start,
1258            end,
1259            gain_ns: gain,
1260        })
1261    }
1262
1263    /// Even the shortest budget is sampled several times before it runs out,
1264    /// and the default one starts at 30 s and repeats every 10 s.
1265    #[test]
1266    fn the_sampling_schedule_follows_the_budget_within_floors() {
1267        use super::sampling_schedule;
1268        let ms = std::time::Duration::from_millis;
1269        let third = |secs: u64| std::time::Duration::from_secs(secs) / 3;
1270        assert_eq!(sampling_schedule(1), (third(1), ms(250)));
1271        assert_eq!(sampling_schedule(2), (third(2), ms(500)));
1272        assert_eq!(sampling_schedule(120), (ms(30_000), ms(10_000)));
1273        assert_eq!(sampling_schedule(0), (ms(250), ms(250)));
1274    }
1275
1276    /// Without CPU data the still-time IS the silence; a busy window pulls it
1277    /// back to the window's start, and the output clock is left alone.
1278    #[test]
1279    fn busy_work_resets_the_still_time_but_not_the_output_silence() {
1280        use super::Activity;
1281        let a = Activity::new();
1282        a.touch();
1283        std::thread::sleep(std::time::Duration::from_millis(60));
1284        let quiet = a.quiet_for();
1285        assert!(a.still_for() >= quiet.saturating_sub(std::time::Duration::from_millis(5)));
1286        a.enable_cpu();
1287        let now = std::time::Instant::now();
1288        a.record(
1289            window(now - std::time::Duration::from_millis(10), now, 2000),
1290            now,
1291            std::time::Duration::from_secs(1),
1292        );
1293        assert!(a.still_for() < std::time::Duration::from_millis(40));
1294        assert!(a.quiet_for() >= std::time::Duration::from_millis(60));
1295    }
1296
1297    /// What the kill message may claim. "Measured idle" names the unbroken
1298    /// span of complete measurements behind it; a broken run is unmeasured;
1299    /// a fresh busy window is reported as busy; no sampling says nothing.
1300    #[test]
1301    fn the_cpu_verdict_claims_only_what_was_measured() {
1302        use super::{Activity, CpuVerdict};
1303        use crate::proctree::Observation;
1304        let s = std::time::Duration::from_secs;
1305        let every = s(10);
1306
1307        let off = Activity::new();
1308        assert_eq!(off.verdict(), CpuVerdict::NotSampled);
1309
1310        let waiting = Activity::new();
1311        waiting.enable_cpu();
1312        assert_eq!(waiting.verdict(), CpuVerdict::Unmeasured);
1313
1314        let now = std::time::Instant::now();
1315        let before = now - s(1);
1316        // A real second of complete, idle measurement: the claim names it.
1317        let idle = Activity::new();
1318        idle.enable_cpu();
1319        let t0 = std::time::Instant::now();
1320        idle.record(Observation::Baseline, t0, every);
1321        std::thread::sleep(std::time::Duration::from_millis(1050));
1322        let t1 = std::time::Instant::now();
1323        idle.record(window(t0, t1, 20), t1, every);
1324        assert_eq!(idle.verdict(), CpuVerdict::MeasuredIdle(1));
1325
1326        let busy = Activity::new();
1327        busy.enable_cpu();
1328        busy.record(Observation::Baseline, before, every);
1329        busy.record(window(before, now, 3900), now, every);
1330        assert_eq!(busy.verdict(), CpuVerdict::BusyAtKill(3900));
1331
1332        let broken = Activity::new();
1333        broken.enable_cpu();
1334        broken.record(Observation::Baseline, before, every);
1335        broken.record(window(before, now, 20), now, every);
1336        broken.record(Observation::Unmeasured, now, every);
1337        assert_eq!(broken.verdict(), CpuVerdict::Unmeasured);
1338    }
1339
1340    /// A rate older than two sampling intervals is not shown, and cannot
1341    /// back a "busy" or "idle" claim.
1342    #[test]
1343    fn a_stale_rate_expires() {
1344        use super::{Activity, CpuVerdict};
1345        use crate::proctree::Observation;
1346        let a = Activity::new();
1347        a.enable_cpu();
1348        let then = std::time::Instant::now();
1349        let tick = std::time::Duration::from_millis(5);
1350        let earlier = then - std::time::Duration::from_secs(1);
1351        a.record(Observation::Baseline, earlier, tick);
1352        a.record(window(earlier, then, 3000), then, tick);
1353        std::thread::sleep(std::time::Duration::from_millis(40));
1354        assert_eq!(a.fresh_rate(), None);
1355        assert_eq!(a.verdict(), CpuVerdict::Unmeasured);
1356    }
1357
1358    #[test]
1359    fn cores_read_as_one_decimal() {
1360        assert_eq!(super::cores(3900), "~3.9 cores");
1361        assert_eq!(super::cores(420), "~0.4 cores");
1362        assert_eq!(super::cores(100), "~0.1 cores");
1363    }
1364
1365    /// The two clocks, decided to the second. A chatty command outlives any
1366    /// silence budget however long it runs; a silent one dies at the budget
1367    /// however short; a command nobody watches answers to the ceiling only.
1368    #[test]
1369    fn the_clocks_judge_silence_and_ceiling_separately() {
1370        use super::{judge, Why};
1371        use std::time::Duration as D;
1372        let s = D::from_secs;
1373        // Chatty and long: past a five-second silence budget, still fine.
1374        assert_eq!(judge(s(900), Some(s(0)), Some(3600), Some(5)), None);
1375        assert_eq!(judge(s(900), Some(s(4)), Some(3600), Some(5)), None);
1376        // Silent for the budget: killed, and the silence is blamed.
1377        assert_eq!(
1378            judge(s(30), Some(s(5)), Some(3600), Some(5)),
1379            Some(Why::Silence(5))
1380        );
1381        // Unobserved output: the silence clock cannot run at all.
1382        assert_eq!(judge(s(900), None, Some(3600), Some(5)), None);
1383        // The ceiling fires on elapsed time whatever the output is doing.
1384        assert_eq!(
1385            judge(s(3600), Some(s(0)), Some(3600), Some(120)),
1386            Some(Why::Ceiling(3600))
1387        );
1388        // Both fired at once: the ceiling is the answer.
1389        assert_eq!(
1390            judge(s(3600), Some(s(600)), Some(3600), Some(120)),
1391            Some(Why::Ceiling(3600))
1392        );
1393        // Both off: nothing ever fires.
1394        assert_eq!(judge(s(86_400), Some(s(86_400)), None, None), None);
1395        // Only silence on: no ceiling, however long it runs.
1396        assert_eq!(judge(s(86_400), Some(s(1)), None, Some(120)), None);
1397    }
1398
1399    /// The deadline kills what outlives it and reports what finished.
1400    #[cfg(unix)]
1401    #[test]
1402    fn the_deadline_kills_a_sleeper_and_spares_a_finisher() {
1403        let started = std::time::Instant::now();
1404        let mut slow = Command::new(program("sleep"));
1405        slow.arg("300").stdin(Stdio::null());
1406        match status_within_secs(&mut slow, 1) {
1407            Ok(Ran::TimedOut(Killed {
1408                why: Why::Ceiling(1),
1409                quiet_secs: None,
1410                ..
1411            })) => {}
1412            other => panic!("expected TimedOut(1), got {:?}", other.map(|_| "ran")),
1413        }
1414        assert!(
1415            started.elapsed() < std::time::Duration::from_secs(60),
1416            "the kill did not happen at the deadline"
1417        );
1418
1419        let mut quick = Command::new(program("true"));
1420        quick.stdin(Stdio::null());
1421        match status_within_secs(&mut quick, 60) {
1422            Ok(Ran::Status(s)) => assert!(s.success()),
1423            other => panic!("expected a clean exit, got {:?}", other.map(|_| "?")),
1424        }
1425    }
1426
1427    use super::*;
1428
1429    #[test]
1430    fn which_finds_a_real_binary_and_not_a_fake_one() {
1431        assert!(which("git").is_some());
1432        assert!(which("definitely-not-a-real-binary-xyz").is_none());
1433    }
1434
1435    /// On Windows a tool can exist BOTH as an extensionless shell script and as
1436    /// a .cmd/.exe in the same directory; only the latter is executable by
1437    /// CreateProcess, so the extension forms must win.
1438    #[test]
1439    #[cfg(windows)]
1440    fn windows_prefers_an_executable_extension_over_a_bare_file() {
1441        let dir = std::env::temp_dir().join("amont-which-order");
1442        let _ = std::fs::create_dir_all(&dir);
1443        std::fs::write(dir.join("faketool"), "#!/bin/sh\n").unwrap();
1444        std::fs::write(dir.join("faketool.cmd"), "@echo off\n").unwrap();
1445        // The path is PASSED, never installed into this process: see
1446        // `which_on`. The old spelling swapped the real PATH out from under
1447        // every other test in this binary for the length of the call.
1448        let found = which_on(dir.as_os_str(), "faketool").unwrap();
1449        assert!(found.ends_with(".cmd"), "got {found}");
1450        let _ = std::fs::remove_dir_all(&dir);
1451    }
1452
1453    /// "Nothing moved" and "`git add` FAILED" are different answers, and the
1454    /// old `bool` gave the same one for both.
1455    ///
1456    /// That conflation is what shipped unformatted code: `prettier.rs` read
1457    /// `if wrote && restage(&files)`, so a failed `git add` fell through to a
1458    /// second `--check` against the now-formatted WORKING TREE, which passed —
1459    /// while the INDEX still held the unformatted content the commit would
1460    /// carry.
1461    ///
1462    /// An absolute path outside any repository is a `git add` git will always
1463    /// refuse, which is the only way to reach the failing branch without
1464    /// sabotaging a real index.
1465    #[test]
1466    fn restage_distinguishes_nothing_from_failure() {
1467        // `restage` runs `git add` in the PROCESS cwd, so this test depends
1468        // on that cwd as surely as one that moves it — see `crate::TEST_CWD`.
1469        // Without the lock it ran inside whatever fixture `gate_stamp` had
1470        // moved into, and took that repository's index.lock out from under
1471        // its own commit.
1472        let _cwd = crate::TEST_CWD.lock().unwrap_or_else(|p| p.into_inner());
1473        let outside = std::env::temp_dir()
1474            .join("amont-restage-outside-any-repo")
1475            .to_string_lossy()
1476            .into_owned();
1477        assert_eq!(
1478            restage(std::slice::from_ref(&outside)),
1479            Restaged::Failed(vec![outside]),
1480            "a `git add` git refuses must report Failed, never Nothing"
1481        );
1482        assert_eq!(
1483            restage(&[]),
1484            Restaged::Nothing,
1485            "no paths is nothing to do, and nothing wrong"
1486        );
1487    }
1488
1489    /// No check may hand `Command` a bare program name.
1490    ///
1491    /// `Command::new` does NO PATHEXT resolution, so `Command::new("npm")`
1492    /// cannot execute `npm.cmd` and `Command::new("uvx")` cannot execute
1493    /// `uvx.exe`: the spawn fails with "program not found" and a
1494    /// `Severity::Block` check reports an installed tool as broken. That is the
1495    /// incident `program()` exists for, and it kept recurring — `yamllint` and
1496    /// three sites in `python_tools` were still doing it, THREE OF THEM after
1497    /// `which()` had already succeeded and discarded the answer.
1498    ///
1499    /// A source scan rather than a runtime assertion because the failure only
1500    /// reproduces on Windows, and the whole point is to catch the next one on
1501    /// every platform. Comment lines are skipped: `program()`'s own doc quotes
1502    /// the offending call. The needle is assembled from two pieces so this
1503    /// module — which the scan also reads — does not match itself.
1504    #[test]
1505    fn no_hook_spawns_a_bare_program_name() {
1506        let needle = concat!("Command", "::new(");
1507        let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/src/hooks");
1508        let mut scanned = 0usize;
1509        for entry in std::fs::read_dir(dir).expect("hooks dir").flatten() {
1510            let path = entry.path();
1511            if path.extension().and_then(|e| e.to_str()) != Some("rs") {
1512                continue;
1513            }
1514            scanned += 1;
1515            let src = std::fs::read_to_string(&path).expect("read a hook module");
1516            for (n, line) in src.lines().enumerate() {
1517                if line.trim_start().starts_with("//") {
1518                    continue;
1519                }
1520                let Some(after) = line.split_once(needle) else {
1521                    continue;
1522                };
1523                assert!(
1524                    !after.1.starts_with('"'),
1525                    "{}:{} spawns a bare name — route it through `program()` or \
1526                     the path `which()` already resolved: {}",
1527                    path.display(),
1528                    n + 1,
1529                    line.trim()
1530                );
1531            }
1532        }
1533        assert!(
1534            scanned > 10,
1535            "the scan found almost nothing: {scanned} files"
1536        );
1537    }
1538
1539    #[test]
1540    fn first_existing_picks_the_earliest_present_name() {
1541        let dir = std::env::temp_dir().join("amont-first-existing-test");
1542        let _ = std::fs::create_dir_all(&dir);
1543        let root = dir.to_string_lossy().into_owned();
1544        let _ = std::fs::write(dir.join("second"), "x");
1545        assert_eq!(
1546            first_existing(&root, &["first", "second", "third"]).as_deref(),
1547            Some("second")
1548        );
1549        assert_eq!(first_existing(&root, &["nope"]), None);
1550        let _ = std::fs::remove_dir_all(&dir);
1551    }
1552}