amont-runtime 1.46.1

The amont hook logic: registry, dispatchers, checks and the trust model
Documentation
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
//! Run a pre-push check against what is being PUSHED.
//!
//! `rust-test` and `run-tests-js` take their file set from the pushed refs —
//! correct — and then run the suite with `current_dir` set to the developer's
//! working tree. So the suite can pass on an uncommitted fix, or fail on an
//! uncommitted experiment, and in neither case has it tested the commits being
//! pushed. Same gap as the pre-commit one, from the other end.
//!
//! ## Why not the stash
//!
//! Holding unstaged changes aside is the pre-commit answer, and it is the wrong
//! instrument here. A push is not a staging operation: the difference that
//! matters is not tree-versus-index but tree-versus-the-commit-you-are-sending,
//! and that includes everything staged-but-uncommitted too. Setting all of it
//! aside for the length of a test suite would leave the developer looking at a
//! tree that is not theirs for minutes at a time.
//!
//! ## What it costs, which is the whole question
//!
//! `git worktree add --detach <tip>` materialises the pushed commit somewhere
//! else and the suite runs there. The tree is untouched, and an interrupted run
//! leaves a worktree rather than a mangled checkout.
//!
//! The cost is real: a second checkout, and a build that cannot reuse the
//! primary tree's `target/` cache, so the first push after this lands is a cold
//! build. That is why it is opt-in — `git config amont.testPushedTree true`
//! — rather than the default. The default keeps today's behaviour and now SAYS
//! what it is testing, which was the actual bug: not that it used the tree, but
//! that nobody knew it did.

use std::path::{Path, PathBuf};

use crate::ui::warning_sign;

/// Whether the user asked for the accurate-but-slower answer.
///
/// Read through [`crate::config`], so `on`, `yes` and every capitalisation work
/// exactly as git-config(1) says they do — and a value git cannot parse takes
/// the default while SAYING so, rather than reading as a quiet "no".
pub fn enabled(settings: &crate::config::Settings) -> bool {
    crate::config::boolean_or(settings, "amont.testPushedTree", false)
}

/// The escape hatch: a command to run inside a fresh snapshot before any
/// suite does, after amont's own preparation (see [`crate::snapshot_prep`]:
/// `amont.snapshotCarry`, then the JavaScript dependencies).
///
/// A checkout is not a workspace, and most of what makes one is now
/// amont's: `npm ci` / `pnpm install --frozen-lockfile` per lockfile, and
/// the untracked files the repository names. What is left is per-repository
/// knowledge amont cannot have — a code generator, a database client, a
/// build step:
///
/// ```sh
/// git config amont.snapshotPrepare "pnpm prisma generate"
/// ```
///
/// When it is set it OWNS the dependencies — amont's own install step stands
/// down, so a repository that already installs here does not install twice.
/// Runs through the shell, in the snapshot, before the gate, and a failure
/// is the snapshot's failure: no suite runs on a tree that was never made
/// runnable. Used by both snapshot consumers — `amont.testPushedTree` at
/// push time and the background rehearsal — so the two cannot drift. It gets
/// `AMONT_SOURCE_WORKTREE`: the working tree the snapshot was taken from.
const PREPARE: &str = "amont.snapshotPrepare";

/// The environment variable naming the source working tree (see PREPARE).
pub const SOURCE_WORKTREE_ENV: &str = "AMONT_SOURCE_WORKTREE";

pub fn prepare_command(settings: &crate::config::Settings) -> Option<String> {
    crate::config::string_value(settings, PREPARE).filter(|s| !s.trim().is_empty())
}

/// A checkout of the pushed commit, removed when it goes out of scope.
pub struct PushedTree {
    path: PathBuf,
    repo: PathBuf,
    /// The commit checked out — what preparation scopes the push by.
    tip: String,
}

impl PushedTree {
    /// Materialise `tip`, or `None` when that is not possible — in which case
    /// the caller falls back to the working tree and says so.
    /// Takes the repository explicitly rather than relying on the working
    /// directory: `set_current_dir` is process-global, so a test that changed
    /// it would race every other test in the binary.
    ///
    /// Checkout AND preparation, for the push-time caller: a snapshot that
    /// could not be prepared is no snapshot, said once, here.
    pub fn create(
        settings: &crate::config::Settings,
        repo: &Path,
        tip: &str,
    ) -> Option<PushedTree> {
        let tree = Self::checkout(repo, tip)?;
        match tree.prepare(settings) {
            Ok(()) => Some(tree),
            Err(why) => {
                // `Drop` removes the worktree; the caller hears `None` and
                // says what it is doing instead.
                println!("{} could not prepare the snapshot: {why}", warning_sign());
                None
            }
        }
    }

