amont 1.55.2

Opinionated git hooks that judge what you are committing, not what is on disk
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
//! A throwaway git repo, for driving hooks from Rust.
//!
//! Phase 4 of docs/rust-migration.md. The zsh suites were the migration's
//! harness and stayed untouched on purpose while the hooks moved; now that the
//! hooks are Rust, the suites are the LAST thing requiring zsh — which is why
//! the Windows job can only run a smoke instead of the real tests.
//!
//! Each `Repo` is an isolated temp repository, cleaned up on drop, so tests run
//! in parallel. The old runner created one repo per SUITE and ran cases
//! sequentially inside it, which is why several cases depended on state left by
//! earlier ones (and why one of them could not fail — see pull-rebase).

#![allow(dead_code)] // each integration test binary uses a different subset

use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};

pub struct Repo {
    pub dir: PathBuf,
}

/// A counter, so parallel tests never collide on a directory name.
fn unique() -> String {
    use std::sync::atomic::{AtomicU64, Ordering};
    static N: AtomicU64 = AtomicU64::new(0);
    format!(
        "amont-test-{}-{}",
        std::process::id(),
        N.fetch_add(1, Ordering::Relaxed)
    )
}

impl Repo {
    /// A fresh repo with an identity and NO hooks installed.
    ///
    /// `--template=` (empty) matters: without it `git init` copies the
    /// machine's own hooks in, and a test would exercise those instead of the
    /// binary under test. That bit the Windows smoke before it was noticed.
    ///
    /// `--initial-branch=main` at INIT time, not `git config init.defaultBranch`
    /// afterward — that config key only governs FUTURE `git init`/`git clone`
    /// calls, so setting it after this repo already exists changes nothing.
    /// Whatever the runner's own git build defaults to otherwise (still
    /// `master` on plenty of them) then decides the branch name instead, and a
    /// test that pushes the literal branch `main` silently pushes a ref that
    /// does not exist — discovered when the CI job with no global
    /// `init.defaultBranch` step hit exactly that.
    pub fn new() -> Self {
        let dir = std::env::temp_dir().join(unique());
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).expect("create temp repo");
        let r = Repo { dir };
        r.git(&["init", "-q", "--template=", "--initial-branch=main", "."]);
        r.git(&["config", "user.email", "test@example.com"]);
        r.git(&["config", "user.name", "test"]);
        // Keep the tests independent of the developer's global config.
        r.git(&["config", "commit.gpgsign", "false"]);
        // Git for Windows converts line endings on checkout by default. A test
        // that writes "a\n", stages it and then compares the file byte for byte
        // would be asserting git's newline policy rather than the behaviour
        // under test — and would pass on two platforms and fail on the third.
        r.git(&["config", "core.autocrlf", "false"]);
        // `amont.quiet` defaults to `auto`, and every command this harness runs
        // goes through `Command::output()` — so stderr is NEVER a terminal and
        // `auto` would resolve to quiet for the whole suite. That is not a
        // harmless difference: roughly twenty assertions here are NEGATIVE
        // (`!says(...)`, `silent()`), and they would all start passing
        // vacuously, because the line they guard against could no longer be
        // printed at all. The scope contracts — a commit touching no Dockerfile
        // says nothing about hadolint, kube-linter is silent without a config —
        // would stop being tested while staying green.
        //
        // So the suite pins the verbose form and tests that opt out say so:
        // `quiet_replaces_the_success_lines_with_their_count` sets `auto` and
        // `always` itself. Pinning also stops every other test asserting a
        // default by accident.
        r.git(&["config", "amont.quiet", "never"]);
        r
    }

    pub fn git(&self, args: &[&str]) -> Output {
        let mut cmd = Command::new("git");
        cmd.args(args).current_dir(&self.dir).stdin(Stdio::null());
        Self::strip_git_env_impl(&mut cmd);
        cmd.output().expect("run git")
    }

    /// Write a file (creating parents) and stage it.
    pub fn stage(&self, path: &str, content: &str) {
        let full = self.dir.join(path);
        if let Some(p) = full.parent() {
            std::fs::create_dir_all(p).expect("create parent");
        }
        std::fs::write(&full, content).expect("write file");
        // `--`: a fixture staging a file named e.g. `-weird.json` (to prove a
        // hook itself handles one) would otherwise fail to stage at all —
        // `git add -weird.json` is `unknown switch 'w'` to git's OWN parser.
        self.git(&["add", "--", path]);
    }

    pub fn write(&self, path: &str, content: &str) {
        let full = self.dir.join(path);
        if let Some(p) = full.parent() {
            std::fs::create_dir_all(p).expect("create parent");
        }
        std::fs::write(&full, content).expect("write file");
    }

    pub fn commit(&self, msg: &str) {
        self.git(&["commit", "-q", "--no-verify", "-m", msg]);
    }

    /// Remove git's exported environment.
    ///
    /// These tests are themselves run by `pre-push-cargo-test`, and git gives a
    /// hook GIT_DIR/GIT_INDEX_FILE/GIT_WORK_TREE pointing at the REAL repo.
    /// Those beat `current_dir`, so without this a fixture's `git commit`
    /// commits to git-templates itself. It did exactly that once.
    pub fn strip_git_env_impl(cmd: &mut Command) {
        for (k, _) in std::env::vars_os() {
            if k.to_string_lossy().starts_with("GIT_") {
                cmd.env_remove(&k);
            }
        }
    }

    /// Run a hook through the binary, as the shim would.
    pub fn hook(&self, name: &str, args: &[&str]) -> HookRun {
        self.hook_at(&self.dir, name, args)
    }

    /// Pin the host-level knobs a fixture must not inherit from the machine
    /// (ADR-0009). These tests run under amont's own `pre-push-cargo-test`,
    /// on a workstation that is often loaded: without
    /// `AMONT_IDLE_LOAD_SCALE=1` every kill-time bound in `timing.rs`
    /// stretches with the load average, and without `AMONT_HOST_SLOT=held`
    /// a fixture's heavy check would queue on the real host slots behind the
    /// very suite that is running it. The slot and load fixtures use
    /// [`Repo::hook_watched_unpinned`], which sets neither.
    pub fn pin_host_env(cmd: &mut Command) {
        cmd.env("AMONT_IDLE_LOAD_SCALE", "1");
        cmd.env("AMONT_HOST_SLOT", "held");
    }

    /// Run a SUBCOMMAND — `amont list`, `amont setup` — from this repo.
    ///
    /// No `--hooks-dir`: that flag is hook mode, and a subcommand takes its own
    /// arguments verbatim.
    ///
    /// `GIT_CONFIG_GLOBAL` is set AFTER `strip_git_env_impl`, which removes
    /// every `GIT_*` variable including this one. Order matters: a test that
    /// exercises `--global` config would otherwise write the DEVELOPER's
    /// `~/.gitconfig`, and these tests are themselves run by
    /// `pre-push-cargo-test`. The file lives inside the repo's temp directory,
    /// so it is removed on drop with everything else.
    pub fn run(&self, args: &[&str]) -> HookRun {
        let mut cmd = Command::new(bin());
        cmd.args(args).current_dir(&self.dir).stdin(Stdio::null());
        Self::strip_git_env_impl(&mut cmd);
        Self::pin_host_env(&mut cmd);
        cmd.env("GIT_CONFIG_GLOBAL", self.dir.join("fake-gitconfig"));
        let out = cmd.output().expect("run amont");
        HookRun {
            code: out.status.code().unwrap_or(-1),
            stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
            stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
        }
    }

    /// Run a hook as the shim would from a LINKED WORKTREE: `--hooks-dir` is
    /// still this (main) repo's `.git/hooks` — real worktrees share the main
    /// checkout's hooks, they do not get their own — but the process runs
    /// with `cwd` set to wherever the commit is actually happening.
    pub fn hook_at(&self, cwd: &Path, name: &str, args: &[&str]) -> HookRun {
        let mut cmd = Command::new(bin());
        cmd.arg("--hooks-dir")
            .arg(self.dir.join(".git/hooks"))
            .arg(name)
            .args(args)
            .current_dir(cwd)
            .stdin(Stdio::null());
        Self::strip_git_env_impl(&mut cmd);
        Self::pin_host_env(&mut cmd);
        let out = cmd.output().expect("run amont");
        HookRun {
            code: out.status.code().unwrap_or(-1),
            stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
            stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
        }
    }

    /// Run a hook under a WATCHDOG that does not trust amont to return.
    ///
    /// For the fixtures that leave descendants behind on purpose (busy
    /// workers, orphans): amont kills only its direct child, and a surviving
    /// worker holding a pipe would make [`Repo::hook`]'s `output()` wait on
    /// it for as long as it lives. So amont runs in a process group of its
    /// own, its output goes to FILES (a worker holding one cannot block
    /// anything), and a separate clock SIGKILLs the whole group and fails the
    /// test at `limit`. The group is killed again afterwards either way, so
    /// nothing a fixture started outlives the test.
    ///
    /// Returns the run and how long it took.
    #[cfg(unix)]
    pub fn hook_watched(
        &self,
        name: &str,
        env: &[(&str, &std::ffi::OsStr)],
        limit: std::time::Duration,
    ) -> (HookRun, std::time::Duration) {
        self.hook_watched_in(&self.dir, name, env, limit, true)
    }

    /// [`Repo::hook_watched`] WITHOUT the host pins of [`Repo::pin_host_env`]:
    /// for the fixtures that are about host slots and the load-scaled
    /// budget, which set those knobs themselves through `env`.
    #[cfg(unix)]
    pub fn hook_watched_unpinned(
        &self,
        name: &str,
        env: &[(&str, &std::ffi::OsStr)],
        limit: std::time::Duration,
    ) -> (HookRun, std::time::Duration) {
        self.hook_watched_in(&self.dir, name, env, limit, false)
    }

    #[cfg(unix)]
    fn hook_watched_in(
        &self,
        cwd: &Path,
        name: &str,
        env: &[(&str, &std::ffi::OsStr)],
        limit: std::time::Duration,
        pinned: bool,
    ) -> (HookRun, std::time::Duration) {
        use std::os::unix::process::CommandExt;
        // Outside the repo: a pre-commit hold may park untracked files.
        let tmp = std::env::temp_dir();
        let out_path = tmp.join(format!("{}.out", unique()));
        let err_path = tmp.join(format!("{}.err", unique()));
        let mut cmd = Command::new(bin());
        cmd.arg("--hooks-dir")
            .arg(self.dir.join(".git/hooks"))
            .arg(name)
            .current_dir(cwd)
            .stdin(Stdio::null())
            .stdout(std::fs::File::create(&out_path).expect("stdout file"))
            .stderr(std::fs::File::create(&err_path).expect("stderr file"))
            .process_group(0);
        Self::strip_git_env_impl(&mut cmd);
        if pinned {
            Self::pin_host_env(&mut cmd);
        } else {
            // `.cargo/config.toml` pins both for the whole test process;
            // an unpinned fixture starts from neither and sets its own.
            cmd.env_remove("AMONT_HOST_SLOT");
            cmd.env_remove("AMONT_IDLE_LOAD_SCALE");
        }
        for (k, v) in env {
            cmd.env(k, v);
        }
        let started = std::time::Instant::now();
        let mut child = cmd.spawn().expect("spawn amont");
        let group = child.id();
        let kill_group = || {
            let _ = Command::new("kill")
                .args(["-KILL", "--", &format!("-{group}")])
                .stderr(Stdio::null())
                .status();
        };
        let status = loop {
            if let Some(s) = child.try_wait().expect("wait amont") {
                break Some(s);
            }
            if started.elapsed() >= limit {
                break None;
            }
            std::thread::sleep(std::time::Duration::from_millis(50));
        };
        let took = started.elapsed();
        kill_group();
        let read = |p: &Path| std::fs::read_to_string(p).unwrap_or_default();
        let run = HookRun {
            code: status.and_then(|s| s.code()).unwrap_or(-1),
            stdout: read(&out_path),
            stderr: read(&err_path),
        };
        let _ = child.wait();
        let _ = std::fs::remove_file(&out_path);
        let _ = std::fs::remove_file(&err_path);
        assert!(
            status.is_some(),
            "the watchdog fired: amont had not returned after {limit:?}:\n{}",
            run.output()
        );
        (run, took)
    }

    /// A linked worktree of this repo, on its own branch. Its `.git` is a
    /// FILE pointing at a private admin directory under this repo's
    /// `.git/worktrees` — distinct from the common `.git` the hooks and
    /// their stash live in, which is exactly the distinction
    /// `amont-held` must get right.
    pub fn worktree(&self, name: &str) -> PathBuf {
        let path = self.dir.join(name);
        self.git(&[
            "worktree",
            "add",
            "-q",
            path.to_str().expect("utf8 path"),
            "-b",
            name,
        ]);
        path
    }

    pub fn path(&self, rel: &str) -> PathBuf {
        self.dir.join(rel)
    }
}

