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