    /// The checkout alone. The rehearsal registers itself between this and
    /// [`PushedTree::prepare`], so an install that takes minutes is visible —
    /// to `--status`, to a push that would otherwise start over, and to a
    /// newer commit that must cancel it.
    pub fn checkout(repo: &Path, tip: &str) -> Option<PushedTree> {
        let base = std::env::temp_dir().join(unique_name("amont-push"));
        Self::checkout_at(base, repo, tip)
    }

    /// The actual work, over an explicit path — split out so a test can hand
    /// it a path it controls, since `create`'s own path is unpredictable BY
    /// DESIGN and cannot be aimed at a fixture.
    ///
    /// `create_dir`, not a `remove_dir_all` before creating: that deleted
    /// whatever was already at this path BEFORE this process had established
    /// it owned it — an unpredictable name makes landing on an existing path
    /// unlikely, not impossible, and "unlikely" is not the bar for a delete.
    /// `create_dir` is exclusive: it fails loudly on anything already there
    /// instead of removing it, and git's own `worktree add` is content to
    /// receive a directory that already exists as long as it is empty —
    /// which this one, having just been created, provably is.
    fn checkout_at(base: PathBuf, repo: &Path, tip: &str) -> Option<PushedTree> {
        std::fs::create_dir(&base).ok()?;
        let ok = crate::git::succeeds(&[
            "-C",
            repo.to_str()?,
            "worktree",
            "add",
            "--detach",
            "--quiet",
            base.to_str()?,
            tip,
        ]);
        if !ok {
            // Ours to clean up: we created it moments ago, so nothing else
            // could have raced in ahead of us to make this delete unsafe.
            let _ = std::fs::remove_dir_all(&base);
            return None;
        }
        Some(PushedTree {
            path: base,
            repo: repo.to_path_buf(),
            tip: tip.to_string(),
        })
    }

    /// Make the checkout runnable: carry, dependencies, then
    /// `amont.snapshotPrepare`. `Err` says why, in one line.
    pub fn prepare(&self, settings: &crate::config::Settings) -> Result<(), String> {
        let script = prepare_command(settings);
        crate::snapshot_prep::run(
            settings,
            &self.repo,
            &self.path,
            &self.tip,
            script.is_some(),
        )?;
        let Some(script) = script else {
            return Ok(());
        };
        println!("preparing the snapshot: {script}");
        #[cfg(unix)]
        let mut cmd = {
            let mut c = std::process::Command::new("sh");
            c.arg("-c").arg(&script);
            c
        };
        #[cfg(not(unix))]
        let mut cmd = {
            let mut c = std::process::Command::new("cmd");
            c.arg("/C").arg(&script);
            c
        };
        cmd.current_dir(&self.path)
            .stdin(std::process::Stdio::null());
        crate::hooks::common::strip_git_env(&mut cmd);
        // the working tree the snapshot was taken FROM — where an installed
        // workspace already lives (see PREPARE)
        cmd.env(SOURCE_WORKTREE_ENV, &self.repo);
        if crate::hooks::common::bounded_success(settings, &mut cmd, PREPARE) {
            Ok(())
        } else {
            Err(format!("{PREPARE} failed in the snapshot"))
        }
    }

    pub fn path(&self) -> &Path {
        &self.path
    }
}

/// `<prefix>-<pid>-<hash>`: the pid stays for a human correlating a leftover
/// directory with a hung process, but the pid ALONE made the name guessable —
/// `ps` hands it to anyone on the box — and a pre-planted directory or
/// symlink at a predictable path in a world-writable `/tmp` is somebody
/// else's decision about where our checkout goes.
///
/// It is no longer the difference between safe and destructive: `create_at`
/// takes the path with an exclusive `create_dir` and refuses anything already
/// there, rather than the `remove_dir_all`-first shape this comment used to
/// describe. Unpredictability is now defence in depth — it means the refusal
/// is not something an attacker can arrange on demand.
///
/// No dependency for real randomness here (this crate ships dependency-free),
/// so the tail is a hash of the wall clock and the pid through
/// `RandomState`'s own OS-seeded key — enough that guessing it in advance is
/// impractical, not a cryptographic promise.
fn unique_name(prefix: &str) -> String {
    use std::collections::hash_map::RandomState;
    use std::hash::{BuildHasher, Hash, Hasher};

    let pid = std::process::id();
    let mut hasher = RandomState::new().build_hasher();
    pid.hash(&mut hasher);
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default()
        .as_nanos()
        .hash(&mut hasher);
    format!("{prefix}-{pid}-{:016x}", hasher.finish())
}