impl Drop for Repo {
    fn drop(&mut self) {
        let _ = std::fs::remove_dir_all(&self.dir);
    }
}

pub struct HookRun {
    pub code: i32,
    pub stdout: String,
    pub stderr: String,
}

impl HookRun {
    pub fn passed(&self) -> bool {
        self.code == 0
    }
    /// Everything the hook printed — several hooks report on stderr.
    pub fn output(&self) -> String {
        format!("{}{}", self.stdout, self.stderr)
    }
    pub fn says(&self, needle: &str) -> bool {
        self.output().contains(needle)
    }
    /// No output at all: several hooks must be SILENT when out of scope, and
    /// "exit 0" alone does not distinguish that from "ran and approved".
    pub fn silent(&self) -> bool {
        self.output().trim().is_empty()
    }
}

/// The variable [`fixture_exec`] sets for its one priming run.
pub const PRIME_ENV: &str = "AMONT_FIXTURE_PRIME";

/// Write a fixture executable — a `#!/bin/sh` script `body`, shebang
/// included — make it executable, and exec it ONCE, before any test reads a
/// clock.
///
/// macOS tags every file a process writes with `com.apple.provenance`, and
/// Gatekeeper assesses such a file the first time it is exec'd: measured on a
/// workstation, the first launch of a two-line script took 0.7 to 6.4 s, the
/// second 0.04 s. A test that writes a shim and then times the hook running
/// it was timing that assessment — the hung-remote probe took 15 s, an
/// installer stub was not seen to start within 30 s, and a sampler watched
/// a root shell sit for a second with 1 ms of CPU, before its child existed.
///
/// The priming run must not run the fixture's body (a `sleep 30`, a log
/// line a test counts), so a guard is put on the line after the shebang:
/// it exits at once when [`PRIME_ENV`] is set. Paying the assessment here
/// costs the same seconds, outside every measured span.
#[cfg(unix)]
pub fn fixture_exec(p: &Path, body: &str) {
    let (shebang, rest) = body
        .split_once('\n')
        .expect("a fixture script starts with its shebang line");
    assert!(shebang.starts_with("#!"), "no shebang in {}", p.display());
    std::fs::write(
        p,
        format!("{shebang}\n[ -n \"${PRIME_ENV}\" ] && exit 0\n{rest}"),
    )
    .expect("write the fixture executable");
    make_executable(p);
    prime(p);
}

