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
70/// Set to `1` on what a snapshot runs to prepare itself — the dependency
71/// install and `amont.snapshotPrepare` — for a custom `prepare` script that
72/// wants to know. INFORMATION, not the guard: the guard is `MARKER`, a file
73/// in the snapshot's git dir, which cannot leak into another repository the
74/// way an inherited variable does.
75pub const SNAPSHOT_ENV: &str = "AMONT_SNAPSHOT";
76
77/// The file a push snapshot carries in its OWN git admin dir
78/// (`<common>/worktrees/<name>/`), which `amont init` reads before baking.
79///
80/// Why it exists: the snapshot is a linked worktree, so it shares the
81/// repository's hooks directory, and its dependency install runs `prepare` —
82/// `amont init` — which baked every hook to a binary inside the snapshot,
83/// deleted minutes later. Why a file there and not an environment variable:
84/// that admin dir is the snapshot's alone and dies with it, so every process
85/// run inside the snapshot sees it (the install, `snapshotPrepare`, an
86/// install a gate runs itself) and a fixture repository amont's own suite
87/// creates does not. `AMONT_REHEARSAL` could not serve: it is removed before
88/// any gate spawns (`rehearsal::in_snapshot`), deliberately.
89///
90/// Both halves must be a version carrying this: an older runner writes no
91/// marker, and an older pinned `init` does not read one.
92const MARKER: &str = "amont-snapshot";
93
94/// Where `dir`'s snapshot marker lives, absolute. `Err` carries git's own
95/// reason when it would not say. The `GIT_*` environment is stripped: a
96/// hook's `GIT_DIR` names the repository the hook runs for, not the
97/// snapshot being asked about.
98fn snapshot_marker_path(dir: &Path) -> std::io::Result<PathBuf> {
99 let mut cmd = std::process::Command::new("git");
100 cmd.arg("-C")
101 .arg(dir)
102 .args(["rev-parse", "--path-format=absolute", "--git-path", MARKER])
103 .stdin(std::process::Stdio::null());
104 crate::hooks::common::strip_git_env(&mut cmd);
105 let out = cmd.output()?;
106 if !out.status.success() {
107 let why = String::from_utf8_lossy(&out.stderr);
108 let why = why
109 .lines()
110 .find(|l| !l.trim().is_empty())
111 .unwrap_or("no reason given");
112 return Err(std::io::Error::other(format!(
113 "git would not say where the snapshot marker lives: {}",
114 crate::ui::sanitize(why)
115 )));
116 }
117 let p = String::from_utf8_lossy(&out.stdout).trim().to_string();
118 if p.is_empty() {
119 return Err(std::io::Error::other(
120 "git gave an empty path for the snapshot marker",
121 ));
122 }
123 let p = PathBuf::from(p);
124 Ok(if p.is_absolute() { p } else { dir.join(p) })
125}
126
127/// Is the marker at `path`? Three answers, not two: `Path::exists` folds
128/// "cannot look" into "absent", and absent is the answer that bakes.
129pub fn probe_marker(path: &Path) -> std::io::Result<bool> {
130 match std::fs::symlink_metadata(path) {
131 Ok(_) => Ok(true),
132 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
133 Err(e) => Err(e),
134 }
135}
136
137/// Is `dir` inside an amont push snapshot? `Err` when that cannot be told —
138/// which the caller must treat as "do not write", never as "no".
139pub fn in_push_snapshot(dir: &Path) -> std::io::Result<bool> {
140 probe_marker(&snapshot_marker_path(dir)?)
141}
142
143/// Mark the snapshot at `dir` as one. Resolving the path is part of the
144/// mark: a snapshot whose marker could not even be located is unmarked.
145fn mark_snapshot(dir: &Path) -> std::io::Result<()> {
146 std::fs::write(snapshot_marker_path(dir)?, b"")
147}
148
149pub fn prepare_command(settings: &crate::config::Settings) -> Option<String> {
150 crate::config::string_value(settings, PREPARE).filter(|s| !s.trim().is_empty())
151}
152
153/// A checkout of the pushed commit, removed when it goes out of scope.
154pub struct PushedTree {
155 path: PathBuf,
156 repo: PathBuf,
157 /// The commit checked out — what preparation scopes the push by.
158 tip: String,
159}
160
161impl PushedTree {
162 /// Materialise `tip`, or `None` when that is not possible — in which case
163 /// the caller falls back to the working tree and says so.
164 /// Takes the repository explicitly rather than relying on the working
165 /// directory: `set_current_dir` is process-global, so a test that changed
166 /// it would race every other test in the binary.
167 ///
168 /// Checkout AND preparation, for the push-time caller: a snapshot that
169 /// could not be prepared is no snapshot, said once, here.
170 pub fn create(
171 settings: &crate::config::Settings,
172 repo: &Path,
173 tip: &str,
174 ) -> Option<PushedTree> {
175 let tree = Self::checkout(repo, tip)?;
176 match tree.prepare(settings) {
177 Ok(()) => Some(tree),
178 Err(why) => {
179 // `Drop` removes the worktree; the caller hears `None` and
180 // says what it is doing instead.
181 println!("{} could not prepare the snapshot: {why}", warning_sign());
182 None
183 }
184 }
185 }
186
187 /// The checkout alone. The rehearsal registers itself between this and
188 /// [`PushedTree::prepare`], so an install that takes minutes is visible —
189 /// to `--status`, to a push that would otherwise start over, and to a
190 /// newer commit that must cancel it.
191 pub fn checkout(repo: &Path, tip: &str) -> Option<PushedTree> {
192 let base = std::env::temp_dir().join(unique_name("amont-push"));
193 Self::checkout_at(base, repo, tip)
194 }
195
196 /// The actual work, over an explicit path — split out so a test can hand
197 /// it a path it controls, since `create`'s own path is unpredictable BY
198 /// DESIGN and cannot be aimed at a fixture.
199 ///
200 /// `create_dir`, not a `remove_dir_all` before creating: that deleted
201 /// whatever was already at this path BEFORE this process had established
202 /// it owned it — an unpredictable name makes landing on an existing path
203 /// unlikely, not impossible, and "unlikely" is not the bar for a delete.
204 /// `create_dir` is exclusive: it fails loudly on anything already there
205 /// instead of removing it, and git's own `worktree add` is content to
206 /// receive a directory that already exists as long as it is empty —
207 /// which this one, having just been created, provably is.
208 fn checkout_at(base: PathBuf, repo: &Path, tip: &str) -> Option<PushedTree> {
209 Self::checkout_at_with(base, repo, tip, &mark_snapshot)
210 }
211
212 /// [`checkout_at`] with the marker writer passed in — a parameter, not a
213 /// global switch, so a test can make it fail without racing the other
214 /// tests in the binary.
215 fn checkout_at_with(
216 base: PathBuf,
217 repo: &Path,
218 tip: &str,
219 mark: &dyn Fn(&Path) -> std::io::Result<()>,
220 ) -> Option<PushedTree> {
221 std::fs::create_dir(&base).ok()?;
222 let ok = crate::git::succeeds(&[
223 "-C",
224 repo.to_str()?,
225 "worktree",
226 "add",
227 "--detach",
228 "--quiet",
229 base.to_str()?,
230 tip,
231 ]);
232 if !ok {
233 // Ours to clean up: we created it moments ago, so nothing else
234 // could have raced in ahead of us to make this delete unsafe.
235 let _ = std::fs::remove_dir_all(&base);
236 return None;
237 }
238 let tree = PushedTree {
239 path: base,
240 repo: repo.to_path_buf(),
241 tip: tip.to_string(),
242 };
243 // Fail closed: an unmarked snapshot is one whose install re-bakes the
244 // repository's hooks to itself. `Drop` unregisters the worktree git
245 // has already recorded and removes the directory.
246 if let Err(e) = mark(&tree.path) {
247 println!("{} could not mark the snapshot: {e}", warning_sign());
248 return None;
249 }
250 Some(tree)
251 }
252
253 /// Make the checkout runnable: carry, dependencies, then
254 /// `amont.snapshotPrepare`. `Err` says why, in one line.
255 pub fn prepare(&self, settings: &crate::config::Settings) -> Result<(), String> {
256 let script = prepare_command(settings);
257 crate::snapshot_prep::run(
258 settings,
259 &self.repo,
260 &self.path,
261 &self.tip,
262 script.is_some(),
263 )?;
264 let Some(script) = script else {
265 return Ok(());
266 };
267 println!("preparing the snapshot: {script}");
268 #[cfg(unix)]
269 let mut cmd = {
270 let mut c = std::process::Command::new("sh");
271 c.arg("-c").arg(&script);
272 c
273 };
274 #[cfg(not(unix))]
275 let mut cmd = {
276 let mut c = std::process::Command::new("cmd");
277 c.arg("/C").arg(&script);
278 c
279 };
280 cmd.current_dir(&self.path)
281 .stdin(std::process::Stdio::null());
282 crate::hooks::common::strip_git_env(&mut cmd);
283 // the working tree the snapshot was taken FROM — where an installed
284 // workspace already lives (see PREPARE)
285 cmd.env(SOURCE_WORKTREE_ENV, &self.repo);
286 cmd.env(SNAPSHOT_ENV, "1");
287 if crate::hooks::common::bounded_success(settings, &mut cmd, PREPARE) {
288 Ok(())
289 } else {
290 Err(format!("{PREPARE} failed in the snapshot"))
291 }
292 }
293
294 pub fn path(&self) -> &Path {
295 &self.path
296 }
297}
298
299/// `<prefix>-<pid>-<hash>`: the pid stays for a human correlating a leftover
300/// directory with a hung process, but the pid ALONE made the name guessable —
301/// `ps` hands it to anyone on the box — and a pre-planted directory or
302/// symlink at a predictable path in a world-writable `/tmp` is somebody
303/// else's decision about where our checkout goes.
304///
305/// It is no longer the difference between safe and destructive: `create_at`
306/// takes the path with an exclusive `create_dir` and refuses anything already
307/// there, rather than the `remove_dir_all`-first shape this comment used to
308/// describe. Unpredictability is now defence in depth — it means the refusal
309/// is not something an attacker can arrange on demand.
310///
311/// No dependency for real randomness here (this crate ships dependency-free),
312/// so the tail is a hash of the wall clock and the pid through
313/// `RandomState`'s own OS-seeded key — enough that guessing it in advance is
314/// impractical, not a cryptographic promise.
315fn unique_name(prefix: &str) -> String {
316 use std::collections::hash_map::RandomState;
317 use std::hash::{BuildHasher, Hash, Hasher};
318
319 let pid = std::process::id();
320 let mut hasher = RandomState::new().build_hasher();
321 pid.hash(&mut hasher);
322 std::time::SystemTime::now()
323 .duration_since(std::time::UNIX_EPOCH)
324 .unwrap_or_default()
325 .as_nanos()
326 .hash(&mut hasher);
327 format!("{prefix}-{pid}-{:016x}", hasher.finish())
328}
329
330impl Drop for PushedTree {
331 fn drop(&mut self) {
332 // `--force`: the suite may have written into it, and a build artefact
333 // must not be a reason to leave a worktree registered forever.
334 let _ = crate::git::succeeds(&[
335 "-C",
336 self.repo.to_str().unwrap_or_default(),
337 "worktree",
338 "remove",
339 "--force",
340 self.path.to_str().unwrap_or_default(),
341 ]);
342 let _ = std::fs::remove_dir_all(&self.path);
343 }
344}
345
346/// `(gate, tip)` pairs whose suite ACTUALLY ran in a checkout of `tip` —
347/// what happened, per check, as opposed to what `enabled()` says was asked
348/// for.
349///
350/// Read by `dispatch::stamp_tips`. It used to decide from the CONFIG plus a
351/// per-tip "the snapshot could not be made" set, which answers for the
352/// checks that asked for a snapshot and says nothing about one that never
353/// did: a declared `amont.conf` gate ran in the working tree, passed on an
354/// uncommitted fix, and was stamped onto a tip that had never been tested —
355/// so the next push of that tip skipped it. A stamp is a claim about one
356/// gate and one tree, and the record it is made from has to be that fine.
357///
358/// A `Mutex` because pre-push checks run concurrently and each loops over its
359/// own refs; a poisoned lock reads as "did not run there", which costs a
360/// redundant gate run and never a false stamp.
361static SNAPSHOTTED: std::sync::OnceLock<
362 std::sync::Mutex<std::collections::HashSet<(String, String)>>,
363> = std::sync::OnceLock::new();
364
365fn snapshotted() -> &'static std::sync::Mutex<std::collections::HashSet<(String, String)>> {
366 SNAPSHOTTED.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
367}
368
369fn note_snapshot(gate: &str, tip: &str) {
370 if let Ok(mut set) = snapshotted().lock() {
371 set.insert((gate.to_string(), tip.to_string()));
372 }
373}
374
375/// Did `gate` run in a checkout of `tip` — the pushed content itself, not
376/// whatever the working tree held?
377///
378/// `false` also when the lock is poisoned: a stamp is a claim that the gate
379/// ran on exactly this content, and a claim we cannot check is one we do not
380/// make.
381pub fn ran_on_tip(gate: &str, tip: &str) -> bool {
382 match snapshotted().lock() {
383 Ok(set) => set.contains(&(gate.to_string(), tip.to_string())),
384 Err(_) => false,
385 }
386}
387
388/// Where a pre-push suite should run, and whether that is the honest answer.
389///
390/// Takes ONE commit, not the whole ref list: a push can carry several refs
391/// (`git push origin a b`), each with its own tip, and a caller that checked
392/// out only the first one and ran every ref's tests against it would test
393/// the second ref's code against the first ref's tree. Callers loop over
394/// their own refs and pass each one's tip in turn.
395///
396/// `gate` is the check's id (`Check::name`) — what a stamp names, and what
397/// [`ran_on_tip`] is asked about when `dispatch::stamp_tips` decides whether
398/// this gate may vouch for this tip. A check that runs a suite anywhere
399/// else than the directory returned here is answering about content no
400/// stamp may claim.
401///
402/// Returns the directory plus the guard that owns it — dropping the guard
403/// removes the worktree, so the caller must hold it for the length of the run.
404pub fn where_to_run(
405 settings: &crate::config::Settings,
406 tip: &str,
407 fallback: &str,
408 gate: &str,
409) -> (PathBuf, Option<PushedTree>) {
410 // Inside a rehearsal the working tree IS a snapshot of the tip: a
411 // second checkout would test the same content twice, and the warning
412 // below would be false.
413 if crate::rehearsal::in_snapshot() {
414 return (PathBuf::from(fallback), None);
415 }
416 if !enabled(settings) {
417 // Today's behaviour, but no longer silent about it. NOT a fallback:
418 // nobody asked for a snapshot, and `stamp_tips` already knows to
419 // vouch for the working tree only when it IS the tip.
420 //
421 // Once per process: every gate in the push asks this, and one line
422 // says it — the fourth repetition would teach the reader to skip it.
423 static SAID: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
424 if !SAID.swap(true, std::sync::atomic::Ordering::Relaxed) {
425 println!(
426 "{} testing the WORKING TREE, not the pushed commits \
427 (`git config amont.testPushedTree true` to test what you are pushing)",
428 warning_sign()
429 );
430 }
431 return (PathBuf::from(fallback), None);
432 }
433 match PushedTree::create(settings, Path::new(fallback), tip) {
434 Some(tree) => {
435 // Recorded, not inferred. `stamp_tips` used to read `enabled()`
436 // and conclude "the suite ran on the tip itself" — for every
437 // gate, including ones that never came here. Only a gate that
438 // was handed this checkout can be said to have run in it.
439 note_snapshot(gate, tip);
440 let path = tree.path().to_path_buf();
441 (path, Some(tree))
442 }
443 None => {
444 // Not recorded, and that IS the record: the gate passed on a
445 // possibly-dirty working tree, and stamping the tip anyway —
446 // which reading the config would do — let the next push of that
447 // tree (a retry after a dropped connection, or a tag on the
448 // merged commit) skip a gate nobody had run on its content.
449 // `gate_stamp`'s own promise is that no path here can let an
450 // unchecked commit through.
451 println!(
452 "{} could not check out {tip} to test it; testing the working tree instead \
453 (nothing will be stamped for it)",
454 warning_sign()
455 );
456 (PathBuf::from(fallback), None)
457 }
458 }
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464
465 fn test_settings() -> crate::config::Settings {
466 crate::config::Settings::default()
467 }
468
469 /// The whole point: two calls must not name the same path, or a
470 /// predictable name is right back to being predictable.
471 #[test]
472 fn unique_name_does_not_repeat() {
473 let a = unique_name("amont-push");
474 let b = unique_name("amont-push");
475 assert_ne!(a, b);
476 assert!(a.starts_with("amont-push-"));
477 }
478
479 /// The point of the whole fix: something already at the target path is
480 /// left ALONE, not deleted to make way. `create_at` fails closed
481 /// (`None`) instead of the old `remove_dir_all`-first shape, which would
482 /// have destroyed `sentinel.txt` here to clear the path for a worktree
483 /// that was never going to use it anyway (the repo below is fake).
484 #[test]
485 fn an_existing_path_is_left_alone_not_cleared() {
486 let base = std::env::temp_dir().join(format!("pushed-collision-{}", std::process::id()));
487 let _ = std::fs::remove_dir_all(&base);
488 std::fs::create_dir_all(&base).unwrap();
489 std::fs::write(base.join("sentinel.txt"), "do not delete me").unwrap();
490
491 let got = PushedTree::checkout_at(base.clone(), Path::new("/does/not/matter"), "HEAD");
492 assert!(
493 got.is_none(),
494 "must refuse rather than reuse a path it did not create"
495 );
496 assert_eq!(
497 std::fs::read_to_string(base.join("sentinel.txt")).unwrap(),
498 "do not delete me",
499 "an existing path must never be cleared to make room"
500 );
501 let _ = std::fs::remove_dir_all(&base);
502 }
503
504 fn repo(name: &str) -> PathBuf {
505 let d = std::env::temp_dir().join(format!("pushed-{name}-{}", std::process::id()));
506 let _ = std::fs::remove_dir_all(&d);
507 std::fs::create_dir_all(&d).unwrap();
508 for args in [
509 vec!["init", "-q", "--template=", "."],
510 vec!["config", "user.email", "t@t.test"],
511 vec!["config", "user.name", "t"],
512 // Git for Windows rewrites line endings on checkout; a byte
513 // comparison would otherwise assert git's newline policy.
514 vec!["config", "core.autocrlf", "false"],
515 ] {
516 std::process::Command::new("git")
517 .args(&args)
518 .current_dir(&d)
519 .output()
520 .expect("git");
521 }
522 d
523 }
524
525 /// The point: the checkout holds the COMMIT, not whatever the developer
526 /// has open.
527 #[test]
528 fn the_worktree_holds_the_committed_content() {
529 let d = repo("tree");
530 let git = |args: &[&str]| {
531 std::process::Command::new("git")
532 .args(args)
533 .current_dir(&d)
534 .output()
535 .expect("git")
536 };
537 std::fs::write(d.join("a.txt"), "committed\n").unwrap();
538 git(&["add", "-A"]);
539 git(&["commit", "-qm", "seed"]);
540 let head = String::from_utf8_lossy(&git(&["rev-parse", "HEAD"]).stdout)
541 .trim()
542 .to_string();
543 // Uncommitted, and it must not travel.
544 std::fs::write(d.join("a.txt"), "dirty, not pushed\n").unwrap();
545
546 let tree = PushedTree::create(&test_settings(), &d, &head).expect("worktree");
547 let seen = std::fs::read_to_string(tree.path().join("a.txt")).unwrap();
548 let at = tree.path().to_path_buf();
549 drop(tree);
550
551 assert_eq!(seen, "committed\n", "the worktree saw the dirty tree");
552 assert!(!at.exists(), "the worktree outlived its guard");
553 let _ = std::fs::remove_dir_all(&d);
554 }
555
556 fn seeded(name: &str) -> (PathBuf, String) {
557 let d = repo(name);
558 let git = |args: &[&str]| {
559 std::process::Command::new("git")
560 .args(args)
561 .current_dir(&d)
562 .output()
563 .expect("git")
564 };
565 git(&["commit", "--allow-empty", "-qm", "seed"]);
566 let head = String::from_utf8_lossy(&git(&["rev-parse", "HEAD"]).stdout)
567 .trim()
568 .to_string();
569 (d, head)
570 }
571
572 /// The snapshot carries the marker in its own admin dir; the repository
573 /// it was taken from does not — the marker is per-worktree, not shared.
574 #[test]
575 fn a_snapshot_is_marked_and_its_source_is_not() {
576 let (d, head) = seeded("marked");
577 let tree = PushedTree::checkout(&d, &head).expect("worktree");
578 assert!(in_push_snapshot(tree.path()).expect("probe"));
579 assert!(!in_push_snapshot(&d).expect("probe"));
580 drop(tree);
581 let _ = std::fs::remove_dir_all(&d);
582 }
583
584 /// Fail closed: a snapshot that could not be marked would re-bake the
585 /// repository's hooks to itself, so it is no snapshot — and git's
586 /// registration of it is undone, not just the directory.
587 #[test]
588 fn an_unmarkable_snapshot_is_no_snapshot() {
589 let (d, head) = seeded("unmarkable");
590 let base = std::env::temp_dir().join(unique_name("amont-push-unmarkable"));
591 let name = base.file_name().unwrap().to_string_lossy().into_owned();
592 let got = PushedTree::checkout_at_with(base.clone(), &d, &head, &|_| {
593 Err(std::io::Error::other("cannot mark"))
594 });
595 assert!(got.is_none(), "an unmarked snapshot was handed out");
596 assert!(
597 !base.exists(),
598 "the snapshot directory outlived the refusal"
599 );
600 let list = std::process::Command::new("git")
601 .args(["worktree", "list", "--porcelain"])
602 .current_dir(&d)
603 .output()
604 .expect("git");
605 let list = String::from_utf8_lossy(&list.stdout);
606 assert!(!list.contains(&name), "still registered:\n{list}");
607 let _ = std::fs::remove_dir_all(&d);
608 }
609
610 #[cfg(unix)]
611 fn is_root() -> bool {
612 std::process::Command::new("id")
613 .arg("-u")
614 .output()
615 .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "0")
616 .unwrap_or(false)
617 }
618
619 /// Three answers, not two. A marker that cannot be looked at is an
620 /// error — `Path::exists` would have said "absent", which bakes.
621 #[cfg(unix)]
622 #[test]
623 fn a_marker_that_cannot_be_looked_at_is_an_error_not_absent() {
624 use std::os::unix::fs::PermissionsExt;
625 if is_root() {
626 eprintln!("skipped: root ignores directory permissions");
627 return;
628 }
629 struct Restore(PathBuf);
630 impl Drop for Restore {
631 fn drop(&mut self) {
632 let _ = std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o755));
633 if let Some(parent) = self.0.parent() {
634 let _ = std::fs::remove_dir_all(parent);
635 }
636 }
637 }
638 let d = std::env::temp_dir().join(unique_name("amont-probe"));
639 let locked = d.join("locked");
640 std::fs::create_dir_all(&locked).expect("mkdir");
641 let marker = locked.join(MARKER);
642 std::fs::write(&marker, "").expect("marker");
643 assert!(probe_marker(&marker).expect("readable"), "present");
644 assert!(
645 !probe_marker(&locked.join("nope")).expect("readable"),
646 "absent"
647 );
648
649 let _guard = Restore(locked.clone());
650 std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).expect("chmod");
651 let got = probe_marker(&marker);
652 assert_eq!(
653 got.expect_err("a locked directory is not an absent marker")
654 .kind(),
655 std::io::ErrorKind::PermissionDenied
656 );
657 }
658}