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() -> bool {
41 crate::config::boolean_or("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() -> Option<String> {
64 crate::config::string_value(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(repo: &Path, tip: &str) -> Option<PushedTree> {
80 let base = std::env::temp_dir().join(unique_name("amont-push"));
81 Self::create_at(base, repo, tip)
82 }
83
84 /// The actual work, over an explicit path — split out so a test can hand
85 /// it a path it controls, since `create`'s own path is unpredictable BY
86 /// DESIGN and cannot be aimed at a fixture.
87 ///
88 /// `create_dir`, not a `remove_dir_all` before creating: that deleted
89 /// whatever was already at this path BEFORE this process had established
90 /// it owned it — an unpredictable name makes landing on an existing path
91 /// unlikely, not impossible, and "unlikely" is not the bar for a delete.
92 /// `create_dir` is exclusive: it fails loudly on anything already there
93 /// instead of removing it, and git's own `worktree add` is content to
94 /// receive a directory that already exists as long as it is empty —
95 /// which this one, having just been created, provably is.
96 fn create_at(base: PathBuf, repo: &Path, tip: &str) -> Option<PushedTree> {
97 std::fs::create_dir(&base).ok()?;
98 let ok = crate::git::succeeds(&[
99 "-C",
100 repo.to_str()?,
101 "worktree",
102 "add",
103 "--detach",
104 "--quiet",
105 base.to_str()?,
106 tip,
107 ]);
108 if !ok {
109 // Ours to clean up: we created it moments ago, so nothing else
110 // could have raced in ahead of us to make this delete unsafe.
111 let _ = std::fs::remove_dir_all(&base);
112 return None;
113 }
114 let tree = PushedTree {
115 path: base,
116 repo: repo.to_path_buf(),
117 };
118 if !tree.prepare() {
119 // `Drop` removes the worktree; the caller hears `None` and says
120 // what it is doing instead.
121 return None;
122 }
123 Some(tree)
124 }
125
126 /// Run `amont.snapshotPrepare`, if set, inside the checkout. True when
127 /// there was nothing to run or it exited 0.
128 fn prepare(&self) -> bool {
129 let Some(script) = prepare_command() else {
130 return true;
131 };
132 println!("preparing the snapshot: {script}");
133 #[cfg(unix)]
134 let mut cmd = {
135 let mut c = std::process::Command::new("sh");
136 c.arg("-c").arg(&script);
137 c
138 };
139 #[cfg(not(unix))]
140 let mut cmd = {
141 let mut c = std::process::Command::new("cmd");
142 c.arg("/C").arg(&script);
143 c
144 };
145 cmd.current_dir(&self.path)
146 .stdin(std::process::Stdio::null());
147 crate::hooks::common::strip_git_env(&mut cmd);
148 let ok = crate::hooks::common::bounded_success(&mut cmd, PREPARE);
149 if !ok {
150 println!(
151 "{} {PREPARE} failed in the snapshot — nothing can be tested there",
152 warning_sign()
153 );
154 }
155 ok
156 }
157
158 pub fn path(&self) -> &Path {
159 &self.path
160 }
161}
162
163/// `<prefix>-<pid>-<hash>`: the pid stays for a human correlating a leftover
164/// directory with a hung process, but the pid ALONE is what made the old name
165/// guessable — `ps` hands it to anyone on the box — which matters because
166/// this path is `remove_dir_all`'d before it is used. A pre-planted symlink
167/// at a predictable name turns that into a race with whatever the symlink
168/// points at. No dependency for real randomness here (this crate ships
169/// dependency-free), so the tail is a hash of the wall clock and the pid
170/// through `RandomState`'s own OS-seeded key — enough that guessing it in
171/// advance is impractical, not a cryptographic promise.
172fn unique_name(prefix: &str) -> String {
173 use std::collections::hash_map::RandomState;
174 use std::hash::{BuildHasher, Hash, Hasher};
175
176 let pid = std::process::id();
177 let mut hasher = RandomState::new().build_hasher();
178 pid.hash(&mut hasher);
179 std::time::SystemTime::now()
180 .duration_since(std::time::UNIX_EPOCH)
181 .unwrap_or_default()
182 .as_nanos()
183 .hash(&mut hasher);
184 format!("{prefix}-{pid}-{:016x}", hasher.finish())
185}
186
187impl Drop for PushedTree {
188 fn drop(&mut self) {
189 // `--force`: the suite may have written into it, and a build artefact
190 // must not be a reason to leave a worktree registered forever.
191 let _ = crate::git::succeeds(&[
192 "-C",
193 self.repo.to_str().unwrap_or_default(),
194 "worktree",
195 "remove",
196 "--force",
197 self.path.to_str().unwrap_or_default(),
198 ]);
199 let _ = std::fs::remove_dir_all(&self.path);
200 }
201}
202
203/// Where a pre-push suite should run, and whether that is the honest answer.
204///
205/// Takes ONE commit, not the whole ref list: a push can carry several refs
206/// (`git push origin a b`), each with its own tip, and a caller that checked
207/// out only the first one and ran every ref's tests against it would test
208/// the second ref's code against the first ref's tree. Callers loop over
209/// their own refs and pass each one's tip in turn.
210///
211/// Returns the directory plus the guard that owns it — dropping the guard
212/// removes the worktree, so the caller must hold it for the length of the run.
213pub fn where_to_run(tip: &str, fallback: &str) -> (PathBuf, Option<PushedTree>) {
214 // Inside a rehearsal the working tree IS a snapshot of the tip: a
215 // second checkout would test the same content twice, and the warning
216 // below would be false.
217 if crate::rehearsal::in_snapshot() {
218 return (PathBuf::from(fallback), None);
219 }
220 if !enabled() {
221 // Today's behaviour, but no longer silent about it.
222 println!(
223 "{} testing the WORKING TREE, not the pushed commits \
224 (`git config amont.testPushedTree true` to test what you are pushing)",
225 warning_sign()
226 );
227 return (PathBuf::from(fallback), None);
228 }
229 match PushedTree::create(Path::new(fallback), tip) {
230 Some(tree) => {
231 let path = tree.path().to_path_buf();
232 (path, Some(tree))
233 }
234 None => {
235 println!(
236 "{} could not check out {tip} to test it; testing the working tree instead",
237 warning_sign()
238 );
239 (PathBuf::from(fallback), None)
240 }
241 }
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247
248 /// The whole point: two calls must not name the same path, or a
249 /// predictable name is right back to being predictable.
250 #[test]
251 fn unique_name_does_not_repeat() {
252 let a = unique_name("amont-push");
253 let b = unique_name("amont-push");
254 assert_ne!(a, b);
255 assert!(a.starts_with("amont-push-"));
256 }
257
258 /// The point of the whole fix: something already at the target path is
259 /// left ALONE, not deleted to make way. `create_at` fails closed
260 /// (`None`) instead of the old `remove_dir_all`-first shape, which would
261 /// have destroyed `sentinel.txt` here to clear the path for a worktree
262 /// that was never going to use it anyway (the repo below is fake).
263 #[test]
264 fn an_existing_path_is_left_alone_not_cleared() {
265 let base = std::env::temp_dir().join(format!("pushed-collision-{}", std::process::id()));
266 let _ = std::fs::remove_dir_all(&base);
267 std::fs::create_dir_all(&base).unwrap();
268 std::fs::write(base.join("sentinel.txt"), "do not delete me").unwrap();
269
270 let got = PushedTree::create_at(base.clone(), Path::new("/does/not/matter"), "HEAD");
271 assert!(
272 got.is_none(),
273 "must refuse rather than reuse a path it did not create"
274 );
275 assert_eq!(
276 std::fs::read_to_string(base.join("sentinel.txt")).unwrap(),
277 "do not delete me",
278 "an existing path must never be cleared to make room"
279 );
280 let _ = std::fs::remove_dir_all(&base);
281 }
282
283 fn repo(name: &str) -> PathBuf {
284 let d = std::env::temp_dir().join(format!("pushed-{name}-{}", std::process::id()));
285 let _ = std::fs::remove_dir_all(&d);
286 std::fs::create_dir_all(&d).unwrap();
287 for args in [
288 vec!["init", "-q", "--template=", "."],
289 vec!["config", "user.email", "t@t.test"],
290 vec!["config", "user.name", "t"],
291 // Git for Windows rewrites line endings on checkout; a byte
292 // comparison would otherwise assert git's newline policy.
293 vec!["config", "core.autocrlf", "false"],
294 ] {
295 std::process::Command::new("git")
296 .args(&args)
297 .current_dir(&d)
298 .output()
299 .expect("git");
300 }
301 d
302 }
303
304 /// The point: the checkout holds the COMMIT, not whatever the developer
305 /// has open.
306 #[test]
307 fn the_worktree_holds_the_committed_content() {
308 let d = repo("tree");
309 let git = |args: &[&str]| {
310 std::process::Command::new("git")
311 .args(args)
312 .current_dir(&d)
313 .output()
314 .expect("git")
315 };
316 std::fs::write(d.join("a.txt"), "committed\n").unwrap();
317 git(&["add", "-A"]);
318 git(&["commit", "-qm", "seed"]);
319 let head = String::from_utf8_lossy(&git(&["rev-parse", "HEAD"]).stdout)
320 .trim()
321 .to_string();
322 // Uncommitted, and it must not travel.
323 std::fs::write(d.join("a.txt"), "dirty, not pushed\n").unwrap();
324
325 let tree = PushedTree::create(&d, &head).expect("worktree");
326 let seen = std::fs::read_to_string(tree.path().join("a.txt")).unwrap();
327 let at = tree.path().to_path_buf();
328 drop(tree);
329
330 assert_eq!(seen, "committed\n", "the worktree saw the dirty tree");
331 assert!(!at.exists(), "the worktree outlived its guard");
332 let _ = std::fs::remove_dir_all(&d);
333 }
334}