/// Exec `p` once with [`PRIME_ENV`] set (see [`fixture_exec`]), for a script
/// already carrying the guard.
///
/// On Linux, exec'ing a file just written can fail with ETXTBSY: another
/// test thread forked while the write was open, and its child holds the
/// descriptor until it execs in turn (O_CLOEXEC closes it then, not
/// before). That window is short, so the exec is retried for up to two
/// seconds; any other error fails at once, naming it.
#[cfg(unix)]
pub fn prime(p: &Path) {
    const ETXTBSY: i32 = 26; // Linux and macOS alike
    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
    loop {
        let ran = Command::new(p)
            .env(PRIME_ENV, "1")
            .stdin(Stdio::null())
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status();
        match ran {
            Ok(s) => {
                assert!(s.success(), "priming {} exited {s}", p.display());
                return;
            }
            Err(e) if e.raw_os_error() == Some(ETXTBSY) && std::time::Instant::now() < deadline => {
                std::thread::sleep(std::time::Duration::from_millis(20));
            }
            Err(e) => panic!("priming {} failed: {e}", p.display()),
        }
    }
}

#[cfg(unix)]
fn make_executable(p: &Path) {
    use std::os::unix::fs::PermissionsExt;
    let _ = std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o755));
}

/// Windows has no executable bit; the dispatcher runs scripts through their
/// shebang interpreter there, so nothing is needed.
#[cfg(not(unix))]
fn make_executable(_p: &Path) {}

