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.
61const PREPARE: &str = "amont.snapshotPrepare";
62
63pub fn prepare_command(settings: &crate::config::Settings) -> Option<String> {
64    crate::config::string_value(settings, PREPARE).filter(|s| !s.trim().is_empty())
65}
66
67/// A checkout of the pushed commit, removed when it goes out of scope.
68pub struct PushedTree {
69    path: PathBuf,
70    repo: PathBuf,
71}
72
73impl PushedTree {
74    /// Materialise `tip`, or `None` when that is not possible — in which case
75    /// the caller falls back to the working tree and says so.
76    /// Takes the repository explicitly rather than relying on the working
77    /// directory: `set_current_dir` is process-global, so a test that changed
78    /// it would race every other test in the binary.
79    pub fn create(
80        settings: &crate::config::Settings,
81        repo: &Path,
82        tip: &str,
83    ) -> Option<PushedTree> {
84        let base = std::env::temp_dir().join(unique_name("amont-push"));
85        Self::create_at(settings, base, repo, tip)
86    }
87
88    /// The actual work, over an explicit path — split out so a test can hand
89    /// it a path it controls, since `create`'s own path is unpredictable BY
90    /// DESIGN and cannot be aimed at a fixture.
91    ///
92    /// `create_dir`, not a `remove_dir_all` before creating: that deleted
93    /// whatever was already at this path BEFORE this process had established
94    /// it owned it — an unpredictable name makes landing on an existing path
95    /// unlikely, not impossible, and "unlikely" is not the bar for a delete.
96    /// `create_dir` is exclusive: it fails loudly on anything already there
97    /// instead of removing it, and git's own `worktree add` is content to
98    /// receive a directory that already exists as long as it is empty —
99    /// which this one, having just been created, provably is.
100    fn create_at(
101        settings: &crate::config::Settings,
102        base: PathBuf,
103        repo: &Path,
104        tip: &str,
105    ) -> Option<PushedTree> {
106        std::fs::create_dir(&base).ok()?;
107        let ok = crate::git::succeeds(&[
108            "-C",
109            repo.to_str()?,
110            "worktree",
111            "add",
112            "--detach",
113            "--quiet",
114            base.to_str()?,
115            tip,
116        ]);
117        if !ok {
118            // Ours to clean up: we created it moments ago, so nothing else
119            // could have raced in ahead of us to make this delete unsafe.
120            let _ = std::fs::remove_dir_all(&base);
121            return None;
122        }
123        let tree = PushedTree {
124            path: base,
125            repo: repo.to_path_buf(),
126        };
127        if !tree.prepare(settings) {
128            // `Drop` removes the worktree; the caller hears `None` and says
129            // what it is doing instead.
130            return None;
131        }
132        Some(tree)
133    }
134
135    /// Run `amont.snapshotPrepare`, if set, inside the checkout. True when
136    /// there was nothing to run or it exited 0.
137    fn prepare(&self, settings: &crate::config::Settings) -> bool {
138        let Some(script) = prepare_command(settings) else {
139            return true;
140        };
141        println!("preparing the snapshot: {script}");
142        #[cfg(unix)]
143        let mut cmd = {
144            let mut c = std::process::Command::new("sh");
145            c.arg("-c").arg(&script);
146            c
147        };
148        #[cfg(not(unix))]
149        let mut cmd = {
150            let mut c = std::process::Command::new("cmd");
151            c.arg("/C").arg(&script);
152            c
153        };
154        cmd.current_dir(&self.path)
155            .stdin(std::process::Stdio::null());
156        crate::hooks::common::strip_git_env(&mut cmd);
157        let ok = crate::hooks::common::bounded_success(settings, &mut cmd, PREPARE);
158        if !ok {
159            println!(
160                "{} {PREPARE} failed in the snapshot — nothing can be tested there",
161                warning_sign()
162            );
163        }
164        ok
165    }
166
167    pub fn path(&self) -> &Path {
168        &self.path
169    }
170}
171
172/// `<prefix>-<pid>-<hash>`: the pid stays for a human correlating a leftover
173/// directory with a hung process, but the pid ALONE made the name guessable —
174/// `ps` hands it to anyone on the box — and a pre-planted directory or
175/// symlink at a predictable path in a world-writable `/tmp` is somebody
176/// else's decision about where our checkout goes.
177///
178/// It is no longer the difference between safe and destructive: `create_at`
179/// takes the path with an exclusive `create_dir` and refuses anything already
180/// there, rather than the `remove_dir_all`-first shape this comment used to
181/// describe. Unpredictability is now defence in depth — it means the refusal
182/// is not something an attacker can arrange on demand.
183///
184/// No dependency for real randomness here (this crate ships dependency-free),
185/// so the tail is a hash of the wall clock and the pid through
186/// `RandomState`'s own OS-seeded key — enough that guessing it in advance is
187/// impractical, not a cryptographic promise.
188fn unique_name(prefix: &str) -> String {
189    use std::collections::hash_map::RandomState;
190    use std::hash::{BuildHasher, Hash, Hasher};
191
192    let pid = std::process::id();
193    let mut hasher = RandomState::new().build_hasher();
194    pid.hash(&mut hasher);
195    std::time::SystemTime::now()
196        .duration_since(std::time::UNIX_EPOCH)
197        .unwrap_or_default()
198        .as_nanos()
199        .hash(&mut hasher);
200    format!("{prefix}-{pid}-{:016x}", hasher.finish())
201}
202
203impl Drop for PushedTree {
204    fn drop(&mut self) {
205        // `--force`: the suite may have written into it, and a build artefact
206        // must not be a reason to leave a worktree registered forever.
207        let _ = crate::git::succeeds(&[
208            "-C",
209            self.repo.to_str().unwrap_or_default(),
210            "worktree",
211            "remove",
212            "--force",
213            self.path.to_str().unwrap_or_default(),
214        ]);
215        let _ = std::fs::remove_dir_all(&self.path);
216    }
217}
218
219/// Tips whose snapshot was ASKED FOR and could not be made, so their suite
220/// ran against the working tree after all.
221///
222/// Read by `dispatch::stamp_tips`, which otherwise decides a tip is vouchable
223/// from `enabled()` — the CONFIG — and would stamp a tip whose content was
224/// never tested. See [`fell_back`].
225///
226/// A `Mutex` because pre-push checks run concurrently and each loops over its
227/// own refs; a poisoned lock is treated as "it fell back", which costs a
228/// redundant gate run and never a false stamp.
229static FELL_BACK: std::sync::OnceLock<std::sync::Mutex<std::collections::HashSet<String>>> =
230    std::sync::OnceLock::new();
231
232fn fallbacks() -> &'static std::sync::Mutex<std::collections::HashSet<String>> {
233    FELL_BACK.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
234}
235
236fn note_fallback(tip: &str) {
237    if let Ok(mut set) = fallbacks().lock() {
238        set.insert(tip.to_string());
239    }
240}
241
242/// Did the snapshot for `tip` fail, leaving its suite on the working tree?
243///
244/// `true` also when the lock is poisoned: a stamp is a claim that the gate
245/// ran on exactly this content, and a claim we cannot check is one we do not
246/// make.
247pub fn fell_back(tip: &str) -> bool {
248    match fallbacks().lock() {
249        Ok(set) => set.contains(tip),
250        Err(_) => true,
251    }
252}
253
254/// Where a pre-push suite should run, and whether that is the honest answer.
255///
256/// Takes ONE commit, not the whole ref list: a push can carry several refs
257/// (`git push origin a b`), each with its own tip, and a caller that checked
258/// out only the first one and ran every ref's tests against it would test
259/// the second ref's code against the first ref's tree. Callers loop over
260/// their own refs and pass each one's tip in turn.
261///
262/// Returns the directory plus the guard that owns it — dropping the guard
263/// removes the worktree, so the caller must hold it for the length of the run.
264pub fn where_to_run(
265    settings: &crate::config::Settings,
266    tip: &str,
267    fallback: &str,
268) -> (PathBuf, Option<PushedTree>) {
269    // Inside a rehearsal the working tree IS a snapshot of the tip: a
270    // second checkout would test the same content twice, and the warning
271    // below would be false.
272    if crate::rehearsal::in_snapshot() {
273        return (PathBuf::from(fallback), None);
274    }
275    if !enabled(settings) {
276        // Today's behaviour, but no longer silent about it. NOT a fallback:
277        // nobody asked for a snapshot, and `stamp_tips` already knows to
278        // vouch for the working tree only when it IS the tip.
279        println!(
280            "{} testing the WORKING TREE, not the pushed commits \
281             (`git config amont.testPushedTree true` to test what you are pushing)",
282            warning_sign()
283        );
284        return (PathBuf::from(fallback), None);
285    }
286    match PushedTree::create(settings, Path::new(fallback), tip) {
287        Some(tree) => {
288            let path = tree.path().to_path_buf();
289            (path, Some(tree))
290        }
291        None => {
292            // Recorded, not just printed. `stamp_tips` used to read
293            // `enabled()` and conclude "the suite ran on the tip itself",
294            // which is exactly what did NOT happen here: the gate passed on
295            // a possibly-dirty working tree and the tip was stamped anyway,
296            // so the next push of that tree — a retry after a dropped
297            // connection, or a tag on the merged commit — skipped a gate
298            // nobody had run on its content. `gate_stamp`'s own promise is
299            // that no path here can let an unchecked commit through.
300            note_fallback(tip);
301            println!(
302                "{} could not check out {tip} to test it; testing the working tree instead \
303                 (nothing will be stamped for it)",
304                warning_sign()
305            );
306            (PathBuf::from(fallback), None)
307        }
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    fn test_settings() -> crate::config::Settings {
316        crate::config::Settings::default()
317    }
318
319    /// The whole point: two calls must not name the same path, or a
320    /// predictable name is right back to being predictable.
321    #[test]
322    fn unique_name_does_not_repeat() {
323        let a = unique_name("amont-push");
324        let b = unique_name("amont-push");
325        assert_ne!(a, b);
326        assert!(a.starts_with("amont-push-"));
327    }
328
329    /// The point of the whole fix: something already at the target path is
330    /// left ALONE, not deleted to make way. `create_at` fails closed
331    /// (`None`) instead of the old `remove_dir_all`-first shape, which would
332    /// have destroyed `sentinel.txt` here to clear the path for a worktree
333    /// that was never going to use it anyway (the repo below is fake).
334    #[test]
335    fn an_existing_path_is_left_alone_not_cleared() {
336        let base = std::env::temp_dir().join(format!("pushed-collision-{}", std::process::id()));
337        let _ = std::fs::remove_dir_all(&base);
338        std::fs::create_dir_all(&base).unwrap();
339        std::fs::write(base.join("sentinel.txt"), "do not delete me").unwrap();
340
341        let got = PushedTree::create_at(
342            &test_settings(),
343            base.clone(),
344            Path::new("/does/not/matter"),
345            "HEAD",
346        );
347        assert!(
348            got.is_none(),
349            "must refuse rather than reuse a path it did not create"
350        );
351        assert_eq!(
352            std::fs::read_to_string(base.join("sentinel.txt")).unwrap(),
353            "do not delete me",
354            "an existing path must never be cleared to make room"
355        );
356        let _ = std::fs::remove_dir_all(&base);
357    }
358
359    fn repo(name: &str) -> PathBuf {
360        let d = std::env::temp_dir().join(format!("pushed-{name}-{}", std::process::id()));
361        let _ = std::fs::remove_dir_all(&d);
362        std::fs::create_dir_all(&d).unwrap();
363        for args in [
364            vec!["init", "-q", "--template=", "."],
365            vec!["config", "user.email", "t@t.test"],
366            vec!["config", "user.name", "t"],
367            // Git for Windows rewrites line endings on checkout; a byte
368            // comparison would otherwise assert git's newline policy.
369            vec!["config", "core.autocrlf", "false"],
370        ] {
371            std::process::Command::new("git")
372                .args(&args)
373                .current_dir(&d)
374                .output()
375                .expect("git");
376        }
377        d
378    }
379
380    /// The point: the checkout holds the COMMIT, not whatever the developer
381    /// has open.
382    #[test]
383    fn the_worktree_holds_the_committed_content() {
384        let d = repo("tree");
385        let git = |args: &[&str]| {
386            std::process::Command::new("git")
387                .args(args)
388                .current_dir(&d)
389                .output()
390                .expect("git")
391        };
392        std::fs::write(d.join("a.txt"), "committed\n").unwrap();
393        git(&["add", "-A"]);
394        git(&["commit", "-qm", "seed"]);
395        let head = String::from_utf8_lossy(&git(&["rev-parse", "HEAD"]).stdout)
396            .trim()
397            .to_string();
398        // Uncommitted, and it must not travel.
399        std::fs::write(d.join("a.txt"), "dirty, not pushed\n").unwrap();
400
401        let tree = PushedTree::create(&test_settings(), &d, &head).expect("worktree");
402        let seen = std::fs::read_to_string(tree.path().join("a.txt")).unwrap();
403        let at = tree.path().to_path_buf();
404        drop(tree);
405
406        assert_eq!(seen, "committed\n", "the worktree saw the dirty tree");
407        assert!(!at.exists(), "the worktree outlived its guard");
408        let _ = std::fs::remove_dir_all(&d);
409    }
410}