impl Drop for PushedTree {
    fn drop(&mut self) {
        // `--force`: the suite may have written into it, and a build artefact
        // must not be a reason to leave a worktree registered forever.
        let _ = crate::git::succeeds(&[
            "-C",
            self.repo.to_str().unwrap_or_default(),
            "worktree",
            "remove",
            "--force",
            self.path.to_str().unwrap_or_default(),
        ]);
        let _ = std::fs::remove_dir_all(&self.path);
    }
}

/// `(gate, tip)` pairs whose suite ACTUALLY ran in a checkout of `tip` —
/// what happened, per check, as opposed to what `enabled()` says was asked
/// for.
///
/// Read by `dispatch::stamp_tips`. It used to decide from the CONFIG plus a
/// per-tip "the snapshot could not be made" set, which answers for the
/// checks that asked for a snapshot and says nothing about one that never
/// did: a declared `amont.conf` gate ran in the working tree, passed on an
/// uncommitted fix, and was stamped onto a tip that had never been tested —
/// so the next push of that tip skipped it. A stamp is a claim about one
/// gate and one tree, and the record it is made from has to be that fine.
///
/// A `Mutex` because pre-push checks run concurrently and each loops over its
/// own refs; a poisoned lock reads as "did not run there", which costs a
/// redundant gate run and never a false stamp.
static SNAPSHOTTED: std::sync::OnceLock<
    std::sync::Mutex<std::collections::HashSet<(String, String)>>,
> = std::sync::OnceLock::new();

fn snapshotted() -> &'static std::sync::Mutex<std::collections::HashSet<(String, String)>> {
    SNAPSHOTTED.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
}

fn note_snapshot(gate: &str, tip: &str) {
    if let Ok(mut set) = snapshotted().lock() {
        set.insert((gate.to_string(), tip.to_string()));
    }
}

/// Did `gate` run in a checkout of `tip` — the pushed content itself, not
/// whatever the working tree held?
///
/// `false` also when the lock is poisoned: a stamp is a claim that the gate
/// ran on exactly this content, and a claim we cannot check is one we do not
/// make.
pub fn ran_on_tip(gate: &str, tip: &str) -> bool {
    match snapshotted().lock() {
        Ok(set) => set.contains(&(gate.to_string(), tip.to_string())),
        Err(_) => false,
    }
}

