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