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