Skip to main content

amont_runtime/
snapshot_prep.rs

1//! Make a fresh snapshot runnable: carry the untracked files a suite needs,
2//! then prepare its JavaScript dependencies.
3//!
4//! A snapshot is a checkout, not a workspace — no `node_modules`, no `.env` —
5//! and a gate started there fails having tested nothing. Both steps run in
6//! [`crate::pushed_tree::PushedTree::prepare`], before `amont.snapshotPrepare`
7//! (the escape hatch), and a failure in either is the snapshot's failure: no
8//! suite runs, nothing is stamped.
9//!
10//! ## What a stamp may rest on
11//!
12//! A stamp claims the gate passed on the COMMITTED content. So nothing here
13//! may put content from the developer's tree where committed content lives:
14//! a carried path must be untracked in the snapshot's own index, and the
15//! dependencies must be the ones the committed lockfile describes.
16//!
17//! The default, `install`, is what CI does: `npm ci`, `pnpm install
18//! --frozen-lockfile`. `reuse` (pnpm only; see `reuse` for why npm is not
19//! reusable) clones the working tree's `node_modules` and keeps the clone
20//! only when it has pnpm's isolated layout and a frozen offline install
21//! accepts it against the snapshot's manifests and lockfile — which catches
22//! a missing or wrong-version package, a stray directory, and a manifest the
23//! lockfile does not satisfy, and does NOT catch a package whose files were
24//! edited in place without a version change. That limit is why reuse is
25//! opt-in.
26//!
27//! Every question about a manifest goes to the package manager, through exit
28//! codes and parseable output: this crate is dependency-free and has no JSON
29//! or YAML parser, and a hand-rolled one reading `workspaces` would be a
30//! second, divergent answer to a question npm already answers.
31
32use std::path::{Component, Path, PathBuf};
33use std::process::{Command, Stdio};
34
35use crate::ui::{highlight, warning_sign};
36
37const CARRY: &str = "amont.snapshotCarry";
38const DEPS: &str = "amont.snapshotDeps";
39
40/// `amont.snapshotDeps`.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum DepsMode {
43    /// `npm ci` / `pnpm install --frozen-lockfile` in the snapshot.
44    Install,
45    /// Clone the working tree's install, keep it only if the manager accepts it.
46    Reuse,
47    /// Prepare nothing.
48    Off,
49}
50
51pub fn deps_mode(settings: &crate::config::Settings) -> DepsMode {
52    match crate::config::enumerated_or(settings, DEPS, &["install", "reuse", "off"], "install") {
53        "reuse" => DepsMode::Reuse,
54        "off" => DepsMode::Off,
55        _ => DepsMode::Install,
56    }
57}
58
59/// `amont.snapshotCarry`, split on whitespace and commas.
60pub fn carry_list(settings: &crate::config::Settings) -> Vec<String> {
61    crate::config::string_value(settings, CARRY)
62        .map(|v| {
63            v.split(|c: char| c.is_whitespace() || c == ',')
64                .filter(|s| !s.is_empty())
65                .map(str::to_string)
66                .collect()
67        })
68        .unwrap_or_default()
69}
70
71/// The snapshot's own tracked files, `-z`, with git's environment stripped:
72/// a hook's `GIT_DIR`/`GIT_INDEX_FILE` would otherwise answer for the
73/// developer's repository, and the pushed tip — not HEAD, not the index the
74/// developer is staging into — decides what the snapshot holds.
75pub fn snapshot_files(snapshot: &Path) -> Result<Vec<String>, String> {
76    let mut cmd = Command::new("git");
77    cmd.arg("-C")
78        .arg(snapshot)
79        .args(["ls-files", "-z"])
80        .stdin(Stdio::null())
81        .stderr(Stdio::null());
82    crate::hooks::common::strip_git_env(&mut cmd);
83    let out = cmd
84        .output()
85        .map_err(|e| format!("could not list the snapshot's files: {e}"))?;
86    if !out.status.success() {
87        return Err("could not list the snapshot's files".to_string());
88    }
89    Ok(crate::git::split_nul_paths(&out.stdout))
90}
91
92// ---------------------------------------------------------------------------
93// carry
94// ---------------------------------------------------------------------------
95
96/// One validated carry entry.
97struct Carried {
98    entry: String,
99    src: PathBuf,
100    dst: PathBuf,
101}
102
103/// Why one carry entry is refused, or `None` when it is lexically a
104/// repo-relative path with nothing to climb out through.
105fn lexical_problem(entry: &str) -> Option<&'static str> {
106    if entry.is_empty() {
107        return Some("empty");
108    }
109    let bytes = entry.as_bytes();
110    if entry.starts_with('/') || entry.starts_with('\\') || (bytes.len() >= 2 && bytes[1] == b':') {
111        return Some("not a repo-relative path");
112    }
113    for part in entry.split(['/', '\\']) {
114        if part.is_empty() {
115            return Some("has an empty path component");
116        }
117        if part == "." || part == ".." {
118            return Some("has a `.` or `..` component");
119        }
120        if part.eq_ignore_ascii_case(".git") {
121            return Some("is inside .git");
122        }
123    }
124    None
125}
126
127/// Is `entry` — or anything inside it, or any directory above it — tracked in
128/// the snapshot? Carrying it would put the developer's copy over committed
129/// content, and the stamp would vouch for a tree nobody committed.
130fn tracked_collision(entry: &str, files: &[String]) -> Option<String> {
131    let inside = format!("{entry}/");
132    files
133        .iter()
134        .find(|f| {
135            f.as_str() == entry || f.starts_with(&inside) || entry.starts_with(&format!("{f}/"))
136        })
137        .cloned()
138}
139
140fn is_symlink(p: &Path) -> bool {
141    std::fs::symlink_metadata(p)
142        .map(|m| m.file_type().is_symlink())
143        .unwrap_or(false)
144}
145
146/// A symlinked directory between `root` and `rel`'s last component — which
147/// would take a read, or a write, somewhere outside the tree it names.
148fn symlinked_ancestor(root: &Path, rel: &Path) -> Option<PathBuf> {
149    let mut at = root.to_path_buf();
150    let parts: Vec<_> = rel.components().collect();
151    for part in parts.iter().take(parts.len().saturating_sub(1)) {
152        at.push(part.as_os_str());
153        if is_symlink(&at) {
154            return Some(at);
155        }
156    }
157    None
158}
159
160/// A symlink anywhere inside `dir`, not followed.
161fn symlink_inside(dir: &Path) -> std::io::Result<Option<PathBuf>> {
162    for entry in std::fs::read_dir(dir)? {
163        let entry = entry?;
164        let kind = entry.file_type()?;
165        if kind.is_symlink() {
166            return Ok(Some(entry.path()));
167        }
168        if kind.is_dir() {
169            if let Some(found) = symlink_inside(&entry.path())? {
170                return Ok(Some(found));
171            }
172        }
173    }
174    Ok(None)
175}
176
177fn copy_tree(src: &Path, dst: &Path) -> std::io::Result<()> {
178    let meta = std::fs::symlink_metadata(src)?;
179    if meta.is_dir() {
180        std::fs::create_dir(dst)?;
181        for entry in std::fs::read_dir(src)? {
182            let entry = entry?;
183            copy_tree(&entry.path(), &dst.join(entry.file_name()))?;
184        }
185        Ok(())
186    } else {
187        std::fs::copy(src, dst).map(|_| ())
188    }
189}
190
191/// Copy the untracked files `amont.snapshotCarry` names from `source` into
192/// `snapshot`.
193///
194/// Every entry is validated before anything is copied, and every refusal is
195/// reported in ONE message — `config::complain` says a key once, which would
196/// hide all but the first bad entry. Any refusal fails preparation: a carry
197/// the author got wrong must be loud, never a silently thinner snapshot that
198/// still earns a stamp. An entry that is simply absent is skipped (a `.env`
199/// exists on a laptop and not in CI); one that is present and cannot be
200/// copied fails.
201pub fn carry(
202    entries: &[String],
203    source: &Path,
204    snapshot: &Path,
205    files: &[String],
206) -> Result<(), String> {
207    let mut refused: Vec<String> = Vec::new();
208    let mut missing: Vec<&str> = Vec::new();
209    let mut plan: Vec<Carried> = Vec::new();
210    for entry in entries {
211        let norm = entry.replace('\\', "/");
212        let norm = norm.trim_end_matches('/').to_string();
213        if let Some(why) = lexical_problem(&norm) {
214            refused.push(format!("{entry}: {why}"));
215            continue;
216        }
217        if let Some(f) = tracked_collision(&norm, files) {
218            refused.push(format!(
219                "{entry}: tracked content ({f}) — only untracked files can be carried"
220            ));
221            continue;
222        }
223        let rel = PathBuf::from(&norm);
224        let src = source.join(&rel);
225        let dst = snapshot.join(&rel);
226        if let Some(link) = symlinked_ancestor(source, &rel) {
227            refused.push(format!(
228                "{entry}: {} is a symlink in the working tree",
229                link.display()
230            ));
231            continue;
232        }
233        if let Some(link) = symlinked_ancestor(snapshot, &rel) {
234            refused.push(format!(
235                "{entry}: {} is a symlink in the snapshot",
236                link.display()
237            ));
238            continue;
239        }
240        let meta = match std::fs::symlink_metadata(&src) {
241            Ok(m) => m,
242            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
243                missing.push(entry);
244                continue;
245            }
246            Err(e) => {
247                refused.push(format!("{entry}: cannot be read: {e}"));
248                continue;
249            }
250        };
251        if meta.file_type().is_symlink() {
252            refused.push(format!("{entry}: is a symlink"));
253            continue;
254        }
255        if meta.is_dir() {
256            match symlink_inside(&src) {
257                Ok(Some(link)) => {
258                    refused.push(format!("{entry}: holds a symlink ({})", link.display()));
259                    continue;
260                }
261                Ok(None) => {}
262                Err(e) => {
263                    refused.push(format!("{entry}: cannot be read: {e}"));
264                    continue;
265                }
266            }
267        }
268        if std::fs::symlink_metadata(&dst).is_ok() {
269            refused.push(format!("{entry}: already exists in the snapshot"));
270            continue;
271        }
272        plan.push(Carried {
273            entry: entry.clone(),
274            src,
275            dst,
276        });
277    }
278    if !refused.is_empty() {
279        return Err(format!(
280            "{CARRY} refused {}: {}",
281            if refused.len() == 1 {
282                "an entry".to_string()
283            } else {
284                format!("{} entries", refused.len())
285            },
286            refused.join("; ")
287        ));
288    }
289    if !missing.is_empty() {
290        println!(
291            "snapshot: not carrying {} — not in the working tree",
292            missing.join(", ")
293        );
294    }
295    for c in &plan {
296        if let Some(parent) = c.dst.parent() {
297            std::fs::create_dir_all(parent).map_err(|e| format!("{CARRY}: {}: {e}", c.entry))?;
298        }
299        copy_tree(&c.src, &c.dst)
300            .map_err(|e| format!("{CARRY}: {} could not be copied: {e}", c.entry))?;
301    }
302    if !plan.is_empty() {
303        let names: Vec<&str> = plan.iter().map(|c| c.entry.as_str()).collect();
304        println!("snapshot: carried {}", names.join(", "));
305    }
306    Ok(())
307}
308
309// ---------------------------------------------------------------------------
310// dependencies
311// ---------------------------------------------------------------------------
312
313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
314pub enum Manager {
315    Npm,
316    Pnpm,
317}
318
319impl Manager {
320    fn lockfile(self) -> &'static str {
321        match self {
322            Manager::Npm => "package-lock.json",
323            Manager::Pnpm => "pnpm-lock.yaml",
324        }
325    }
326    fn tool(self) -> &'static str {
327        match self {
328            Manager::Npm => "npm",
329            Manager::Pnpm => "pnpm",
330        }
331    }
332}
333
334/// One installation unit: a directory holding a tracked lockfile, and the
335/// manager that reads it.
336#[derive(Debug, Clone, PartialEq, Eq)]
337pub struct Unit {
338    /// Repo-relative, `""` for the root.
339    pub dir: String,
340    pub manager: Manager,
341    /// pnpm only: a lockfile with no `pnpm-workspace.yaml` beside it is a
342    /// standalone project, even inside a workspace (see `audit::pnpm_args`).
343    pub standalone: bool,
344}
345
346/// The directories of `files` named exactly `name`, repo-relative (`""` for
347/// the root), sorted and deduplicated. Pure: the caller decides WHICH index
348/// the list comes from — the snapshot's, for a snapshot.
349pub fn lockfile_dirs_in(files: &[String], name: &str) -> Vec<String> {
350    let mut dirs: Vec<String> = files
351        .iter()
352        .filter(|p| p.rsplit('/').next().unwrap_or(p) == name)
353        .map(|p| {
354            p.rsplit_once('/')
355                .map(|(d, _)| d.to_string())
356                .unwrap_or_default()
357        })
358        .collect();
359    dirs.sort();
360    dirs.dedup();
361    dirs
362}
363
364fn join_rel(dir: &str, name: &str) -> String {
365    if dir.is_empty() {
366        name.to_string()
367    } else {
368        format!("{dir}/{name}")
369    }
370}
371
372/// The `packageManager` field of a package.json, by a targeted scan of that
373/// one key — `Some("npm")`/`Some("pnpm")` when it names one of them without
374/// ambiguity, `None` otherwise.
375pub fn declared_manager(package_json: &str) -> Option<Manager> {
376    let key = "\"packageManager\"";
377    let mut hits = package_json.match_indices(key);
378    let (at, _) = hits.next()?;
379    if hits.next().is_some() {
380        return None;
381    }
382    let rest = package_json[at + key.len()..].trim_start();
383    let rest = rest.strip_prefix(':')?.trim_start();
384    let rest = rest.strip_prefix('"')?;
385    let value = &rest[..rest.find('"')?];
386    if value.starts_with("pnpm@") || value == "pnpm" {
387        Some(Manager::Pnpm)
388    } else if value.starts_with("npm@") || value == "npm" {
389        Some(Manager::Npm)
390    } else {
391        None
392    }
393}
394
395/// Every installation unit in `files`. A directory holding BOTH lockfiles is
396/// settled by `packageManager`, or refused: guessing would install the wrong
397/// tree and test it as if it were the right one.
398pub fn units(files: &[String], snapshot: &Path) -> Result<Vec<Unit>, String> {
399    let npm = lockfile_dirs_in(files, Manager::Npm.lockfile());
400    let pnpm = lockfile_dirs_in(files, Manager::Pnpm.lockfile());
401    let mut out = Vec::new();
402    let mut dirs: Vec<&String> = npm.iter().chain(pnpm.iter()).collect();
403    dirs.sort();
404    dirs.dedup();
405    for dir in dirs {
406        let manager = match (npm.contains(dir), pnpm.contains(dir)) {
407            (true, true) => {
408                let manifest = snapshot.join(join_rel(dir, "package.json"));
409                let body = std::fs::read_to_string(&manifest).unwrap_or_default();
410                declared_manager(&body).ok_or_else(|| {
411                    format!(
412                        "both package-lock.json and pnpm-lock.yaml in {} and no \
413                         `packageManager` to choose — set amont.snapshotPrepare",
414                        if dir.is_empty() { "the root" } else { dir }
415                    )
416                })?
417            }
418            (true, false) => Manager::Npm,
419            _ => Manager::Pnpm,
420        };
421        let standalone =
422            manager == Manager::Pnpm && !files.contains(&join_rel(dir, "pnpm-workspace.yaml"));
423        out.push(Unit {
424            dir: dir.clone(),
425            manager,
426            standalone,
427        });
428    }
429    Ok(out)
430}
431
432fn unit_label(u: &Unit) -> String {
433    if u.dir.is_empty() {
434        "the root".to_string()
435    } else {
436        format!("{}/", u.dir)
437    }
438}
439
440fn manager_cmd(u: &Unit, at: &Path, args: &[&str]) -> Command {
441    let mut cmd = Command::new(crate::hooks::common::program(u.manager.tool()));
442    cmd.args(args).current_dir(at).stdin(Stdio::null());
443    if u.manager == Manager::Pnpm {
444        // A purge prompt cannot be answered from a hook, and aborts.
445        cmd.arg("--config.confirmModulesPurge=false");
446        if u.standalone {
447            cmd.arg("--ignore-workspace");
448        }
449    }
450    crate::hooks::common::strip_git_env(&mut cmd);
451    // For a `prepare` script that wants to know; the guard itself is the
452    // marker in the snapshot's git dir (`pushed_tree::MARKER`).
453    cmd.env(crate::pushed_tree::SNAPSHOT_ENV, "1");
454    cmd
455}
456
457/// Run a manager command quietly; `Ok(output)` on exit 0, `Err(output)`
458/// otherwise — the output is what the caller quotes when it says why.
459fn quiet(settings: &crate::config::Settings, mut cmd: Command) -> Result<String, String> {
460    match crate::hooks::common::capture_within(settings, &mut cmd) {
461        Some((crate::hooks::common::Ran::Status(s), text)) if s.success() => Ok(text),
462        Some((_, text)) => Err(text),
463        None => Err("could not start".to_string()),
464    }
465}
466
467/// The last meaningful line of a tool's output, for a one-line reason.
468fn last_line(text: &str) -> String {
469    text.lines()
470        .map(str::trim)
471        .rfind(|l| !l.is_empty())
472        .unwrap_or("no output")
473        .chars()
474        .take(200)
475        .collect()
476}
477
478fn install(settings: &crate::config::Settings, u: &Unit, at: &Path) -> Result<(), String> {
479    let args: &[&str] = match u.manager {
480        Manager::Npm => &["ci", "--no-audit", "--no-fund", "--prefer-offline"],
481        Manager::Pnpm => &["install", "--frozen-lockfile", "--prefer-offline"],
482    };
483    let mut cmd = manager_cmd(u, at, args);
484    let what = format!("{} {}", u.manager.tool(), args[0]);
485    if crate::hooks::common::bounded_success(settings, &mut cmd, &what) {
486        Ok(())
487    } else {
488        Err(format!("{what} failed in {}", unit_label(u)))
489    }
490}
491
492/// Clone a directory by spawning the platform's copy tool by ABSOLUTE path:
493/// a GNU `cp` first on PATH has no `-c`. APFS clones with `-c`, btrfs/xfs
494/// reflink with `--reflink=auto` (a plain copy elsewhere); symlinks are
495/// copied as symlinks either way.
496fn clone_dir(src: &Path, dst: &Path) -> Result<(), String> {
497    #[cfg(target_os = "macos")]
498    let attempts: &[&[&str]] = &[&["/bin/cp", "-cR"], &["/bin/cp", "-R"]];
499    #[cfg(all(unix, not(target_os = "macos")))]
500    let attempts: &[&[&str]] = &[
501        &["/bin/cp", "-R", "--reflink=auto"],
502        &["/usr/bin/cp", "-R", "--reflink=auto"],
503    ];
504    #[cfg(not(unix))]
505    let attempts: &[&[&str]] = &[];
506    for argv in attempts {
507        if !Path::new(argv[0]).exists() {
508            continue;
509        }
510        let ok = Command::new(argv[0])
511            .args(&argv[1..])
512            .arg(src)
513            .arg(dst)
514            .stdin(Stdio::null())
515            .stdout(Stdio::null())
516            .stderr(Stdio::null())
517            .status()
518            .map(|s| s.success())
519            .unwrap_or(false);
520        if ok {
521            return Ok(());
522        }
523        let _ = std::fs::remove_dir_all(dst);
524    }
525    Err(format!("could not clone {}", src.display()))
526}
527
528/// Lexically resolve `p` — `.` and `..` folded, nothing touched on disk.
529fn normalize(p: &Path) -> PathBuf {
530    let mut out = PathBuf::new();
531    for c in p.components() {
532        match c {
533            Component::ParentDir => {
534                out.pop();
535            }
536            Component::CurDir => {}
537            other => out.push(other.as_os_str()),
538        }
539    }
540    out
541}
542
543/// The first symlink under `dir` that resolves into `source` — a clone
544/// still reading the developer's tree through a link is not isolated,
545/// whatever else it preserved.
546fn link_into(dir: &Path, sources: &[PathBuf]) -> std::io::Result<Option<PathBuf>> {
547    let mut stack = vec![dir.to_path_buf()];
548    while let Some(at) = stack.pop() {
549        for entry in std::fs::read_dir(&at)? {
550            let entry = entry?;
551            let kind = entry.file_type()?;
552            let path = entry.path();
553            if kind.is_symlink() {
554                let target = std::fs::read_link(&path)?;
555                let resolved = if target.is_absolute() {
556                    normalize(&target)
557                } else {
558                    normalize(&at.join(target))
559                };
560                // Lexically AND through every link: `/var` is itself a
561                // symlink on macOS, so the two spellings of one directory
562                // must both be caught.
563                let real = path.canonicalize().ok();
564                if sources.iter().any(|s| {
565                    resolved.starts_with(s) || real.as_ref().is_some_and(|r| r.starts_with(s))
566                }) {
567                    return Ok(Some(path));
568                }
569            } else if kind.is_dir() {
570                stack.push(path);
571            }
572        }
573    }
574    Ok(None)
575}
576
577/// The first regular file under `nm` modified after `record`, pnpm's
578/// install record (`.modules.yaml`, rewritten as an install finishes).
579///
580/// Skipped, because an install or the tools legitimately write them after
581/// the record: every `.bin/` (pnpm relinks it last), `.cache/` and
582/// `.vite*/` (tool caches), and pnpm's own `.pnpm/lock.yaml`,
583/// `.pnpm-workspace-state*` and `.modules.yaml`. Symlinks are not followed —
584/// a top-level package is a link into `.pnpm`, which is walked itself.
585fn newer_than(nm: &Path, record: std::time::SystemTime) -> std::io::Result<Option<PathBuf>> {
586    if !nm.is_dir() {
587        return Ok(None);
588    }
589    let mut stack = vec![nm.to_path_buf()];
590    while let Some(at) = stack.pop() {
591        for entry in std::fs::read_dir(&at)? {
592            let entry = entry?;
593            let name = entry.file_name();
594            let name = name.to_string_lossy();
595            let kind = entry.file_type()?;
596            if kind.is_dir() {
597                if name == ".bin" || name == ".cache" || name.starts_with(".vite") {
598                    continue;
599                }
600                stack.push(entry.path());
601            } else if kind.is_file() {
602                if name == ".modules.yaml"
603                    || name.starts_with(".pnpm-workspace-state")
604                    || (name == "lock.yaml" && at.file_name().is_some_and(|d| d == ".pnpm"))
605                {
606                    continue;
607                }
608                if entry.metadata()?.modified()? > record {
609                    return Ok(Some(entry.path()));
610                }
611            }
612        }
613    }
614    Ok(None)
615}
616
617/// Workspace members of a pnpm unit, as pnpm lists them, relative to the
618/// unit dir. Asked in the SNAPSHOT: the committed manifests decide.
619fn members(settings: &crate::config::Settings, u: &Unit, at: &Path) -> Result<Vec<String>, String> {
620    if u.standalone {
621        return Ok(Vec::new());
622    }
623    let text = quiet(
624        settings,
625        manager_cmd(u, at, &["ls", "-r", "--depth", "-1", "--parseable"]),
626    )
627    .map_err(|t| format!("pnpm could not list the workspace: {}", last_line(&t)))?;
628    let root = normalize(at);
629    let canon = at.canonicalize().unwrap_or_else(|_| root.clone());
630    Ok(text
631        .lines()
632        .filter_map(|l| {
633            let p = PathBuf::from(l.trim());
634            let rel = p
635                .strip_prefix(&root)
636                .or_else(|_| p.strip_prefix(&canon))
637                .ok()?;
638            let rel = rel.to_string_lossy().replace('\\', "/");
639            (!rel.is_empty()).then_some(rel)
640        })
641        .collect())
642}
643
644/// Is `nm` laid out the way pnpm's default (`isolated`) linker lays it out —
645/// every package a link, `@scope` directories holding only links? pnpm's own
646/// check does not look at a directory it did not create, and a stray one
647/// here is importable by the suite; so anything else is not reused.
648fn pnpm_layout_problem(nm: &Path, is_root: bool) -> Option<String> {
649    if is_root {
650        let modules = std::fs::read_to_string(nm.join(".modules.yaml")).unwrap_or_default();
651        let isolated = modules
652            .lines()
653            .any(|l| l.contains("nodeLinker") && l.contains("isolated"));
654        if !isolated {
655            return Some("not installed with pnpm's isolated linker".to_string());
656        }
657    }
658    let entries = std::fs::read_dir(nm).ok()?;
659    for e in entries.flatten() {
660        let name = e.file_name().to_string_lossy().into_owned();
661        if name.starts_with('.') {
662            continue;
663        }
664        let kind = e.file_type().ok()?;
665        if kind.is_symlink() {
666            continue;
667        }
668        if name.starts_with('@') && kind.is_dir() {
669            for inner in std::fs::read_dir(e.path()).ok()?.flatten() {
670                if !inner.file_type().map(|k| k.is_symlink()).unwrap_or(false) {
671                    return Some(format!(
672                        "{} is not a link pnpm made",
673                        inner.path().display()
674                    ));
675                }
676            }
677            continue;
678        }
679        return Some(format!("{} is not a link pnpm made", e.path().display()));
680    }
681    None
682}
683
684/// Try to reuse the working tree's install for one unit. `Ok(())` when the
685/// clone is in place and pnpm accepted it; `Err(reason)` — with the snapshot
686/// left exactly as it was — when it cannot be.
687///
688/// npm is never reused. `npm ls` answers "is this dependency graph valid",
689/// not "is this the tree the lockfile describes": a real repository with
690/// peer-range conflicts `npm ci` installs without complaint fails `npm ls`
691/// on a fresh install (application-landscape: 182 findings), so it can
692/// neither accept a correct clone nor be trusted to reject a wrong one.
693fn reuse(
694    settings: &crate::config::Settings,
695    u: &Unit,
696    source: &Path,
697    snapshot: &Path,
698    unit_dirs: &[String],
699) -> Result<(), String> {
700    if u.manager == Manager::Npm {
701        return Err(
702            "npm cannot check an installed tree against its lockfile, so npm \
703                    snapshots always install"
704                .to_string(),
705        );
706    }
707    let src_dir = source.join(&u.dir);
708    let snap_dir = snapshot.join(&u.dir);
709    let lock = u.manager.lockfile();
710    let src_lock = std::fs::read(src_dir.join(lock))
711        .map_err(|_| "no lockfile in the working tree".to_string())?;
712    let snap_lock = std::fs::read(snap_dir.join(lock)).map_err(|e| format!("{lock}: {e}"))?;
713    if src_lock != snap_lock {
714        return Err("the working tree's lockfile differs from the commit's".to_string());
715    }
716    if !src_dir.join("node_modules").is_dir() {
717        return Err("nothing installed in the working tree".to_string());
718    }
719    let mut set = vec![String::new()];
720    for m in members(settings, u, &snap_dir)? {
721        let repo_rel = join_rel(&u.dir, &m);
722        // A member with a lockfile of its own is its own unit.
723        if unit_dirs.contains(&repo_rel) {
724            continue;
725        }
726        if src_dir.join(&m).join("node_modules").is_dir() {
727            set.push(m);
728        }
729    }
730    // Checked on the SOURCE, before anything is copied: a file changed
731    // since pnpm finished installing is an edit pnpm's own check will not
732    // see, and a clone would carry it into a stamped snapshot.
733    let record = std::fs::metadata(src_dir.join("node_modules/.modules.yaml"))
734        .and_then(|m| m.modified())
735        .map_err(|_| "no pnpm install record (node_modules/.modules.yaml)".to_string())?;
736    for m in &set {
737        match newer_than(&src_dir.join(m).join("node_modules"), record) {
738            Ok(None) => {}
739            Ok(Some(f)) => {
740                let shown = f.strip_prefix(source).unwrap_or(&f);
741                return Err(format!(
742                    "{} changed after the install — an in-place edit pnpm does not check",
743                    shown.display()
744                ));
745            }
746            Err(e) => return Err(format!("could not inspect the working tree's install: {e}")),
747        }
748    }
749    let mut cloned: Vec<PathBuf> = Vec::new();
750    let rollback = |cloned: &[PathBuf]| {
751        for d in cloned {
752            let _ = std::fs::remove_dir_all(d);
753        }
754    };
755    let sources = [
756        normalize(source),
757        source.canonicalize().unwrap_or_else(|_| normalize(source)),
758    ];
759    for m in &set {
760        let from = src_dir.join(m).join("node_modules");
761        let to = snap_dir.join(m).join("node_modules");
762        if std::fs::symlink_metadata(&to).is_ok() {
763            rollback(&cloned);
764            return Err(format!("{} already exists in the snapshot", to.display()));
765        }
766        if let Err(e) = clone_dir(&from, &to) {
767            rollback(&cloned);
768            return Err(e);
769        }
770        cloned.push(to.clone());
771        let problem = match link_into(&to, &sources) {
772            Ok(Some(link)) => Some(format!(
773                "{} links back into the working tree",
774                link.display()
775            )),
776            Ok(None) => pnpm_layout_problem(&to, m.is_empty()),
777            Err(e) => Some(format!("could not inspect the clone: {e}")),
778        };
779        if let Some(why) = problem {
780            rollback(&cloned);
781            return Err(why);
782        }
783    }
784    // pnpm records the absolute paths of the tree it installed; the clone
785    // must not carry the source's.
786    if let Ok(rd) = std::fs::read_dir(snap_dir.join("node_modules")) {
787        for e in rd.flatten() {
788            if e.file_name()
789                .to_string_lossy()
790                .starts_with(".pnpm-workspace-state")
791            {
792                let _ = std::fs::remove_file(e.path());
793            }
794        }
795    }
796    // A frozen OFFLINE install is both the check and the repair: it fails on
797    // a manifest the lockfile does not satisfy, relinks drift from the
798    // store, and says "Already up to date" for a faithful clone.
799    if let Err(text) = quiet(
800        settings,
801        manager_cmd(u, &snap_dir, &["install", "--frozen-lockfile", "--offline"]),
802    ) {
803        rollback(&cloned);
804        return Err(format!(
805            "pnpm did not accept the clone ({})",
806            last_line(&text)
807        ));
808    }
809    Ok(())
810}
811
812/// The files `tip` changes relative to where it forked from its upstream —
813/// what the push is about. `None` when git cannot say (no upstream, a tip
814/// it does not know): the caller then prepares everything.
815pub fn changed_since_upstream(source: &Path, tip: &str) -> Option<Vec<String>> {
816    let git = |args: &[&str]| -> Option<Vec<u8>> {
817        let mut cmd = Command::new("git");
818        cmd.arg("-C")
819            .arg(source)
820            .args(args)
821            .stdin(Stdio::null())
822            .stderr(Stdio::null());
823        crate::hooks::common::strip_git_env(&mut cmd);
824        let out = cmd.output().ok()?;
825        out.status.success().then_some(out.stdout)
826    };
827    let base = git(&["merge-base", tip, "@{upstream}"])?;
828    let base = String::from_utf8_lossy(&base).trim().to_string();
829    if base.is_empty() {
830        return None;
831    }
832    let raw = git(&["diff", "--name-only", "-z", &base, tip])?;
833    Some(crate::git::split_nul_paths(&raw))
834}
835
836/// Which units a snapshot needs: the root one, and every unit that is the
837/// NEAREST enclosing unit of a changed file. A unit the push does not touch
838/// cannot hold a failure the push introduced — and nothing the gates run for
839/// this push reads it — so installing it is pure cost (website-builder: seven
840/// spikes, an install each, on every snapshot). `changed` is `None` when the
841/// range is unknown, and then every unit is kept.
842///
843/// Returns `(kept, skipped)`.
844pub fn select_units(units: Vec<Unit>, changed: Option<&[String]>) -> (Vec<Unit>, Vec<Unit>) {
845    let Some(changed) = changed else {
846        return (units, Vec::new());
847    };
848    let nearest = |f: &str| -> Option<&str> {
849        units
850            .iter()
851            .map(|u| u.dir.as_str())
852            .filter(|d| d.is_empty() || f.starts_with(&format!("{d}/")))
853            .max_by_key(|d| d.len())
854    };
855    let touched: Vec<&str> = changed.iter().filter_map(|f| nearest(f)).collect();
856    let (kept, skipped): (Vec<Unit>, Vec<Unit>) = units
857        .clone()
858        .into_iter()
859        .partition(|u| u.dir.is_empty() || touched.contains(&u.dir.as_str()));
860    (kept, skipped)
861}
862
863/// Prepare the dependencies of every unit the push needs in `snapshot`.
864pub fn deps(
865    settings: &crate::config::Settings,
866    mode: DepsMode,
867    source: &Path,
868    snapshot: &Path,
869    files: &[String],
870    changed: Option<&[String]>,
871) -> Result<(), String> {
872    if mode == DepsMode::Off {
873        return Ok(());
874    }
875    let all = units(files, snapshot)?;
876    let unit_dirs: Vec<String> = all.iter().map(|u| u.dir.clone()).collect();
877    let (units, skipped) = select_units(all, changed);
878    if !skipped.is_empty() {
879        let names: Vec<String> = skipped.iter().map(unit_label).collect();
880        println!(
881            "snapshot: not preparing {} — the push changes nothing there",
882            names.join(", ")
883        );
884    }
885    for u in &units {
886        let at = snapshot.join(&u.dir);
887        let label = unit_label(u);
888        if mode == DepsMode::Reuse {
889            // No clone tool on Windows: `clone_dir` says so and this falls
890            // through to the install, like any other refusal.
891            match reuse(settings, u, source, snapshot, &unit_dirs) {
892                Ok(()) => {
893                    println!(
894                        "snapshot: node_modules in {label} reused from the working tree — {} \
895                         accepted it against the lockfile",
896                        u.manager.tool()
897                    );
898                    continue;
899                }
900                Err(why) => println!(
901                    "snapshot: {} in {label} — not reusing the working tree's install: {why}",
902                    highlight(&format!("{} {}", u.manager.tool(), install_verb(u.manager)))
903                ),
904            }
905        } else {
906            println!(
907                "snapshot: {} in {label}",
908                highlight(&format!("{} {}", u.manager.tool(), install_verb(u.manager)))
909            );
910        }
911        install(settings, u, &at)?;
912    }
913    Ok(())
914}
915
916fn install_verb(m: Manager) -> &'static str {
917    match m {
918        Manager::Npm => "ci",
919        Manager::Pnpm => "install --frozen-lockfile",
920    }
921}
922
923/// Carry → dependencies → nothing else; `snapshotPrepare` runs after this,
924/// in `PushedTree::prepare`. Dependencies are skipped when a
925/// `snapshotPrepare` command exists: that command owns them.
926pub fn run(
927    settings: &crate::config::Settings,
928    source: &Path,
929    snapshot: &Path,
930    tip: &str,
931    command_owns_deps: bool,
932) -> Result<(), String> {
933    let entries = carry_list(settings);
934    let mode = if command_owns_deps {
935        DepsMode::Off
936    } else {
937        deps_mode(settings)
938    };
939    if entries.is_empty() && mode == DepsMode::Off {
940        return Ok(());
941    }
942    let files = snapshot_files(snapshot)?;
943    if !entries.is_empty() {
944        carry(&entries, source, snapshot, &files)?;
945    }
946    let changed = if mode == DepsMode::Off {
947        None
948    } else {
949        changed_since_upstream(source, tip)
950    };
951    deps(settings, mode, source, snapshot, &files, changed.as_deref()).map_err(|e| {
952        println!("{} {e}", warning_sign());
953        e
954    })
955}
956
957#[cfg(test)]
958mod tests {
959    use super::*;
960
961    fn s(v: &[&str]) -> Vec<String> {
962        v.iter().map(|x| x.to_string()).collect()
963    }
964
965    #[test]
966    fn lexical_refusals() {
967        for bad in [
968            "",
969            "/etc/hosts",
970            "\\x",
971            "C:/x",
972            "a//b",
973            ".",
974            "..",
975            "a/../b",
976            ".git/config",
977            "x/.GIT/y",
978        ] {
979            assert!(lexical_problem(bad).is_some(), "{bad:?} must be refused");
980        }
981        for good in [".env", "config/local.json", ".npmrc", "a.b/c"] {
982            assert!(lexical_problem(good).is_none(), "{good:?} must pass");
983        }
984    }
985
986    #[test]
987    fn a_tracked_path_inside_above_or_equal_collides() {
988        let files = s(&["src/a.ts", "vendor", "README.md"]);
989        assert!(tracked_collision("src", &files).is_some());
990        assert!(tracked_collision("src/a.ts", &files).is_some());
991        assert!(tracked_collision("vendor/x", &files).is_some());
992        assert!(tracked_collision(".env", &files).is_none());
993        assert!(tracked_collision("sr", &files).is_none());
994    }
995
996    #[test]
997    fn lockfile_dirs_come_from_the_list_given() {
998        let files = s(&[
999            "package-lock.json",
1000            "web/package-lock.json",
1001            "web/x/pnpm-lock.yaml",
1002            "notpackage-lock.json",
1003        ]);
1004        assert_eq!(
1005            lockfile_dirs_in(&files, "package-lock.json"),
1006            s(&["", "web"])
1007        );
1008        assert_eq!(lockfile_dirs_in(&files, "pnpm-lock.yaml"), s(&["web/x"]));
1009    }
1010
1011    #[test]
1012    fn package_manager_field_is_read_narrowly() {
1013        assert_eq!(
1014            declared_manager(r#"{"packageManager": "pnpm@10.1.0"}"#),
1015            Some(Manager::Pnpm)
1016        );
1017        assert_eq!(
1018            declared_manager(r#"{"packageManager":"npm@11"}"#),
1019            Some(Manager::Npm)
1020        );
1021        assert_eq!(declared_manager(r#"{"packageManager":"yarn@4"}"#), None);
1022        assert_eq!(declared_manager(r#"{"name":"x"}"#), None);
1023        assert_eq!(
1024            declared_manager(r#"{"packageManager":"npm@1","x":{"packageManager":"pnpm@1"}}"#),
1025            None,
1026            "two answers is no answer"
1027        );
1028    }
1029
1030    fn unit(dir: &str) -> Unit {
1031        Unit {
1032            dir: dir.to_string(),
1033            manager: Manager::Pnpm,
1034            standalone: !dir.is_empty(),
1035        }
1036    }
1037
1038    #[test]
1039    fn only_the_root_and_the_touched_units_are_kept() {
1040        let all = vec![unit(""), unit("spikes/a"), unit("spikes/b"), unit("tools")];
1041        let changed = s(&["src/x.ts", "spikes/b/index.ts", "spikes/bb/y.ts"]);
1042        let (kept, skipped) = select_units(all.clone(), Some(&changed));
1043        let dirs = |v: &[Unit]| v.iter().map(|u| u.dir.clone()).collect::<Vec<_>>();
1044        assert_eq!(
1045            dirs(&kept),
1046            s(&["", "spikes/b"]),
1047            "spikes/bb is not spikes/b"
1048        );
1049        assert_eq!(dirs(&skipped), s(&["spikes/a", "tools"]));
1050        let (kept, skipped) = select_units(all.clone(), None);
1051        assert_eq!(kept.len(), 4, "an unknown range keeps everything");
1052        assert!(skipped.is_empty());
1053    }
1054
1055    #[test]
1056    fn a_nested_unit_claims_its_files_from_the_outer_one() {
1057        let all = vec![unit("web"), unit("web/nested")];
1058        let changed = s(&["web/nested/a.ts"]);
1059        let (kept, _) = select_units(all, Some(&changed));
1060        assert_eq!(kept.len(), 1);
1061        assert_eq!(kept[0].dir, "web/nested");
1062    }
1063
1064    #[test]
1065    fn a_file_newer_than_the_install_record_is_found() {
1066        let d = std::env::temp_dir().join(format!("newer-{}", std::process::id()));
1067        let _ = std::fs::remove_dir_all(&d);
1068        let pkg = d.join(".pnpm/a@1/node_modules/a");
1069        std::fs::create_dir_all(&pkg).unwrap();
1070        std::fs::create_dir_all(d.join(".bin")).unwrap();
1071        std::fs::create_dir_all(d.join(".cache")).unwrap();
1072        std::fs::write(pkg.join("index.js"), "old").unwrap();
1073        let record = std::time::SystemTime::now();
1074        std::thread::sleep(std::time::Duration::from_millis(20));
1075        for skipped in [".bin/tsc", ".cache/x", ".pnpm/lock.yaml", ".modules.yaml"] {
1076            std::fs::write(d.join(skipped), "new").unwrap();
1077        }
1078        assert_eq!(
1079            newer_than(&d, record).unwrap(),
1080            None,
1081            "the install's own writes"
1082        );
1083        std::fs::write(pkg.join("index.js"), "edited").unwrap();
1084        assert_eq!(newer_than(&d, record).unwrap(), Some(pkg.join("index.js")));
1085        let _ = std::fs::remove_dir_all(&d);
1086    }
1087
1088    #[test]
1089    fn normalize_folds_dots_lexically() {
1090        assert_eq!(
1091            normalize(Path::new("/a/b/../c/./d")),
1092            PathBuf::from("/a/c/d")
1093        );
1094    }
1095}