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/// 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}