/// The binary under test. Cargo builds it before integration tests and points
/// at it via CARGO_BIN_EXE_*.
fn bin() -> &'static str {
    env!("CARGO_BIN_EXE_amont")
}

/// True when a tool the case genuinely needs is absent — and it SAYS SO.
///
/// Rust has no native skip: an early `return` reports as a pass, which is the
/// exact trap the zsh suites already guarded against by printing
/// "unavailable — skipping". The same phrase is used here so CI's
/// skip-reporter sees both harnesses, and a suite that quietly did not run
/// cannot masquerade as one that passed.
pub fn missing(tool: &str) -> bool {
    let found = std::env::var_os("PATH")
        .map(|path| {
            std::env::split_paths(&path).any(|d| {
                d.join(tool).is_file()
                    || (cfg!(windows)
                        && [".exe", ".cmd", ".bat"]
                            .iter()
                            .any(|e| d.join(format!("{tool}{e}")).is_file()))
            })
        })
        .unwrap_or(false);
    if !found {
        println!("  ! {tool} unavailable — skipping");
    }
    !found
}

/// Absolute path to the repo's own templates/hooks, for tests that need a shim.
pub fn template_hook(name: &str) -> PathBuf {
    // ../../ — templates live at the WORKSPACE root, not in this crate.
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("../../templates/hooks")
        .join(name)
}

/// Run a command that executes a binary some test COPIED into place,
/// retrying `ETXTBSY`. On Linux, another thread's fork-to-exec window can
/// briefly hold a write descriptor on the file (descriptors are
/// process-wide, and `fork` duplicates all of them), and executing it in
/// that window fails with "Text file busy". The window is microseconds;
/// the flake it produced on the ubuntu runner was real twice in one day.
/// Never retries any other error — a missing or broken binary is an answer.
pub fn output_retrying_etxtbsy(cmd: &mut std::process::Command) -> std::io::Result<Output> {
    let mut delay = std::time::Duration::from_millis(10);
    for tries_left in [4u8, 3, 2, 1, 0] {
        match cmd.output() {
            Err(e) if tries_left > 0 && e.raw_os_error() == Some(26) => {
                std::thread::sleep(delay);
                delay *= 2;
            }
            other => return other,
        }
    }
    unreachable!("the zero-tries arm returns")
}