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