/// Where a pre-push suite should run, and whether that is the honest answer.
///
/// Takes ONE commit, not the whole ref list: a push can carry several refs
/// (`git push origin a b`), each with its own tip, and a caller that checked
/// out only the first one and ran every ref's tests against it would test
/// the second ref's code against the first ref's tree. Callers loop over
/// their own refs and pass each one's tip in turn.
///
/// `gate` is the check's id (`Check::name`) — what a stamp names, and what
/// [`ran_on_tip`] is asked about when `dispatch::stamp_tips` decides whether
/// this gate may vouch for this tip. A check that runs a suite anywhere
/// else than the directory returned here is answering about content no
/// stamp may claim.
///
/// Returns the directory plus the guard that owns it — dropping the guard
/// removes the worktree, so the caller must hold it for the length of the run.
pub fn where_to_run(
    settings: &crate::config::Settings,
    tip: &str,
    fallback: &str,
    gate: &str,
) -> (PathBuf, Option<PushedTree>) {
    // Inside a rehearsal the working tree IS a snapshot of the tip: a
    // second checkout would test the same content twice, and the warning
    // below would be false.
    if crate::rehearsal::in_snapshot() {
        return (PathBuf::from(fallback), None);
    }
    if !enabled(settings) {
        // Today's behaviour, but no longer silent about it. NOT a fallback:
        // nobody asked for a snapshot, and `stamp_tips` already knows to
        // vouch for the working tree only when it IS the tip.
        //
        // Once per process: every gate in the push asks this, and one line
        // says it — the fourth repetition would teach the reader to skip it.
        static SAID: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
        if !SAID.swap(true, std::sync::atomic::Ordering::Relaxed) {
            println!(
                "{} testing the WORKING TREE, not the pushed commits \
                 (`git config amont.testPushedTree true` to test what you are pushing)",
                warning_sign()
            );
        }
        return (PathBuf::from(fallback), None);
    }
    match PushedTree::create(settings, Path::new(fallback), tip) {
        Some(tree) => {
            // Recorded, not inferred. `stamp_tips` used to read `enabled()`
            // and conclude "the suite ran on the tip itself" — for every
            // gate, including ones that never came here. Only a gate that
            // was handed this checkout can be said to have run in it.
            note_snapshot(gate, tip);
            let path = tree.path().to_path_buf();
            (path, Some(tree))
        }
        None => {
            // Not recorded, and that IS the record: the gate passed on a
            // possibly-dirty working tree, and stamping the tip anyway —
            // which reading the config would do — let the next push of that
            // tree (a retry after a dropped connection, or a tag on the
            // merged commit) skip a gate nobody had run on its content.
            // `gate_stamp`'s own promise is that no path here can let an
            // unchecked commit through.
            println!(
                "{} could not check out {tip} to test it; testing the working tree instead \
                 (nothing will be stamped for it)",
                warning_sign()
            );
            (PathBuf::from(fallback), None)
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn test_settings() -> crate::config::Settings {
        crate::config::Settings::default()
    }

    /// The whole point: two calls must not name the same path, or a
    /// predictable name is right back to being predictable.
    #[test]
    fn unique_name_does_not_repeat() {
        let a = unique_name("amont-push");
        let b = unique_name("amont-push");
        assert_ne!(a, b);
        assert!(a.starts_with("amont-push-"));
    }

    /// The point of the whole fix: something already at the target path is
    /// left ALONE, not deleted to make way. `create_at` fails closed
    /// (`None`) instead of the old `remove_dir_all`-first shape, which would
    /// have destroyed `sentinel.txt` here to clear the path for a worktree
    /// that was never going to use it anyway (the repo below is fake).
    #[test]
    fn an_existing_path_is_left_alone_not_cleared() {
        let base = std::env::temp_dir().join(format!("pushed-collision-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&base);
        std::fs::create_dir_all(&base).unwrap();
        std::fs::write(base.join("sentinel.txt"), "do not delete me").unwrap();

        let got = PushedTree::checkout_at(base.clone(), Path::new("/does/not/matter"), "HEAD");
        assert!(
            got.is_none(),
            "must refuse rather than reuse a path it did not create"
        );
        assert_eq!(
            std::fs::read_to_string(base.join("sentinel.txt")).unwrap(),
            "do not delete me",
            "an existing path must never be cleared to make room"
        );
        let _ = std::fs::remove_dir_all(&base);
    }

    fn repo(name: &str) -> PathBuf {
        let d = std::env::temp_dir().join(format!("pushed-{name}-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&d);
        std::fs::create_dir_all(&d).unwrap();
        for args in [
            vec!["init", "-q", "--template=", "."],
            vec!["config", "user.email", "t@t.test"],
            vec!["config", "user.name", "t"],
            // Git for Windows rewrites line endings on checkout; a byte
            // comparison would otherwise assert git's newline policy.
            vec!["config", "core.autocrlf", "false"],
        ] {
            std::process::Command::new("git")
                .args(&args)
                .current_dir(&d)
                .output()
                .expect("git");
        }
        d
    }

    /// The point: the checkout holds the COMMIT, not whatever the developer
    /// has open.
    #[test]
    fn the_worktree_holds_the_committed_content() {
        let d = repo("tree");
        let git = |args: &[&str]| {
            std::process::Command::new("git")
                .args(args)
                .current_dir(&d)
                .output()
                .expect("git")
        };
        std::fs::write(d.join("a.txt"), "committed\n").unwrap();
        git(&["add", "-A"]);
        git(&["commit", "-qm", "seed"]);
        let head = String::from_utf8_lossy(&git(&["rev-parse", "HEAD"]).stdout)
            .trim()
            .to_string();
        // Uncommitted, and it must not travel.
        std::fs::write(d.join("a.txt"), "dirty, not pushed\n").unwrap();

        let tree = PushedTree::create(&test_settings(), &d, &head).expect("worktree");
        let seen = std::fs::read_to_string(tree.path().join("a.txt")).unwrap();
        let at = tree.path().to_path_buf();
        drop(tree);

        assert_eq!(seen, "committed\n", "the worktree saw the dirty tree");
        assert!(!at.exists(), "the worktree outlived its guard");
        let _ = std::fs::remove_dir_all(&d);
    }
}