Skip to main content

amont_runtime/
pushed_tree.rs

1//! Run a pre-push check against what is being PUSHED.
2//!
3//! `rust-test` and `run-tests-js` take their file set from the pushed refs —
4//! correct — and then run the suite with `current_dir` set to the developer's
5//! working tree. So the suite can pass on an uncommitted fix, or fail on an
6//! uncommitted experiment, and in neither case has it tested the commits being
7//! pushed. Same gap as the pre-commit one, from the other end.
8//!
9//! ## Why not the stash
10//!
11//! Holding unstaged changes aside is the pre-commit answer, and it is the wrong
12//! instrument here. A push is not a staging operation: the difference that
13//! matters is not tree-versus-index but tree-versus-the-commit-you-are-sending,
14//! and that includes everything staged-but-uncommitted too. Setting all of it
15//! aside for the length of a test suite would leave the developer looking at a
16//! tree that is not theirs for minutes at a time.
17//!
18//! ## What it costs, which is the whole question
19//!
20//! `git worktree add --detach <tip>` materialises the pushed commit somewhere
21//! else and the suite runs there. The tree is untouched, and an interrupted run
22//! leaves a worktree rather than a mangled checkout.
23//!
24//! The cost is real: a second checkout, and a build that cannot reuse the
25//! primary tree's `target/` cache, so the first push after this lands is a cold
26//! build. That is why it is opt-in — `git config amont.testPushedTree true`
27//! — rather than the default. The default keeps today's behaviour and now SAYS
28//! what it is testing, which was the actual bug: not that it used the tree, but
29//! that nobody knew it did.
30
31use std::path::{Path, PathBuf};
32
33use crate::ui::warning_sign;
34
35/// Whether the user asked for the accurate-but-slower answer.
36///
37/// Read through [`crate::config`], so `on`, `yes` and every capitalisation work
38/// exactly as git-config(1) says they do — and a value git cannot parse takes
39/// the default while SAYING so, rather than reading as a quiet "no".
40pub fn enabled(settings: &crate::config::Settings) -> bool {
41    crate::config::boolean_or(settings, "amont.testPushedTree", false)
42}
43
44/// A command to run inside a fresh snapshot before any suite does.
45///
46/// A checkout is not a workspace: a pnpm monorepo has no `node_modules` in
47/// a worktree git just created, and a suite started there fails on
48/// `Cannot find module` having tested nothing. What makes it a workspace is
49/// per-repository knowledge — `pnpm install --offline --frozen-lockfile`,
50/// `npm ci`, nothing at all for a Rust crate — so it is a git-config value,
51/// set once per clone by the person who knows:
52///
53/// ```sh
54/// git config amont.snapshotPrepare "pnpm install --offline --frozen-lockfile"
55/// ```
56///
57/// Runs through the shell, in the snapshot, before the gate, and a failure
58/// is the snapshot's failure: no suite runs on a tree that was never made
59/// runnable. Used by both snapshot consumers — `amont.testPushedTree` at
60/// push time and the background rehearsal — so the two cannot drift.
61///
62/// The command gets `AMONT_SOURCE_WORKTREE`: the working tree the snapshot
63/// was taken from. Installing from scratch on every rehearsal is minutes of
64/// network and disk; a workspace is usually already installed right there,
65/// and a prepare step that knows where can reuse it — copy-on-write clone
66/// `node_modules` when the lockfile matches, install only when it does not:
67///
68/// ```sh
69/// git config amont.snapshotPrepare 'sh scripts/amont-snapshot-prepare.sh'
70/// ```
71const PREPARE: &str = "amont.snapshotPrepare";
72
73/// The environment variable naming the source working tree (see PREPARE).
74pub const SOURCE_WORKTREE_ENV: &str = "AMONT_SOURCE_WORKTREE";
75
76pub fn prepare_command(settings: &crate::config::Settings) -> Option<String> {
77    crate::config::string_value(settings, PREPARE).filter(|s| !s.trim().is_empty())
78}
79
80/// A checkout of the pushed commit, removed when it goes out of scope.
81pub struct PushedTree {
82    path: PathBuf,
83    repo: PathBuf,
84}
85
86impl PushedTree {
87    /// Materialise `tip`, or `None` when that is not possible — in which case
88    /// the caller falls back to the working tree and says so.
89    /// Takes the repository explicitly rather than relying on the working
90    /// directory: `set_current_dir` is process-global, so a test that changed
91    /// it would race every other test in the binary.
92    pub fn create(
93        settings: &crate::config::Settings,
94        repo: &Path,
95        tip: &str,
96    ) -> Option<PushedTree> {
97        let base = std::env::temp_dir().join(unique_name("amont-push"));
98        Self::create_at(settings, base, repo, tip)
99    }
100
101    /// The actual work, over an explicit path — split out so a test can hand
102    /// it a path it controls, since `create`'s own path is unpredictable BY
103    /// DESIGN and cannot be aimed at a fixture.
104    ///
105    /// `create_dir`, not a `remove_dir_all` before creating: that deleted
106    /// whatever was already at this path BEFORE this process had established
107    /// it owned it — an unpredictable name makes landing on an existing path
108    /// unlikely, not impossible, and "unlikely" is not the bar for a delete.
109    /// `create_dir` is exclusive: it fails loudly on anything already there
110    /// instead of removing it, and git's own `worktree add` is content to
111    /// receive a directory that already exists as long as it is empty —
112    /// which this one, having just been created, provably is.
113    fn create_at(
114        settings: &crate::config::Settings,
115        base: PathBuf,
116        repo: &Path,
117        tip: &str,
118    ) -> Option<PushedTree> {
119        std::fs::create_dir(&base).ok()?;
120        let ok = crate::git::succeeds(&[
121            "-C",
122            repo.to_str()?,
123            "worktree",
124            "add",
125            "--detach",
126            "--quiet",
127            base.to_str()?,
128            tip,
129        ]);
130        if !ok {
131            // Ours to clean up: we created it moments ago, so nothing else
132            // could have raced in ahead of us to make this delete unsafe.
133            let _ = std::fs::remove_dir_all(&base);
134            return None;
135        }
136        let tree = PushedTree {
137            path: base,
138            repo: repo.to_path_buf(),
139        };
140        if !tree.prepare(settings) {
141            // `Drop` removes the worktree; the caller hears `None` and says
142            // what it is doing instead.
143            return None;
144        }
145        Some(tree)
146    }
147
148    /// Run `amont.snapshotPrepare`, if set, inside the checkout. True when
149    /// there was nothing to run or it exited 0.
150    fn prepare(&self, settings: &crate::config::Settings) -> bool {
151        let Some(script) = prepare_command(settings) else {
152            return true;
153        };
154        println!("preparing the snapshot: {script}");
155        #[cfg(unix)]
156        let mut cmd = {
157            let mut c = std::process::Command::new("sh");
158            c.arg("-c").arg(&script);
159            c
160        };
161        #[cfg(not(unix))]
162        let mut cmd = {
163            let mut c = std::process::Command::new("cmd");
164            c.arg("/C").arg(&script);
165            c
166        };
167        cmd.current_dir(&self.path)
168            .stdin(std::process::Stdio::null());
169        crate::hooks::common::strip_git_env(&mut cmd);
170        // the working tree the snapshot was taken FROM — where an installed
171        // workspace already lives (see PREPARE)
172        cmd.env(SOURCE_WORKTREE_ENV, &self.repo);
173        let ok = crate::hooks::common::bounded_success(settings, &mut cmd, PREPARE);
174        if !ok {
175            println!(
176                "{} {PREPARE} failed in the snapshot — nothing can be tested there",
177                warning_sign()
178            );
179        }
180        ok
181    }
182
183    pub fn path(&self) -> &Path {
184        &self.path
185    }
186}
187
188/// `<prefix>-<pid>-<hash>`: the pid stays for a human correlating a leftover
189/// directory with a hung process, but the pid ALONE made the name guessable —
190/// `ps` hands it to anyone on the box — and a pre-planted directory or
191/// symlink at a predictable path in a world-writable `/tmp` is somebody
192/// else's decision about where our checkout goes.
193///
194/// It is no longer the difference between safe and destructive: `create_at`
195/// takes the path with an exclusive `create_dir` and refuses anything already
196/// there, rather than the `remove_dir_all`-first shape this comment used to
197/// describe. Unpredictability is now defence in depth — it means the refusal
198/// is not something an attacker can arrange on demand.
199///
200/// No dependency for real randomness here (this crate ships dependency-free),
201/// so the tail is a hash of the wall clock and the pid through
202/// `RandomState`'s own OS-seeded key — enough that guessing it in advance is
203/// impractical, not a cryptographic promise.
204fn unique_name(prefix: &str) -> String {
205    use std::collections::hash_map::RandomState;
206    use std::hash::{BuildHasher, Hash, Hasher};
207
208    let pid = std::process::id();
209    let mut hasher = RandomState::new().build_hasher();
210    pid.hash(&mut hasher);
211    std::time::SystemTime::now()
212        .duration_since(std::time::UNIX_EPOCH)
213        .unwrap_or_default()
214        .as_nanos()
215        .hash(&mut hasher);
216    format!("{prefix}-{pid}-{:016x}", hasher.finish())
217}
218
219impl Drop for PushedTree {
220    fn drop(&mut self) {
221        // `--force`: the suite may have written into it, and a build artefact
222        // must not be a reason to leave a worktree registered forever.
223        let _ = crate::git::succeeds(&[
224            "-C",
225            self.repo.to_str().unwrap_or_default(),
226            "worktree",
227            "remove",
228            "--force",
229            self.path.to_str().unwrap_or_default(),
230        ]);
231        let _ = std::fs::remove_dir_all(&self.path);
232    }
233}
234
235/// `(gate, tip)` pairs whose suite ACTUALLY ran in a checkout of `tip` —
236/// what happened, per check, as opposed to what `enabled()` says was asked
237/// for.
238///
239/// Read by `dispatch::stamp_tips`. It used to decide from the CONFIG plus a
240/// per-tip "the snapshot could not be made" set, which answers for the
241/// checks that asked for a snapshot and says nothing about one that never
242/// did: a declared `amont.conf` gate ran in the working tree, passed on an
243/// uncommitted fix, and was stamped onto a tip that had never been tested —
244/// so the next push of that tip skipped it. A stamp is a claim about one
245/// gate and one tree, and the record it is made from has to be that fine.
246///
247/// A `Mutex` because pre-push checks run concurrently and each loops over its
248/// own refs; a poisoned lock reads as "did not run there", which costs a
249/// redundant gate run and never a false stamp.
250static SNAPSHOTTED: std::sync::OnceLock<
251    std::sync::Mutex<std::collections::HashSet<(String, String)>>,
252> = std::sync::OnceLock::new();
253
254fn snapshotted() -> &'static std::sync::Mutex<std::collections::HashSet<(String, String)>> {
255    SNAPSHOTTED.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
256}
257
258fn note_snapshot(gate: &str, tip: &str) {
259    if let Ok(mut set) = snapshotted().lock() {
260        set.insert((gate.to_string(), tip.to_string()));
261    }
262}
263
264/// Did `gate` run in a checkout of `tip` — the pushed content itself, not
265/// whatever the working tree held?
266///
267/// `false` also when the lock is poisoned: a stamp is a claim that the gate
268/// ran on exactly this content, and a claim we cannot check is one we do not
269/// make.
270pub fn ran_on_tip(gate: &str, tip: &str) -> bool {
271    match snapshotted().lock() {
272        Ok(set) => set.contains(&(gate.to_string(), tip.to_string())),
273        Err(_) => false,
274    }
275}
276
277/// Where a pre-push suite should run, and whether that is the honest answer.
278///
279/// Takes ONE commit, not the whole ref list: a push can carry several refs
280/// (`git push origin a b`), each with its own tip, and a caller that checked
281/// out only the first one and ran every ref's tests against it would test
282/// the second ref's code against the first ref's tree. Callers loop over
283/// their own refs and pass each one's tip in turn.
284///
285/// `gate` is the check's id (`Check::name`) — what a stamp names, and what
286/// [`ran_on_tip`] is asked about when `dispatch::stamp_tips` decides whether
287/// this gate may vouch for this tip. A check that runs a suite anywhere
288/// else than the directory returned here is answering about content no
289/// stamp may claim.
290///
291/// Returns the directory plus the guard that owns it — dropping the guard
292/// removes the worktree, so the caller must hold it for the length of the run.
293pub fn where_to_run(
294    settings: &crate::config::Settings,
295    tip: &str,
296    fallback: &str,
297    gate: &str,
298) -> (PathBuf, Option<PushedTree>) {
299    // Inside a rehearsal the working tree IS a snapshot of the tip: a
300    // second checkout would test the same content twice, and the warning
301    // below would be false.
302    if crate::rehearsal::in_snapshot() {
303        return (PathBuf::from(fallback), None);
304    }
305    if !enabled(settings) {
306        // Today's behaviour, but no longer silent about it. NOT a fallback:
307        // nobody asked for a snapshot, and `stamp_tips` already knows to
308        // vouch for the working tree only when it IS the tip.
309        //
310        // Once per process: every gate in the push asks this, and one line
311        // says it — the fourth repetition would teach the reader to skip it.
312        static SAID: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
313        if !SAID.swap(true, std::sync::atomic::Ordering::Relaxed) {
314            println!(
315                "{} testing the WORKING TREE, not the pushed commits \
316                 (`git config amont.testPushedTree true` to test what you are pushing)",
317                warning_sign()
318            );
319        }
320        return (PathBuf::from(fallback), None);
321    }
322    match PushedTree::create(settings, Path::new(fallback), tip) {
323        Some(tree) => {
324            // Recorded, not inferred. `stamp_tips` used to read `enabled()`
325            // and conclude "the suite ran on the tip itself" — for every
326            // gate, including ones that never came here. Only a gate that
327            // was handed this checkout can be said to have run in it.
328            note_snapshot(gate, tip);
329            let path = tree.path().to_path_buf();
330            (path, Some(tree))
331        }
332        None => {
333            // Not recorded, and that IS the record: the gate passed on a
334            // possibly-dirty working tree, and stamping the tip anyway —
335            // which reading the config would do — let the next push of that
336            // tree (a retry after a dropped connection, or a tag on the
337            // merged commit) skip a gate nobody had run on its content.
338            // `gate_stamp`'s own promise is that no path here can let an
339            // unchecked commit through.
340            println!(
341                "{} could not check out {tip} to test it; testing the working tree instead \
342                 (nothing will be stamped for it)",
343                warning_sign()
344            );
345            (PathBuf::from(fallback), None)
346        }
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    fn test_settings() -> crate::config::Settings {
355        crate::config::Settings::default()
356    }
357
358    /// The whole point: two calls must not name the same path, or a
359    /// predictable name is right back to being predictable.
360    #[test]
361    fn unique_name_does_not_repeat() {
362        let a = unique_name("amont-push");
363        let b = unique_name("amont-push");
364        assert_ne!(a, b);
365        assert!(a.starts_with("amont-push-"));
366    }
367
368    /// The point of the whole fix: something already at the target path is
369    /// left ALONE, not deleted to make way. `create_at` fails closed
370    /// (`None`) instead of the old `remove_dir_all`-first shape, which would
371    /// have destroyed `sentinel.txt` here to clear the path for a worktree
372    /// that was never going to use it anyway (the repo below is fake).
373    #[test]
374    fn an_existing_path_is_left_alone_not_cleared() {
375        let base = std::env::temp_dir().join(format!("pushed-collision-{}", std::process::id()));
376        let _ = std::fs::remove_dir_all(&base);
377        std::fs::create_dir_all(&base).unwrap();
378        std::fs::write(base.join("sentinel.txt"), "do not delete me").unwrap();
379
380        let got = PushedTree::create_at(
381            &test_settings(),
382            base.clone(),
383            Path::new("/does/not/matter"),
384            "HEAD",
385        );
386        assert!(
387            got.is_none(),
388            "must refuse rather than reuse a path it did not create"
389        );
390        assert_eq!(
391            std::fs::read_to_string(base.join("sentinel.txt")).unwrap(),
392            "do not delete me",
393            "an existing path must never be cleared to make room"
394        );
395        let _ = std::fs::remove_dir_all(&base);
396    }
397
398    fn repo(name: &str) -> PathBuf {
399        let d = std::env::temp_dir().join(format!("pushed-{name}-{}", std::process::id()));
400        let _ = std::fs::remove_dir_all(&d);
401        std::fs::create_dir_all(&d).unwrap();
402        for args in [
403            vec!["init", "-q", "--template=", "."],
404            vec!["config", "user.email", "t@t.test"],
405            vec!["config", "user.name", "t"],
406            // Git for Windows rewrites line endings on checkout; a byte
407            // comparison would otherwise assert git's newline policy.
408            vec!["config", "core.autocrlf", "false"],
409        ] {
410            std::process::Command::new("git")
411                .args(&args)
412                .current_dir(&d)
413                .output()
414                .expect("git");
415        }
416        d
417    }
418
419    /// The point: the checkout holds the COMMIT, not whatever the developer
420    /// has open.
421    #[test]
422    fn the_worktree_holds_the_committed_content() {
423        let d = repo("tree");
424        let git = |args: &[&str]| {
425            std::process::Command::new("git")
426                .args(args)
427                .current_dir(&d)
428                .output()
429                .expect("git")
430        };
431        std::fs::write(d.join("a.txt"), "committed\n").unwrap();
432        git(&["add", "-A"]);
433        git(&["commit", "-qm", "seed"]);
434        let head = String::from_utf8_lossy(&git(&["rev-parse", "HEAD"]).stdout)
435            .trim()
436            .to_string();
437        // Uncommitted, and it must not travel.
438        std::fs::write(d.join("a.txt"), "dirty, not pushed\n").unwrap();
439
440        let tree = PushedTree::create(&test_settings(), &d, &head).expect("worktree");
441        let seen = std::fs::read_to_string(tree.path().join("a.txt")).unwrap();
442        let at = tree.path().to_path_buf();
443        drop(tree);
444
445        assert_eq!(seen, "committed\n", "the worktree saw the dirty tree");
446        assert!(!at.exists(), "the worktree outlived its guard");
447        let _ = std::fs::remove_dir_all(&d);
448    }
449}