amont_runtime/hooks/common.rs
1//! Shared plumbing for the linter-orchestration hooks.
2//!
3//! Nine of them do the same four things: collect staged files of some kind,
4//! bail out if there are none, resolve a tool, run it. In shell that was ~65
5//! lines apiece, mostly duplicated; here it is a handful of helpers and each
6//! hook keeps only what is actually specific to it.
7
8use crate::git;
9use crate::ui::{error_sign, valid_sign, warning_sign};
10use std::path::Path;
11use std::process::{Command, Stdio};
12use std::sync::OnceLock;
13
14/// Staged files, deletions excluded, whose name ends with one of `exts`.
15/// The file set every check asks about, when it is not the staged one.
16///
17/// Set at most once, before any check runs, by `amont run --all-files`. A
18/// process-level override rather than a parameter because a check's signature
19/// is `(&[OsString])` — it never sees a `Ctx` — and threading a file set
20/// through twenty of them to serve one mode would be a worse trade than a
21/// value that is written once and read many times.
22///
23/// Same shape as `PushRefs`: read once, lent to every check that asks.
24static OVERRIDE: OnceLock<Vec<String>> = OnceLock::new();
25
26/// Set once the file set stops being the index.
27///
28/// `restage`'s own doc says what makes re-staging safe: the pre-commit stage
29/// holds the unstaged changes aside, so the tree contains the staged content
30/// and nothing else, and anything a formatter touched is by definition part of
31/// this commit. `amont run --all-files` replaces the file set with every
32/// tracked path — which is that precondition being FALSE.
33///
34/// With `amont.fix true`, every fixer's `restage(&files)` would then `git
35/// add` everything in the working tree that differs from the index, turning a
36/// read-only "does my tree pass" query into `git add .`. That is the hazard §2
37/// of docs/index-fidelity-and-run-modes.md names.
38///
39/// The gate hangs off the OVERRIDE rather than off a flag threaded through
40/// twenty check signatures, because the override IS the fact that matters. It
41/// therefore covers built-ins and `manifest::External::run` (which consults
42/// `fixing_enabled` in two places) in one change, and a future check cannot
43/// forget it.
44static NOT_THE_INDEX: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
45
46/// Make every subsequent `staged_files` answer from `files` instead of the
47/// index. Only the first call counts.
48pub fn override_file_set(files: Vec<String>) {
49 // Set unconditionally, even if a set already won the `OnceLock`: the
50 // statement "the file set is not the index" is true from the first call
51 // onwards regardless of which one supplied the paths.
52 NOT_THE_INDEX.store(true, std::sync::atomic::Ordering::SeqCst);
53 let _ = OVERRIDE.set(files);
54}
55
56/// Whether the file set every check sees is something other than the index.
57pub fn not_the_index() -> bool {
58 NOT_THE_INDEX.load(std::sync::atomic::Ordering::SeqCst)
59}
60
61/// An empty `exts` returns them all.
62///
63/// The UNFILTERED list is read from git ONCE per process and lent to every
64/// caller — the per-stage snapshot. Eleven of the pre-commit checks ask this
65/// question, concurrently, and each used to pay its own `git diff` spawn for
66/// an answer that cannot change while the stage runs: the index-fidelity
67/// hold pins the tree, and a fixer's `restage()` re-adds only paths already
68/// on this list. `PushRefs` ("read once and lent") and `Overrides` ("ONE
69/// subprocess for the whole stage") are the same pattern; this was the last
70/// hot question still answered per asker.
71pub fn staged_files(exts: &[&str]) -> Vec<String> {
72 if let Some(all) = OVERRIDE.get() {
73 return all
74 .iter()
75 .filter(|f| exts.is_empty() || exts.iter().any(|e| f.ends_with(e)))
76 .cloned()
77 .collect();
78 }
79 static INDEX: OnceLock<Vec<String>> = OnceLock::new();
80 INDEX
81 .get_or_init(|| {
82 match git::stdout_paths(&["diff", "--diff-filter=d", "--cached", "--name-only"]) {
83 Some(files) => files,
84 // The third member of a bug family (`repo_hooks`, the push
85 // gates): git FAILING is not git answering "empty", and a
86 // stage that judges an empty set on a git failure reports
87 // clean having verified nothing. Say so — once, this cache
88 // being the once — and still fail open: pre-commit's job is
89 // never to block a commit over its own plumbing.
90 None => {
91 warn(
92 "git would not list the staged files — the checks are judging \
93 an EMPTY set, not a verified one",
94 );
95 Vec::new()
96 }
97 }
98 })
99 .iter()
100 .filter(|f| exts.is_empty() || exts.iter().any(|e| f.ends_with(e)))
101 .cloned()
102 .collect()
103}
104
105/// Repo root, or "." when git cannot say.
106///
107/// **For CHECK BODIES ONLY.** The fallback is safe there and nowhere else: git
108/// invokes a hook with the working tree as the current directory, so a check
109/// that reaches this line is already standing in the repository, and "." is the
110/// right answer rather than a guess.
111///
112/// Anything a user types — `amont agents-md`, `install`, `trust`, `restore`
113/// — can be typed from any directory on the machine, and there the fallback is
114/// not a fallback but a wrong answer that reads as a right one. Use
115/// [`repo_root_checked`] at every command entry point.
116pub fn repo_root() -> String {
117 // Cached: the answer is a property of the process's repository, and
118 // every check asked it through its own subprocess.
119 static ROOT: OnceLock<String> = OnceLock::new();
120 ROOT.get_or_init(|| {
121 git::stdout(&["rev-parse", "--show-toplevel"]).unwrap_or_else(|| ".".into())
122 })
123 .clone()
124}
125
126/// Repo root, or an error naming the problem.
127///
128/// The same question as [`repo_root`] without the "." — because "." is a
129/// PLAUSIBLE root, and that is what made it dangerous. `amont agents-md`
130/// run outside a repository did not fail; it resolved the root to the current
131/// directory and wrote `./AGENTS.md` into whatever directory the user happened
132/// to be standing in, then printed `wrote ./AGENTS.md` as if that were the
133/// answer. Same shape in `install`'s two prompts, in `trust` (which then
134/// looked for a manifest, and would have recorded trust, under `.`) and in
135/// `restore`.
136///
137/// Every one of those is a command somebody types, and a command somebody
138/// types is a command they can type from `~`. There is no correct behaviour
139/// available to this function when git cannot answer, so it does not invent
140/// one.
141pub fn repo_root_checked() -> Result<String, String> {
142 git::stdout(&["rev-parse", "--show-toplevel"])
143 .filter(|s| !s.is_empty())
144 .ok_or_else(|| "not inside a git repository".to_string())
145}
146
147/// Resolve a tool, preferring the repo's PINNED copy so the hook matches CI.
148///
149///
150/// Order: `<root>/node_modules/.bin/<tool>`, then the MAIN worktree's (a linked
151/// worktree has no node_modules of its own — this is why the shell version
152/// consulted the git common dir), then PATH.
153pub fn resolve_tool(root: &str, tool: &str) -> Option<Vec<String>> {
154 // Same extension problem as `which`: an npm-installed binary is `eslint.cmd`
155 // on Windows, so the bare name misses the repo's PINNED copy and the hook
156 // silently falls through to an ambient one.
157 if let Some(p) = in_bin_dir(&format!("{root}/node_modules/.bin"), tool) {
158 return Some(vec![p]);
159 }
160 if let Some(common) = git::stdout(&["rev-parse", "--path-format=absolute", "--git-common-dir"])
161 {
162 if let Some(main) = Path::new(&common).parent() {
163 if let Some(p) = in_bin_dir(&main.join("node_modules/.bin").to_string_lossy(), tool) {
164 return Some(vec![p]);
165 }
166 }
167 }
168 if let Some(full) = which(tool) {
169 return Some(vec![full]);
170 }
171 // `npx --no-install`: never silently download a random latest version — a
172 // hook that quietly pulls a different linter than CI uses is worse than one
173 // that skips.
174 if which("npx").is_some()
175 && Command::new(program("npx"))
176 .args(["--no-install", tool, "--version"])
177 .current_dir(root)
178 .stdin(Stdio::null())
179 .stdout(Stdio::null())
180 .stderr(Stdio::null())
181 .status()
182 .map(|s| s.success())
183 .unwrap_or(false)
184 {
185 return Some(vec![
186 program("npx"),
187 "--no-install".to_string(),
188 tool.to_string(),
189 ]);
190 }
191 None
192}
193
194/// First match for `tool` on PATH.
195///
196/// Windows executables carry an extension — `git` is `git.exe`, an npm-installed
197/// `eslint` is `eslint.cmd` — so the bare name finds nothing there. PATHEXT is
198/// the OS's own list of what counts as executable; fall back to the usual set
199/// when it is unset. Found by the Windows CI job on its first run, where
200/// `which("git")` returned None on a machine that plainly has git.
201pub fn which(tool: &str) -> Option<String> {
202 let path = std::env::var_os("PATH")?;
203 let exts: Vec<String> = if cfg!(windows) {
204 std::env::var("PATHEXT")
205 .unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".into())
206 .split(';')
207 .filter(|e| !e.is_empty())
208 .map(|e| e.to_lowercase())
209 .collect()
210 } else {
211 Vec::new()
212 };
213 for dir in std::env::split_paths(&path) {
214 // On Windows the EXTENSION forms come first. A node install ships both
215 // `npm` (an extensionless shell script, for MSYS) and `npm.cmd` in the
216 // same directory; preferring the bare name hands CreateProcess a shell
217 // script it cannot execute — "%1 is not a valid Win32 application" —
218 // and the hook reports an installed tool as broken.
219 for e in &exts {
220 let c = dir.join(format!("{tool}{e}"));
221 if c.is_file() {
222 return Some(c.to_string_lossy().into_owned());
223 }
224 }
225 let bare = dir.join(tool);
226 if bare.is_file() {
227 return Some(bare.to_string_lossy().into_owned());
228 }
229 }
230 None
231}
232
233/// `<dir>/<tool>`, trying the Windows executable extensions too.
234fn in_bin_dir(dir: &str, tool: &str) -> Option<String> {
235 let bare = Path::new(dir).join(tool);
236 if bare.is_file() {
237 return Some(bare.to_string_lossy().into_owned());
238 }
239 if cfg!(windows) {
240 for e in [".cmd", ".exe", ".bat", ".ps1"] {
241 let c = Path::new(dir).join(format!("{tool}{e}"));
242 if c.is_file() {
243 return Some(c.to_string_lossy().into_owned());
244 }
245 }
246 }
247 None
248}
249
250/// Resolve a tool name to a full path for spawning.
251///
252/// `Command::new("npm")` cannot execute `npm.cmd`: Rust does no PATHEXT
253/// resolution, so on Windows every bare-name spawn fails with "program not
254/// found" and the hook reports the tool as broken rather than absent. Found by
255/// the Windows job on its first FULL-suite run — the smoke never spawned a
256/// tool, so it could not have surfaced this.
257///
258/// Falls back to the name unchanged, so a caller still gets a sensible error.
259pub fn program(name: &str) -> String {
260 which(name).unwrap_or_else(|| name.to_string())
261}
262
263/// The first of `names` that exists at the repo root — how these hooks decide
264/// a repo has opted into a tool.
265pub fn first_existing(root: &str, names: &[&str]) -> Option<String> {
266 names
267 .iter()
268 .find(|n| Path::new(root).join(n).exists())
269 .map(|n| (*n).to_string())
270}
271
272/// Strip git's own environment before handing a Command to another tool.
273///
274/// git exports GIT_DIR, GIT_INDEX_FILE, GIT_WORK_TREE and friends to every
275/// hook. Those OVERRIDE the working directory, so any tool that shells out to
276/// git operates on the hook's repository no matter where it was launched.
277///
278/// That is not hypothetical: `pre-push-cargo-test` runs a project's test suite,
279/// and this repo's own suite creates throwaway repos and commits to them. With
280/// GIT_DIR inherited, `git commit` in a test wrote into the REAL repository —
281/// an actual stray commit, authored by the test fixture, pushed to a branch.
282///
283/// A test suite should behave exactly as it does when run by hand, which means
284/// seeing no git environment at all.
285pub fn strip_git_env(cmd: &mut Command) {
286 for (k, _) in std::env::vars_os() {
287 let key = k.to_string_lossy();
288 if key.starts_with("GIT_") {
289 cmd.env_remove(&k);
290 }
291 }
292}
293
294/// The wall-clock budget for one check's spawned command, in seconds.
295///
296/// `amont.timeout`, default 600 — ten minutes, the figure the generated
297/// agent guidance already tells tooling to allow a whole commit or push; a
298/// single check that outlives it is not slow, it is stuck. `0` disables.
299/// Read once per process: twenty concurrent checks must not each spawn a
300/// `git config` to learn the same number.
301pub fn check_timeout() -> u64 {
302 static TIMEOUT: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
303 *TIMEOUT.get_or_init(|| crate::config::integer_or("amont.timeout", 600, 0..=86_400) as u64)
304}
305
306/// What became of a command run under the deadline.
307pub enum Ran {
308 Status(std::process::ExitStatus),
309 /// Killed at the deadline; carries the budget it exceeded, in seconds.
310 TimedOut(u64),
311}
312
313/// `cmd.status()`, bounded by [`check_timeout`].
314///
315/// Without a bound, one hung tool — a linter deadlocked on a lock file, a
316/// plugin doing network I/O — blocked the commit FOREVER, and it hung inside
317/// the index-fidelity hold: the user's unstaged changes parked in `$GIT_DIR`,
318/// their tree showing staged content only, for as long as they were willing
319/// to wait. The learned response to that is `--no-verify`, permanently —
320/// which disarms every check to escape one.
321///
322/// The kill reaches the direct child only. A grandchild that detached
323/// survives, orphaned — but the COMMIT is no longer hostage to it, which is
324/// the property that matters.
325pub fn status_within(cmd: &mut Command) -> std::io::Result<Ran> {
326 status_within_secs(cmd, check_timeout())
327}
328
329/// [`status_within`] with an explicit budget — the testable seam.
330pub fn status_within_secs(cmd: &mut Command, budget_secs: u64) -> std::io::Result<Ran> {
331 if budget_secs == 0 {
332 return cmd.status().map(Ran::Status);
333 }
334 let mut child = cmd.spawn()?;
335 wait_within(&mut child, budget_secs)
336}
337
338/// [`status_within`], with the child's stdout and stderr CAPTURED into the
339/// calling check's slot instead of inherited — the other half of one-check-
340/// one-block: a linter's twelve lines used to land on the shared terminal
341/// between two other checks' lines. Falls back to plain [`status_within`]
342/// when no slot is installed on this thread (`amont.progress false`, or a
343/// spawn outside a stage), which is byte-for-byte the old behaviour.
344///
345/// stdout and stderr merge in ARRIVAL order inside the block, which is what
346/// the terminal showed before. The readers are threads, not processes, and
347/// they are joined before the status is returned so a block can never grow
348/// after its check finished.
349pub fn status_streamed(cmd: &mut Command) -> std::io::Result<Ran> {
350 let Some((stage, idx)) = crate::live::current_sink() else {
351 return status_within(cmd);
352 };
353 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
354 if crate::live::watching() {
355 // The block lands on a real terminal but the tool sees a pipe and
356 // would strip its colors; the big three opt-in knobs put them back.
357 cmd.env("FORCE_COLOR", "1")
358 .env("CLICOLOR_FORCE", "1")
359 .env("CARGO_TERM_COLOR", "always");
360 }
361 let budget = check_timeout();
362 let mut child = cmd.spawn()?;
363 let mut readers = Vec::new();
364 for pipe in [
365 child
366 .stdout
367 .take()
368 .map(|p| Box::new(p) as Box<dyn std::io::Read + Send>),
369 child
370 .stderr
371 .take()
372 .map(|p| Box::new(p) as Box<dyn std::io::Read + Send>),
373 ]
374 .into_iter()
375 .flatten()
376 {
377 let stage = std::sync::Arc::clone(&stage);
378 readers.push(std::thread::spawn(move || {
379 let mut pipe = pipe;
380 let mut chunk = [0u8; 4096];
381 loop {
382 match std::io::Read::read(&mut pipe, &mut chunk) {
383 Ok(0) | Err(_) => break,
384 Ok(n) => stage.append_raw(idx, &chunk[..n]),
385 }
386 }
387 }));
388 }
389 let ran = wait_within(&mut child, budget)?;
390 for r in readers {
391 let _ = r.join();
392 }
393 Ok(ran)
394}
395
396/// The deadline loop over an already-spawned child — shared by the
397/// inherited and captured runners.
398fn wait_within(child: &mut std::process::Child, budget_secs: u64) -> std::io::Result<Ran> {
399 if budget_secs == 0 {
400 return child.wait().map(Ran::Status);
401 }
402 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(budget_secs);
403 loop {
404 if let Some(status) = child.try_wait()? {
405 return Ok(Ran::Status(status));
406 }
407 if std::time::Instant::now() >= deadline {
408 let _ = child.kill();
409 let _ = child.wait();
410 return Ok(Ran::TimedOut(budget_secs));
411 }
412 std::thread::sleep(std::time::Duration::from_millis(25));
413 }
414}
415
416/// Say a command was killed at the deadline, and how to change the deadline.
417pub fn say_timed_out(what: &str, budget_secs: u64) {
418 fail(&format!(
419 "{} timed out after {budget_secs}s — killed. {} raises the budget",
420 hl(what),
421 hl("git config amont.timeout <secs>")
422 ));
423}
424
425/// [`status_within`], collapsed to "did it exit 0" — the shape the one-shot
426/// tool spawns want. A timeout says so, names `what`, and reads as failure.
427pub fn bounded_success(cmd: &mut Command, what: &str) -> bool {
428 match status_streamed(cmd) {
429 Ok(Ran::Status(s)) => s.success(),
430 Ok(Ran::TimedOut(b)) => {
431 say_timed_out(what, b);
432 false
433 }
434 Err(_) => false,
435 }
436}
437
438/// Run `argv` from `root`, inheriting stdio. True when it exits 0.
439pub fn run(root: &str, argv: &[String], extra: &[String]) -> bool {
440 let Some((program, rest)) = argv.split_first() else {
441 return true;
442 };
443 let mut cmd = Command::new(program);
444 cmd.args(rest)
445 .args(extra)
446 .current_dir(root)
447 .stdin(Stdio::null());
448 strip_git_env(&mut cmd);
449 bounded_success(&mut cmd, program)
450}
451
452/// As [`run`], but with the tool's own output discarded.
453///
454/// For a pass whose only job is to decide something — prettier's `--check`,
455/// ruff's `--fix` sweep — where the offenders are printed once, by the pass
456/// that reports them, rather than twice.
457pub fn run_quiet(root: &str, argv: &[String], extra: &[String]) -> bool {
458 let Some((program, rest)) = argv.split_first() else {
459 return true;
460 };
461 let mut cmd = Command::new(program);
462 cmd.args(rest)
463 .args(extra)
464 .current_dir(root)
465 .stdin(Stdio::null())
466 .stdout(Stdio::null())
467 .stderr(Stdio::null());
468 strip_git_env(&mut cmd);
469 // Deliberately NOT the streamed runner: this helper's contract is that
470 // the output is discarded, and capture would resurrect it into the block.
471 match status_within(&mut cmd) {
472 Ok(Ran::Status(s)) => s.success(),
473 Ok(Ran::TimedOut(b)) => {
474 say_timed_out(program, b);
475 false
476 }
477 Err(_) => false,
478 }
479}
480
481/// Whether the user asked for checks to repair what they find.
482///
483/// OFF by default. `git config amont.fix true` turns it on, per repository,
484/// because a hook that edits your files without being asked is a larger
485/// surprise than one that complains — and because with index fidelity in place
486/// the repair lands in the commit you are making, which is a bigger claim to
487/// make on somebody's behalf than printing an error.
488pub fn fixing_enabled() -> bool {
489 // Never while the file set is not the index — see `NOT_THE_INDEX`.
490 !not_the_index() && fixing_requested()
491}
492
493/// What the CONFIG says, ignoring whether the current run may act on it.
494///
495/// Split out so `run_all` can tell the difference between "fixing is off" and
496/// "you asked for fixing and this mode will not do it", and say the second out
497/// loud instead of silently ignoring the key.
498pub fn fixing_requested() -> bool {
499 crate::config::boolean_or("amont.fix", false)
500}
501
502/// What a re-stage actually did. THREE answers, because the old `bool`
503/// conflated two of them and the conflation shipped unformatted code.
504///
505/// `prettier.rs` read `if run_quiet(write) && restage(&files) { … Fixed }`. When
506/// `git add` FAILED, `restage` returned `false` — indistinguishable from
507/// "nothing needed staging" — so control fell through to a second `--check`
508/// pass, which inspected the NOW-FORMATTED WORKING TREE, passed, printed
509/// "Prettier passed" and returned `Outcome::Passed`. The index still held the
510/// unformatted content, so the commit contained unformatted code and the hook
511/// said it had passed. `manifest.rs` had the same shape.
512#[derive(Debug, Clone, PartialEq, Eq)]
513pub enum Restaged {
514 /// No path differed from the index — nothing to do, and nothing wrong.
515 Nothing,
516 /// `git add` succeeded; the index now holds the repair.
517 Staged,
518 /// `git add` failed, carrying the paths it could not stage. The index
519 /// holds content the fixer has already replaced on disk, so this MUST be
520 /// loud at every call site — and naming the files is the difference
521 /// between a message somebody can act on and one they cannot.
522 Failed(Vec<String>),
523}
524
525/// Serialises this process's own `git add` calls.
526///
527/// pre-commit runs its checks concurrently (`dispatch.rs`), and up to three of
528/// them can re-stage. git takes `$GIT_DIR/index.lock` exclusively, so two
529/// concurrent `git add`s in the same repository make one of them fail — which,
530/// before `Restaged`, was silently read as "nothing moved". Holding this across
531/// the `git add` removes self-contention entirely; the retry below is only for
532/// OTHER processes.
533static INDEX_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
534
535/// Re-stage exactly the paths a fixer rewrote, and say what happened.
536///
537/// Safe ONLY because the pre-commit stage holds unstaged changes aside: the
538/// tree contains the staged content and nothing else, so anything a formatter
539/// touched is by definition part of this commit. Without that, re-staging would
540/// sweep in work the author deliberately kept back.
541pub fn restage(paths: &[String]) -> Restaged {
542 // Belt and braces alongside `fixing_enabled`: a future fixer that forgets
543 // the gate still cannot turn `amont run --all-files` into `git add .`.
544 if not_the_index() {
545 return Restaged::Nothing;
546 }
547 let changed: Vec<String> = paths
548 .iter()
549 .filter(|p| !git::succeeds(&["diff", "--quiet", "--", p]))
550 .cloned()
551 .collect();
552 if changed.is_empty() {
553 return Restaged::Nothing;
554 }
555 let mut args = vec!["add", "--"];
556 args.extend(changed.iter().map(String::as_str));
557
558 let _serialised = INDEX_LOCK.lock().unwrap_or_else(|e| e.into_inner());
559 // Another PROCESS can hold `index.lock` — a `git status` from an editor, a
560 // second hook in a linked worktree. Back off and retry rather than
561 // reporting a transient collision as a failed repair. `git add` of the same
562 // paths is idempotent: it records the paths' current worktree content, so
563 // running it twice records the same thing twice and cannot double-stage.
564 const BACKOFF_MS: [u64; 3] = [50, 150, 400];
565 if git::succeeds(&args) {
566 return Restaged::Staged;
567 }
568 for wait in BACKOFF_MS {
569 std::thread::sleep(std::time::Duration::from_millis(wait));
570 if git::succeeds(&args) {
571 return Restaged::Staged;
572 }
573 }
574 Restaged::Failed(changed)
575}
576
577pub fn ok(msg: &str) {
578 crate::live::say(&format!("{} {msg}", valid_sign()));
579}
580pub fn fail(msg: &str) {
581 crate::live::say(&format!("{} {msg}", error_sign()));
582}
583pub fn warn(msg: &str) {
584 crate::live::say(&format!("{} {msg}", warning_sign()));
585}
586/// A line with no sign of its own — what a check's direct `println!` becomes,
587/// so it lands in the check's block instead of interleaving. See `live::say`.
588pub fn say(msg: &str) {
589 crate::live::say(msg);
590}
591
592/// Orange, for the fragments these hooks highlight.
593pub fn hl(s: &str) -> String {
594 crate::ui::highlight(s)
595}
596
597#[cfg(test)]
598mod tests {
599
600 /// The deadline kills what outlives it and reports what finished.
601 #[cfg(unix)]
602 #[test]
603 fn the_deadline_kills_a_sleeper_and_spares_a_finisher() {
604 let started = std::time::Instant::now();
605 let mut slow = Command::new(program("sleep"));
606 slow.arg("300").stdin(Stdio::null());
607 match status_within_secs(&mut slow, 1) {
608 Ok(Ran::TimedOut(1)) => {}
609 other => panic!("expected TimedOut(1), got {:?}", other.map(|_| "ran")),
610 }
611 assert!(
612 started.elapsed() < std::time::Duration::from_secs(60),
613 "the kill did not happen at the deadline"
614 );
615
616 let mut quick = Command::new(program("true"));
617 quick.stdin(Stdio::null());
618 match status_within_secs(&mut quick, 60) {
619 Ok(Ran::Status(s)) => assert!(s.success()),
620 other => panic!("expected a clean exit, got {:?}", other.map(|_| "?")),
621 }
622 }
623
624 use super::*;
625
626 #[test]
627 fn which_finds_a_real_binary_and_not_a_fake_one() {
628 assert!(which("git").is_some());
629 assert!(which("definitely-not-a-real-binary-xyz").is_none());
630 }
631
632 /// On Windows a tool can exist BOTH as an extensionless shell script and as
633 /// a .cmd/.exe in the same directory; only the latter is executable by
634 /// CreateProcess, so the extension forms must win.
635 #[test]
636 #[cfg(windows)]
637 fn windows_prefers_an_executable_extension_over_a_bare_file() {
638 let dir = std::env::temp_dir().join("amont-which-order");
639 let _ = std::fs::create_dir_all(&dir);
640 std::fs::write(dir.join("faketool"), "#!/bin/sh\n").unwrap();
641 std::fs::write(dir.join("faketool.cmd"), "@echo off\n").unwrap();
642 let saved = std::env::var_os("PATH");
643 std::env::set_var("PATH", &dir);
644 let found = which("faketool").unwrap();
645 if let Some(p) = saved {
646 std::env::set_var("PATH", p);
647 }
648 assert!(found.ends_with(".cmd"), "got {found}");
649 let _ = std::fs::remove_dir_all(&dir);
650 }
651
652 /// "Nothing moved" and "`git add` FAILED" are different answers, and the
653 /// old `bool` gave the same one for both.
654 ///
655 /// That conflation is what shipped unformatted code: `prettier.rs` read
656 /// `if wrote && restage(&files)`, so a failed `git add` fell through to a
657 /// second `--check` against the now-formatted WORKING TREE, which passed —
658 /// while the INDEX still held the unformatted content the commit would
659 /// carry.
660 ///
661 /// An absolute path outside any repository is a `git add` git will always
662 /// refuse, which is the only way to reach the failing branch without
663 /// sabotaging a real index.
664 #[test]
665 fn restage_distinguishes_nothing_from_failure() {
666 let outside = std::env::temp_dir()
667 .join("amont-restage-outside-any-repo")
668 .to_string_lossy()
669 .into_owned();
670 assert_eq!(
671 restage(std::slice::from_ref(&outside)),
672 Restaged::Failed(vec![outside]),
673 "a `git add` git refuses must report Failed, never Nothing"
674 );
675 assert_eq!(
676 restage(&[]),
677 Restaged::Nothing,
678 "no paths is nothing to do, and nothing wrong"
679 );
680 }
681
682 /// No check may hand `Command` a bare program name.
683 ///
684 /// `Command::new` does NO PATHEXT resolution, so `Command::new("npm")`
685 /// cannot execute `npm.cmd` and `Command::new("uvx")` cannot execute
686 /// `uvx.exe`: the spawn fails with "program not found" and a
687 /// `Severity::Block` check reports an installed tool as broken. That is the
688 /// incident `program()` exists for, and it kept recurring — `yamllint` and
689 /// three sites in `python_tools` were still doing it, THREE OF THEM after
690 /// `which()` had already succeeded and discarded the answer.
691 ///
692 /// A source scan rather than a runtime assertion because the failure only
693 /// reproduces on Windows, and the whole point is to catch the next one on
694 /// every platform. Comment lines are skipped: `program()`'s own doc quotes
695 /// the offending call. The needle is assembled from two pieces so this
696 /// module — which the scan also reads — does not match itself.
697 #[test]
698 fn no_hook_spawns_a_bare_program_name() {
699 let needle = concat!("Command", "::new(");
700 let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/src/hooks");
701 let mut scanned = 0usize;
702 for entry in std::fs::read_dir(dir).expect("hooks dir").flatten() {
703 let path = entry.path();
704 if path.extension().and_then(|e| e.to_str()) != Some("rs") {
705 continue;
706 }
707 scanned += 1;
708 let src = std::fs::read_to_string(&path).expect("read a hook module");
709 for (n, line) in src.lines().enumerate() {
710 if line.trim_start().starts_with("//") {
711 continue;
712 }
713 let Some(after) = line.split_once(needle) else {
714 continue;
715 };
716 assert!(
717 !after.1.starts_with('"'),
718 "{}:{} spawns a bare name — route it through `program()` or \
719 the path `which()` already resolved: {}",
720 path.display(),
721 n + 1,
722 line.trim()
723 );
724 }
725 }
726 assert!(
727 scanned > 10,
728 "the scan found almost nothing: {scanned} files"
729 );
730 }
731
732 #[test]
733 fn first_existing_picks_the_earliest_present_name() {
734 let dir = std::env::temp_dir().join("amont-first-existing-test");
735 let _ = std::fs::create_dir_all(&dir);
736 let root = dir.to_string_lossy().into_owned();
737 let _ = std::fs::write(dir.join("second"), "x");
738 assert_eq!(
739 first_existing(&root, &["first", "second", "third"]).as_deref(),
740 Some("second")
741 );
742 assert_eq!(first_existing(&root, &["nope"]), None);
743 let _ = std::fs::remove_dir_all(&dir);
744 }
745}