Skip to main content

speccoder_runtime/
prepare.rs

1//! A run's worktree gets its dependency folders (`[worktree]`).
2//!
3//! A worktree holds only what is committed, and dependency folders never
4//! are (`node_modules/`, `.venv/`): a check that needs them cannot start
5//! there. Before the baseline each Node or uv root of the project gets its
6//! folder by the toolchain's own locked, offline install, sandboxed — once
7//! per lockfile: a record beside the worktree keeps the lockfile's digest,
8//! and an attempt that changes the lockfile is installed again before its
9//! checks. Nothing is removed or installed at a folder outside the worktree
10//! or reached through a symlink.
11
12use std::path::{Path, PathBuf};
13use std::time::Duration;
14
15use serde::{Deserialize, Serialize};
16
17use crate::git::{Git, nul_fields};
18use crate::sandbox::Runner;
19use crate::verify::process::{CommandRunner as _, Invocation, OUTPUT_CAP, Outcome, scrubbed_env};
20
21/// How long one install may take.
22const INSTALL_TIMEOUT: Duration = Duration::from_secs(600);
23
24/// `[worktree]` in `speccoder.toml`.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct Config {
27    /// `prepare = "auto"` (the default): prepare Node and uv roots before
28    /// the baseline; `"off"`: never.
29    pub prepare: bool,
30    /// Git-ignored files of the checkout copied into each worktree:
31    /// `.gitignore`-style patterns (`.env`, `config/*.local.json`), joined
32    /// with `.worktreeinclude`'s (see [`crate::include`]).
33    pub include: Vec<String>,
34}
35
36impl Default for Config {
37    fn default() -> Self {
38        Self {
39            prepare: true,
40            include: Vec::new(),
41        }
42    }
43}
44
45#[derive(Debug, Default, Deserialize)]
46#[serde(deny_unknown_fields)]
47struct Raw {
48    prepare: Option<String>,
49    #[serde(default)]
50    include: Vec<String>,
51}
52
53/// Reads `[worktree]`.
54///
55/// # Errors
56///
57/// An unknown key, a `prepare` other than `auto` or `off`, or an `include`
58/// pattern that is empty or leaves the project.
59pub fn parse(text: &str) -> Result<Config, String> {
60    let table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
61    let Some(v) = table.get("worktree") else {
62        return Ok(Config::default());
63    };
64    let raw: Raw = v
65        .clone()
66        .try_into()
67        .map_err(|e| format!("[worktree]: {e}"))?;
68    let prepare = match raw.prepare.as_deref() {
69        None | Some("auto") => true,
70        Some("off") => false,
71        Some(o) => return Err(format!("[worktree] prepare = \"{o}\" (auto or off)")),
72    };
73    let mut include = Vec::new();
74    for p in &raw.include {
75        let p = p.trim();
76        if p.is_empty() {
77            return Err("[worktree] include: an empty pattern".to_owned());
78        }
79        if p.starts_with('~') || p.split('/').any(|c| c == "..") {
80            return Err(format!("[worktree] include: `{p}` leaves the project"));
81        }
82        if p.split('/').any(|c| c == ".git") {
83            return Err(format!("[worktree] include: `{p}` is git's own"));
84        }
85        if !include.iter().any(|q: &String| q == p) {
86            include.push(p.to_owned());
87        }
88    }
89    Ok(Config { prepare, include })
90}
91
92/// A toolchain whose project needs a dependency folder in the tree its
93/// checks run in.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(rename_all = "kebab-case")]
96pub enum Toolchain {
97    /// npm (`package-lock.json`).
98    Npm,
99    /// pnpm (`pnpm-lock.yaml`).
100    Pnpm,
101    /// Yarn 1 (`yarn.lock`).
102    Yarn,
103    /// Yarn 2+ (a `yarn.lock` with `__metadata`).
104    YarnBerry,
105    /// bun (`bun.lock`, `bun.lockb`).
106    Bun,
107    /// uv (`uv.lock`).
108    Uv,
109}
110
111/// The lockfiles, in the order one is chosen when a directory has several.
112const LOCKFILES: &[(&str, Toolchain)] = &[
113    ("package-lock.json", Toolchain::Npm),
114    ("pnpm-lock.yaml", Toolchain::Pnpm),
115    ("yarn.lock", Toolchain::Yarn),
116    ("bun.lock", Toolchain::Bun),
117    ("bun.lockb", Toolchain::Bun),
118    ("uv.lock", Toolchain::Uv),
119];
120
121impl Toolchain {
122    /// The name in the record and the run log.
123    #[must_use]
124    pub const fn name(self) -> &'static str {
125        match self {
126            Self::Npm => "npm",
127            Self::Pnpm => "pnpm",
128            Self::Yarn => "yarn",
129            Self::YarnBerry => "yarn-berry",
130            Self::Bun => "bun",
131            Self::Uv => "uv",
132        }
133    }
134
135    /// The folder it installs into, beside its lockfile.
136    #[must_use]
137    pub const fn folder(self) -> &'static str {
138        match self {
139            Self::Uv => ".venv",
140            _ => "node_modules",
141        }
142    }
143
144    /// The manifest its lockfile sits beside.
145    #[must_use]
146    pub const fn manifest(self) -> &'static str {
147        match self {
148            Self::Uv => "pyproject.toml",
149            _ => "package.json",
150        }
151    }
152
153    /// Its locked install: offline from the toolchain's cache, or the
154    /// online form (which `[sandbox] network_allow` may let reach the
155    /// network). bun and Yarn 2+ have no offline flag: they read their
156    /// cache first.
157    #[must_use]
158    pub fn install(self, offline: bool) -> Vec<String> {
159        let w: &[&str] = match (self, offline) {
160            (Self::Npm, true) => &["npm", "ci", "--offline"],
161            (Self::Npm, false) => &["npm", "ci"],
162            (Self::Pnpm, true) => &["pnpm", "install", "--frozen-lockfile", "--offline"],
163            (Self::Pnpm, false) => &["pnpm", "install", "--frozen-lockfile"],
164            (Self::Yarn, true) => &["yarn", "install", "--frozen-lockfile", "--offline"],
165            (Self::Yarn, false) => &["yarn", "install", "--frozen-lockfile"],
166            (Self::YarnBerry, _) => &["yarn", "install", "--immutable"],
167            (Self::Bun, _) => &["bun", "install", "--frozen-lockfile"],
168            (Self::Uv, true) => &["uv", "sync", "--locked", "--offline"],
169            (Self::Uv, false) => &["uv", "sync", "--locked"],
170        };
171        w.iter().map(|s| (*s).to_owned()).collect()
172    }
173
174    /// What fills its cache, run once in the checkout.
175    #[must_use]
176    pub const fn fill(self) -> &'static str {
177        match self {
178            Self::Npm => "npm install",
179            Self::Pnpm => "pnpm install",
180            Self::Yarn | Self::YarnBerry => "yarn install",
181            Self::Bun => "bun install",
182            Self::Uv => "uv sync",
183        }
184    }
185}
186
187/// A directory of the project with a committed lockfile beside its manifest.
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub struct Root {
190    /// Project-relative; `""` is the project's own directory.
191    pub dir: String,
192    /// The toolchain its lockfile names.
193    pub toolchain: Toolchain,
194    /// The lockfile, project-relative.
195    pub lockfile: String,
196}
197
198impl Root {
199    /// Its dependency folder, project-relative.
200    #[must_use]
201    pub fn folder(&self) -> String {
202        join(&self.dir, self.toolchain.folder())
203    }
204}
205
206fn join(dir: &str, name: &str) -> String {
207    if dir.is_empty() {
208        name.to_owned()
209    } else {
210        format!("{dir}/{name}")
211    }
212}
213
214/// The project's toolchain roots: each committed lockfile beside its
215/// manifest (one per directory), never inside a dependency folder. Go and
216/// Rust have none: their caches are shared already.
217#[must_use]
218pub fn roots(project: &Path) -> Vec<Root> {
219    let listed = Git::at(project)
220        .run(&["ls-files", "-z"])
221        .map(|o| nul_fields(&o))
222        .unwrap_or_default();
223    let mut out: Vec<(usize, Root)> = Vec::new();
224    for path in &listed {
225        if path.split('/').any(|c| c == "node_modules" || c == ".venv") {
226            continue;
227        }
228        let (dir, name) = path.rsplit_once('/').unwrap_or(("", path.as_str()));
229        let Some((rank, (_, toolchain))) =
230            LOCKFILES.iter().enumerate().find(|(_, (n, _))| *n == name)
231        else {
232            continue;
233        };
234        if !project.join(dir).join(toolchain.manifest()).is_file() {
235            continue;
236        }
237        let toolchain = match toolchain {
238            // Yarn 2+ writes `__metadata` at the top of its lockfile.
239            Toolchain::Yarn
240                if std::fs::read_to_string(project.join(path))
241                    .is_ok_and(|t| t.contains("\n__metadata:") || t.starts_with("__metadata:")) =>
242            {
243                Toolchain::YarnBerry
244            }
245            t => *t,
246        };
247        let root = Root {
248            dir: dir.to_owned(),
249            toolchain,
250            lockfile: path.clone(),
251        };
252        let node = |t: Toolchain| t != Toolchain::Uv;
253        match out
254            .iter_mut()
255            .find(|(_, r)| r.dir == root.dir && node(r.toolchain) == node(root.toolchain))
256        {
257            Some(seen) if seen.0 > rank => *seen = (rank, root),
258            Some(_) => {}
259            None => out.push((rank, root)),
260        }
261    }
262    let mut roots: Vec<Root> = out.into_iter().map(|(_, r)| r).collect();
263    // A workspace member: its lockfile is the workspace's, above it.
264    for uv in [false, true] {
265        let manifest = if uv { "pyproject.toml" } else { "package.json" };
266        if project.join(manifest).is_file()
267            && !roots
268                .iter()
269                .any(|r| r.dir.is_empty() && (r.toolchain == Toolchain::Uv) == uv)
270            && let Some(r) = above(project, uv)
271        {
272            roots.push(r);
273        }
274    }
275    roots.sort_by(|a, b| (&a.dir, a.toolchain.folder()).cmp(&(&b.dir, b.toolchain.folder())));
276    roots
277}
278
279/// The nearest committed lockfile (Node, or uv's) above the project, up to
280/// the repository's root, beside its manifest: the root that governs the
281/// project's dependencies, its `dir` made of `..` (still inside the tree).
282fn above(project: &Path, uv: bool) -> Option<Root> {
283    let g = Git::at(project);
284    let prefix = g.line(&["rev-parse", "--show-prefix"]).ok()?;
285    let parts: Vec<&str> = prefix.split('/').filter(|c| !c.is_empty()).collect();
286    for up in 1..=parts.len() {
287        let top = parts[..parts.len() - up].join("/");
288        let back = vec![".."; up].join("/");
289        for (name, toolchain) in LOCKFILES
290            .iter()
291            .filter(|(_, t)| (*t == Toolchain::Uv) == uv)
292        {
293            let committed = g
294                .run(&[
295                    "ls-files",
296                    "-z",
297                    "--",
298                    &format!(":(top){}", join(&top, name)),
299                ])
300                .is_ok_and(|o| !o.is_empty());
301            if !committed || !project.join(&back).join(toolchain.manifest()).is_file() {
302                continue;
303            }
304            let lockfile = join(&back, name);
305            let toolchain = match toolchain {
306                Toolchain::Yarn
307                    if std::fs::read_to_string(project.join(&lockfile)).is_ok_and(|t| {
308                        t.contains("\n__metadata:") || t.starts_with("__metadata:")
309                    }) =>
310                {
311                    Toolchain::YarnBerry
312                }
313                t => *t,
314            };
315            return Some(Root {
316                dir: back,
317                toolchain,
318                lockfile,
319            });
320        }
321    }
322    None
323}
324
325/// Whether the project has anything to prepare: a root, or a manifest
326/// whose lockfile is missing.
327#[must_use]
328pub fn needed(project: &Path) -> bool {
329    let roots = roots(project);
330    !roots.is_empty() || missing_lockfile(project, &roots).is_some()
331}
332
333/// A manifest at the project's root that needs a lockfile none is
334/// committed for: `(what is missing, the command that writes it)`.
335#[must_use]
336pub fn missing_lockfile(project: &Path, roots: &[Root]) -> Option<(String, String)> {
337    // The project's own directory, or a workspace's above it.
338    let at_root = |uv: bool| {
339        roots.iter().any(|r| {
340            r.dir.split('/').all(|c| c.is_empty() || c == "..")
341                && (r.toolchain == Toolchain::Uv) == uv
342        })
343    };
344    let package = std::fs::read_to_string(project.join("package.json"))
345        .ok()
346        .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok());
347    if let Some(p) = package
348        && !at_root(false)
349        && ["dependencies", "devDependencies", "optionalDependencies"]
350            .iter()
351            .any(|k| p[*k].as_object().is_some_and(|o| !o.is_empty()))
352    {
353        return Some((
354            "package.json declares dependencies, but no lockfile is committed".to_owned(),
355            "npm install (writes package-lock.json; commit it)".to_owned(),
356        ));
357    }
358    let pyproject = std::fs::read_to_string(project.join("pyproject.toml")).unwrap_or_default();
359    let uv = pyproject.contains("[tool.uv") || pyproject.contains("uv_build");
360    if uv && !at_root(true) {
361        return Some((
362            "pyproject.toml is a uv project, but no uv.lock is committed".to_owned(),
363            "uv lock (writes uv.lock; commit it)".to_owned(),
364        ));
365    }
366    None
367}
368
369/// One root as prepared.
370#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
371pub struct Entry {
372    /// The root, project-relative.
373    pub dir: String,
374    /// Its toolchain.
375    pub toolchain: Toolchain,
376    /// Its lockfile, project-relative.
377    pub lockfile: String,
378    /// sha256 of the lockfile it was installed for.
379    pub lock_sha256: String,
380    /// Why it was installed again, when an attempt changed its lockfile.
381    #[serde(default, skip_serializing_if = "Option::is_none")]
382    pub why: Option<String>,
383    /// When (seconds since the epoch).
384    pub at: u64,
385}
386
387impl Entry {
388    /// The folder, project-relative.
389    #[must_use]
390    pub fn folder(&self) -> String {
391        join(&self.dir, self.toolchain.folder())
392    }
393
394    fn root(&self) -> Root {
395        Root {
396            dir: self.dir.clone(),
397            toolchain: self.toolchain,
398            lockfile: self.lockfile.clone(),
399        }
400    }
401}
402
403/// What a worktree was prepared with.
404#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
405pub struct Record {
406    /// Per root.
407    #[serde(default)]
408    pub roots: Vec<Entry>,
409}
410
411/// Where a worktree's record is kept: beside it (`<name>.prepare.json`),
412/// never inside it, where git would see it.
413#[must_use]
414pub fn record_path(worktree: &Path) -> PathBuf {
415    let name = worktree
416        .file_name()
417        .map(|n| n.to_string_lossy().into_owned())
418        .unwrap_or_default();
419    worktree.with_file_name(format!("{name}.prepare.json"))
420}
421
422/// The worktree's record (none: nothing prepared yet).
423#[must_use]
424pub fn load(worktree: &Path) -> Record {
425    std::fs::read_to_string(record_path(worktree))
426        .ok()
427        .and_then(|t| serde_json::from_str(&t).ok())
428        .unwrap_or_default()
429}
430
431fn store(worktree: &Path, record: &Record) -> Result<(), Failure> {
432    let path = record_path(worktree);
433    let tmp = path.with_extension("json.tmp");
434    serde_json::to_string_pretty(record)
435        .map_err(|e| e.to_string())
436        .and_then(|text| {
437            std::fs::write(&tmp, text)
438                .and_then(|()| std::fs::rename(&tmp, &path))
439                .map_err(|e| format!("{}: {e}", path.display()))
440        })
441        .map_err(|e| Failure {
442            message: format!("the worktree's prepare record could not be written: {e}"),
443            fix: vec!["speccoder doctor".to_owned()],
444        })
445}
446
447/// Forgets what a worktree was prepared with (its folders stay, the
448/// worktree's own): the next prepare installs again.
449pub fn forget(worktree: &Path) {
450    let _ = std::fs::remove_file(record_path(worktree));
451}
452
453/// sha256 of a file, hex; `None` when it cannot be read.
454#[must_use]
455pub fn sha256_file(path: &Path) -> Option<String> {
456    std::fs::read(path)
457        .ok()
458        .map(|b| crate::integrity::sha256_hex(&b))
459}
460
461/// What preparing needs.
462pub struct Prepare<'a> {
463    /// The worktree's root (the record is kept beside it).
464    pub worktree: &'a Path,
465    /// The project's directory inside it.
466    pub project: &'a Path,
467    /// `[worktree]`.
468    pub config: &'a Config,
469    /// The worktree's (sandboxed) runner.
470    pub runner: &'a Runner,
471    /// speccoder's state directory: the cache `speccoder new`'s steps
472    /// filled is tried after the person's own.
473    pub state: Option<&'a Path>,
474}
475
476/// What preparing did.
477#[derive(Debug, Clone, Default, PartialEq, Eq)]
478pub struct Prepared {
479    /// One line per root installed now (`npm ci --offline (2.1s)`); empty:
480    /// everything was reused.
481    pub done: Vec<String>,
482    /// The record as stored.
483    pub record: Record,
484}
485
486/// Why preparing stopped the task.
487#[derive(Debug, Clone, PartialEq, Eq)]
488pub struct Failure {
489    /// What failed, with the toolchain's own reason.
490    pub message: String,
491    /// How to resolve it.
492    pub fix: Vec<String>,
493}
494
495fn now() -> u64 {
496    std::time::SystemTime::now()
497        .duration_since(std::time::UNIX_EPOCH)
498        .map_or(0, |d| d.as_secs())
499}
500
501/// Prepares the worktree: each root's folder installed, unless the record
502/// shows it installed for the same lockfile and still there. `None`: the
503/// project has no root that needs it (Go, Rust).
504///
505/// # Errors
506///
507/// A lockfile is missing, a folder is not the worktree's own, or an
508/// install failed: the task stops (`PREPARE-FAILED`), never a verdict.
509pub fn prepare(p: &Prepare<'_>) -> Result<Option<Prepared>, Failure> {
510    let roots = roots(p.project);
511    if let Some((what, writes)) = missing_lockfile(p.project, &roots) {
512        return Err(Failure {
513            message: format!("{what}: the worktree cannot get the dependencies it locks"),
514            fix: vec![
515                format!("→ in your checkout: {writes}"),
516                "→ or, when the checks need none of it: [worktree] prepare = \"off\"".to_owned(),
517            ],
518        });
519    }
520    if roots.is_empty() {
521        forget(p.worktree);
522        return Ok(None);
523    }
524    let old = load(p.worktree);
525    let mut out = Prepared::default();
526    let done = prepare_roots(p, &roots, &old, &mut out).and_then(|()| {
527        if out.record == old && record_path(p.worktree).is_file() {
528            Ok(())
529        } else {
530            store(p.worktree, &out.record)
531        }
532    });
533    if let Err(f) = done {
534        // Half prepared: the next run starts over.
535        forget(p.worktree);
536        return Err(f);
537    }
538    Ok(Some(out))
539}
540
541fn prepare_roots(
542    p: &Prepare<'_>,
543    roots: &[Root],
544    old: &Record,
545    out: &mut Prepared,
546) -> Result<(), Failure> {
547    for root in roots {
548        let folder = p.project.join(root.folder());
549        let sha = sha256_file(&p.project.join(&root.lockfile)).unwrap_or_default();
550        let prev = old.roots.iter().find(|e| {
551            e.dir == root.dir && e.toolchain == root.toolchain && e.lockfile == root.lockfile
552        });
553        if let Some(e) = prev
554            && e.lock_sha256 == sha
555            && holds(p.worktree, &folder)
556        {
557            out.record.roots.push(e.clone());
558            continue;
559        }
560        out.done.push(install_at(p, root, &folder)?);
561        out.record.roots.push(Entry {
562            dir: root.dir.clone(),
563            toolchain: root.toolchain,
564            lockfile: root.lockfile.clone(),
565            lock_sha256: sha,
566            why: None,
567            at: now(),
568        });
569    }
570    Ok(())
571}
572
573/// Whether an installed folder is still the worktree's own: a directory,
574/// not a link, reached without one.
575fn holds(worktree: &Path, folder: &Path) -> bool {
576    confined(worktree, folder).is_ok()
577        && std::fs::symlink_metadata(folder).is_ok_and(|m| m.is_dir())
578}
579
580/// `root`'s install at its `folder`, once the folder is known to be the
581/// worktree's own: never at one outside it or reached through a symlink,
582/// and a link there (an attempt can make one) goes first — itself, never
583/// what it points to.
584fn install_at(p: &Prepare<'_>, root: &Root, folder: &Path) -> Result<String, Failure> {
585    confined(p.worktree, folder).map_err(|why| Failure {
586        message: format!("the worktree's {} cannot be prepared: {why}", root.folder()),
587        fix: vec!["speccoder doctor".to_owned()],
588    })?;
589    remove_link(folder)?;
590    install(p, root)
591}
592
593/// After an attempt, before its checks: a root whose lockfile the attempt
594/// changed is installed again for it, so the checks and the tick are about
595/// the lockfile committed. The run log's lines; none when nothing changed.
596///
597/// # Errors
598///
599/// Its install failed: the task stops (`PREPARE-FAILED`).
600pub fn recheck(p: &Prepare<'_>) -> Result<Vec<String>, Failure> {
601    let mut record = load(p.worktree);
602    let mut done = Vec::new();
603    for e in &mut record.roots {
604        let sha = sha256_file(&p.project.join(&e.lockfile)).unwrap_or_default();
605        if sha == e.lock_sha256 {
606            continue;
607        }
608        let folder = p.project.join(e.folder());
609        match install_at(p, &e.root(), &folder) {
610            Ok(ran) => {
611                done.push(ran);
612                e.lock_sha256 = sha;
613                e.why = Some("the attempt changed its lockfile".to_owned());
614                e.at = now();
615            }
616            Err(f) => {
617                forget(p.worktree);
618                return Err(f);
619            }
620        }
621    }
622    if !done.is_empty() {
623        store(p.worktree, &record)?;
624    }
625    Ok(done)
626}
627
628/// Removes `folder` when it is a link — the link itself, never what it
629/// points to.
630fn remove_link(folder: &Path) -> Result<(), Failure> {
631    if std::fs::symlink_metadata(folder).is_ok_and(|m| m.file_type().is_symlink()) {
632        std::fs::remove_file(folder).map_err(|e| Failure {
633            message: format!(
634                "the worktree's link {} could not be removed: {e}",
635                folder.display()
636            ),
637            fix: vec!["speccoder doctor".to_owned()],
638        })?;
639    }
640    Ok(())
641}
642
643/// `p` with each `..` taken back and each `.` dropped, without asking the
644/// file system.
645fn lexical(p: &Path) -> PathBuf {
646    use std::path::Component;
647    let mut out = PathBuf::new();
648    for c in p.components() {
649        match c {
650            Component::CurDir => {}
651            Component::ParentDir => {
652                if !out.pop() {
653                    out.push("..");
654                }
655            }
656            c => out.push(c),
657        }
658    }
659    out
660}
661
662/// Where `folder` really lies, when that is inside the worktree and its
663/// parent is reached without a symlink on the way; else why not. Nothing
664/// is removed or linked at a folder that is not.
665fn confined(worktree: &Path, folder: &Path) -> Result<PathBuf, String> {
666    let root =
667        std::fs::canonicalize(worktree).map_err(|e| format!("{}: {e}", worktree.display()))?;
668    let (Some(parent), Some(name)) = (folder.parent(), folder.file_name()) else {
669        return Err("it is not a folder of the worktree".to_owned());
670    };
671    let lex = lexical(parent);
672    let rel = lex
673        .strip_prefix(lexical(worktree))
674        .or_else(|_| lex.strip_prefix(&root))
675        .map_err(|_| "it lies outside the worktree".to_owned())?
676        .to_path_buf();
677    let real = std::fs::canonicalize(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
678    if real != root.join(&rel) {
679        return Err(format!(
680            "`{}` is reached through a symlink (to {}), so it is not the worktree's own",
681            rel.display(),
682            real.display()
683        ));
684    }
685    Ok(real.join(name))
686}
687
688/// Runs `root`'s install in its directory, sandboxed: the offline form,
689/// or the online one when `[sandbox] network_allow` names it exactly;
690/// once more from `speccoder new`'s own cache when the person's lacks a
691/// package. The line for the run log.
692fn install(p: &Prepare<'_>, root: &Root) -> Result<String, Failure> {
693    let online = root.toolchain.install(false);
694    let argv = if p.runner.allows_network(&online) {
695        online.clone()
696    } else {
697        root.toolchain.install(true)
698    };
699    let cwd = p.project.join(&root.dir);
700    let mut env = scrubbed_env(std::env::vars());
701    env.push(("npm_config_update_notifier".to_owned(), "false".to_owned()));
702    let run = |runner: &Runner, env: Vec<(String, String)>| {
703        runner.run(&Invocation {
704            argv: argv.clone(),
705            cwd: cwd.clone(),
706            env,
707            timeout: INSTALL_TIMEOUT,
708            output_cap: OUTPUT_CAP,
709        })
710    };
711    let ok = |o: &Outcome| o.exit_code == Some(0) && !o.timed_out && o.spawn_error.is_none();
712    let began = std::time::Instant::now();
713    let mut out = run(p.runner, env.clone());
714    if !ok(&out)
715        && out.spawn_error.is_none()
716        && let Some((cache, vars)) = p.state.and_then(|s| {
717            crate::new_project::steps::filled_cache(s, argv.first().map_or("", String::as_str))
718        })
719    {
720        let mut env = env;
721        for (k, v) in vars {
722            env.retain(|(key, _)| *key != k);
723            env.push((k, v));
724        }
725        out = run(&p.runner.clone().with_writable(&cache), env);
726    }
727    let shown = argv.join(" ");
728    let at = if root.dir.is_empty() {
729        String::new()
730    } else {
731        format!(" in {}", root.dir)
732    };
733    if ok(&out) {
734        return Ok(format!(
735            "{shown}{at} ({:.1}s)",
736            began.elapsed().as_secs_f64()
737        ));
738    }
739    let mut fix = vec![format!(
740        "→ run `{}` once in your checkout{at} to fill {}'s cache",
741        root.toolchain.fill(),
742        argv.first().map_or("the toolchain", String::as_str)
743    )];
744    if online != argv {
745        fix.push(format!(
746            "→ or let `{}` reach the network: [sandbox] network_allow = [[{}]]",
747            online.join(" "),
748            online
749                .iter()
750                .map(|a| format!("\"{a}\""))
751                .collect::<Vec<_>>()
752                .join(", ")
753        ));
754    }
755    fix.push("speccoder doctor".to_owned());
756    Err(Failure {
757        message: format!("`{shown}` failed in the worktree{at}: {}", reason(&out)),
758        fix,
759    })
760}
761
762/// The toolchain's own reason, in a line: its first error line (and the
763/// cause uv gives on the next), else the last line it printed.
764fn reason(out: &Outcome) -> String {
765    if let Some(e) = &out.spawn_error {
766        return e.clone();
767    }
768    if out.timed_out {
769        return format!("timed out after {}s", out.duration.as_secs());
770    }
771    let text = format!(
772        "{}\n{}",
773        String::from_utf8_lossy(&out.stderr.kept),
774        String::from_utf8_lossy(&out.stdout.kept)
775    );
776    let lines: Vec<&str> = text
777        .lines()
778        .map(str::trim)
779        .filter(|l| !l.is_empty())
780        .collect();
781    let noise = |l: &str| {
782        l.contains("complete log of this run")
783            || l.contains("Log files were not written")
784            || (l.starts_with("npm error code ") && !l["npm error code ".len()..].contains(' '))
785    };
786    let first = lines.iter().position(|l| {
787        let low = l.to_lowercase();
788        (low.starts_with("error") || low.starts_with("npm error") || low.starts_with("npm err!"))
789            && !noise(l)
790    });
791    match first {
792        Some(i) => {
793            let line = lines[i]
794                .trim_start_matches("npm error ")
795                .trim_start_matches("npm ERR! ");
796            match lines.get(i + 1).filter(|n| n.starts_with("cause:")) {
797                Some(cause) => format!("{line} — {cause}"),
798                None => line.to_owned(),
799            }
800        }
801        None => lines
802            .last()
803            .map_or_else(|| format!("exit {:?}", out.exit_code), |l| (*l).to_owned()),
804    }
805}
806
807#[cfg(test)]
808mod tests {
809    use super::*;
810
811    fn git(dir: &Path, args: &[&str]) {
812        let ok = std::process::Command::new("git")
813            .args(["-c", "user.name=t", "-c", "user.email=t@example.com"])
814            .args([
815                "-c",
816                "commit.gpgsign=false",
817                "-c",
818                "init.defaultBranch=main",
819            ])
820            .args(args)
821            .current_dir(dir)
822            .status()
823            .is_ok_and(|s| s.success());
824        assert!(ok, "git {args:?}");
825    }
826
827    fn repo(files: &[(&str, &str)]) -> tempfile::TempDir {
828        let dir = tempfile::tempdir().unwrap();
829        for (p, c) in files {
830            let f = dir.path().join(p);
831            std::fs::create_dir_all(f.parent().unwrap()).unwrap();
832            std::fs::write(f, c).unwrap();
833        }
834        git(dir.path(), &["init", "-q"]);
835        git(dir.path(), &["add", "-A"]);
836        git(dir.path(), &["commit", "-q", "-m", "init"]);
837        dir
838    }
839
840    #[test]
841    fn the_section_parses_with_its_defaults_and_refuses_patterns_outside() {
842        assert_eq!(parse("").unwrap(), Config::default());
843        let c = parse("[worktree]\nprepare = \"off\"\ninclude = [\".env\", \"config/*.local.json\", \".env\"]\n").unwrap();
844        assert!(!c.prepare);
845        assert_eq!(c.include, [".env", "config/*.local.json"]);
846        for bad in ["", "../up", "a/../b", "~/x", ".git/config"] {
847            let e = parse(&format!("[worktree]\ninclude = [\"{bad}\"]\n")).unwrap_err();
848            assert!(e.starts_with("[worktree] include: "), "{bad}: {e}");
849        }
850        assert!(parse("[worktree]\nprepare = \"always\"\n").is_err());
851        assert!(parse("[worktree]\nlink = []\n").is_err());
852    }
853
854    #[test]
855    fn the_record_is_kept_beside_the_worktree_and_round_trips() {
856        let dir = tempfile::tempdir().unwrap();
857        let wt = dir.path().join("001-x");
858        std::fs::create_dir_all(&wt).unwrap();
859        assert_eq!(record_path(&wt), dir.path().join("001-x.prepare.json"));
860        assert_eq!(load(&wt), Record::default());
861        let record = Record {
862            roots: vec![Entry {
863                dir: String::new(),
864                toolchain: Toolchain::Uv,
865                lockfile: "uv.lock".to_owned(),
866                lock_sha256: "ab".to_owned(),
867                why: None,
868                at: 1,
869            }],
870        };
871        store(&wt, &record).unwrap();
872        assert_eq!(load(&wt), record);
873        forget(&wt);
874        assert!(!record_path(&wt).exists());
875    }
876
877    #[test]
878    fn a_folder_reached_through_a_symlinked_parent_is_never_touched() {
879        let checkout = tempfile::tempdir().unwrap();
880        let api = checkout.path().join("api");
881        std::fs::create_dir_all(api.join("node_modules/left-pad")).unwrap();
882        std::fs::write(api.join("node_modules/left-pad/index.js"), "x").unwrap();
883        std::fs::write(api.join("package.json"), "{}").unwrap();
884        std::fs::write(api.join("package-lock.json"), "{}").unwrap();
885        let wt = repo(&[("api/package.json", "{}"), ("api/package-lock.json", "{}")]);
886        // The worktree's `api` replaced by a link to the checkout's.
887        std::fs::remove_dir_all(wt.path().join("api")).unwrap();
888        std::os::unix::fs::symlink(&api, wt.path().join("api")).unwrap();
889        let entry = Entry {
890            dir: "api".to_owned(),
891            toolchain: Toolchain::Npm,
892            lockfile: "api/package-lock.json".to_owned(),
893            lock_sha256: "an older lockfile".to_owned(),
894            why: None,
895            at: 0,
896        };
897        let p = Prepare {
898            worktree: wt.path(),
899            project: wt.path(),
900            config: &Config::default(),
901            runner: &Runner::Plain,
902            state: None,
903        };
904        for _ in 0..2 {
905            store(
906                wt.path(),
907                &Record {
908                    roots: vec![entry.clone()],
909                },
910            )
911            .unwrap();
912            let f = prepare(&p).unwrap_err();
913            assert!(
914                f.message.contains("reached through a symlink"),
915                "{}",
916                f.message
917            );
918            assert!(api.join("node_modules/left-pad/index.js").is_file());
919            // Half prepared: forgotten, so the next run starts over.
920            assert!(!record_path(wt.path()).exists());
921            store(
922                wt.path(),
923                &Record {
924                    roots: vec![entry.clone()],
925                },
926            )
927            .unwrap();
928            let f = recheck(&p).unwrap_err();
929            assert!(
930                f.message.contains("reached through a symlink"),
931                "{}",
932                f.message
933            );
934            assert!(api.join("node_modules/left-pad/index.js").is_file());
935        }
936        // Reused as recorded, it would not be the worktree's own either.
937        store(
938            wt.path(),
939            &Record {
940                roots: vec![Entry {
941                    lock_sha256: sha256_file(&api.join("package-lock.json")).unwrap(),
942                    ..entry
943                }],
944            },
945        )
946        .unwrap();
947        assert!(prepare(&p).is_err());
948        assert!(api.join("node_modules/left-pad/index.js").is_file());
949    }
950
951    #[test]
952    fn a_link_where_a_folder_belongs_goes_itself_and_never_what_it_points_to() {
953        let checkout = tempfile::tempdir().unwrap();
954        std::fs::create_dir_all(checkout.path().join("node_modules/a")).unwrap();
955        std::fs::write(checkout.path().join("node_modules/a/index.js"), "x").unwrap();
956        let wt = repo(&[("package.json", "{}"), ("package-lock.json", "{}")]);
957        std::os::unix::fs::symlink(
958            checkout.path().join("node_modules"),
959            wt.path().join("node_modules"),
960        )
961        .unwrap();
962        // The install itself fails here (no npm cache holds this), after the link went.
963        let _ = install_at(
964            &Prepare {
965                worktree: wt.path(),
966                project: wt.path(),
967                config: &Config::default(),
968                runner: &Runner::Plain,
969                state: None,
970            },
971            &roots(wt.path())[0],
972            &wt.path().join("node_modules"),
973        );
974        assert!(
975            std::fs::symlink_metadata(wt.path().join("node_modules"))
976                .map_or(true, |m| !m.file_type().is_symlink())
977        );
978        assert!(checkout.path().join("node_modules/a/index.js").is_file());
979    }
980
981    #[test]
982    fn roots_are_the_committed_lockfiles_beside_their_manifests() {
983        let berry =
984            "# This file is generated by running \"yarn install\"\n\n__metadata:\n  version: 8\n";
985        type Files<'a> = &'a [(&'a str, &'a str)];
986        type Want<'a> = &'a [(&'a str, Toolchain, &'a str)];
987        let cases: &[(Files<'_>, Want<'_>)] = &[
988            (&[("Cargo.toml", ""), ("Cargo.lock", "")], &[]),
989            (&[("go.mod", ""), ("go.sum", "")], &[]),
990            (
991                &[("package.json", "{}"), ("package-lock.json", "{}")],
992                &[("", Toolchain::Npm, "package-lock.json")],
993            ),
994            (
995                &[("package.json", "{}"), ("pnpm-lock.yaml", "")],
996                &[("", Toolchain::Pnpm, "pnpm-lock.yaml")],
997            ),
998            (
999                &[
1000                    ("package.json", "{}"),
1001                    ("yarn.lock", "# yarn lockfile v1\n"),
1002                ],
1003                &[("", Toolchain::Yarn, "yarn.lock")],
1004            ),
1005            (
1006                &[("package.json", "{}"), ("yarn.lock", berry)],
1007                &[("", Toolchain::YarnBerry, "yarn.lock")],
1008            ),
1009            (
1010                &[("package.json", "{}"), ("bun.lock", "")],
1011                &[("", Toolchain::Bun, "bun.lock")],
1012            ),
1013            (
1014                &[("package.json", "{}"), ("bun.lockb", "")],
1015                &[("", Toolchain::Bun, "bun.lockb")],
1016            ),
1017            // Two Node lockfiles: npm's is chosen.
1018            (
1019                &[
1020                    ("package.json", "{}"),
1021                    ("yarn.lock", ""),
1022                    ("package-lock.json", "{}"),
1023                ],
1024                &[("", Toolchain::Npm, "package-lock.json")],
1025            ),
1026            (
1027                &[("pyproject.toml", ""), ("uv.lock", "")],
1028                &[("", Toolchain::Uv, "uv.lock")],
1029            ),
1030            // A workspace: each lockfile's directory on its own.
1031            (
1032                &[
1033                    ("package.json", "{}"),
1034                    ("package-lock.json", "{}"),
1035                    ("api/pyproject.toml", ""),
1036                    ("api/uv.lock", ""),
1037                ],
1038                &[
1039                    ("", Toolchain::Npm, "package-lock.json"),
1040                    ("api", Toolchain::Uv, "api/uv.lock"),
1041                ],
1042            ),
1043            // A lockfile without its manifest is no root.
1044            (&[("uv.lock", "")], &[]),
1045        ];
1046        for (files, want) in cases {
1047            let r = repo(files);
1048            let got: Vec<(String, Toolchain, String)> = roots(r.path())
1049                .into_iter()
1050                .map(|x| (x.dir, x.toolchain, x.lockfile))
1051                .collect();
1052            let want: Vec<(String, Toolchain, String)> = want
1053                .iter()
1054                .map(|(d, t, l)| ((*d).to_owned(), *t, (*l).to_owned()))
1055                .collect();
1056            assert_eq!(got, want, "{files:?}");
1057        }
1058        // Uncommitted lockfiles do not count.
1059        let r = repo(&[("package.json", "{}")]);
1060        std::fs::write(r.path().join("package-lock.json"), "{}").unwrap();
1061        assert!(roots(r.path()).is_empty());
1062    }
1063
1064    #[test]
1065    fn each_toolchain_installs_locked_and_offline() {
1066        let shown = |t: Toolchain, offline| t.install(offline).join(" ");
1067        assert_eq!(shown(Toolchain::Npm, true), "npm ci --offline");
1068        assert_eq!(shown(Toolchain::Npm, false), "npm ci");
1069        assert_eq!(
1070            shown(Toolchain::Pnpm, true),
1071            "pnpm install --frozen-lockfile --offline"
1072        );
1073        assert_eq!(
1074            shown(Toolchain::Yarn, true),
1075            "yarn install --frozen-lockfile --offline"
1076        );
1077        assert_eq!(
1078            shown(Toolchain::YarnBerry, true),
1079            "yarn install --immutable"
1080        );
1081        assert_eq!(shown(Toolchain::Bun, true), "bun install --frozen-lockfile");
1082        assert_eq!(shown(Toolchain::Uv, true), "uv sync --locked --offline");
1083        assert_eq!(shown(Toolchain::Uv, false), "uv sync --locked");
1084    }
1085
1086    #[test]
1087    fn a_manifest_with_dependencies_and_no_lockfile_is_named() {
1088        let r = repo(&[(
1089            "package.json",
1090            "{\"devDependencies\": {\"vitest\": \"^3\"}}",
1091        )]);
1092        let (what, writes) = missing_lockfile(r.path(), &roots(r.path())).unwrap();
1093        assert!(what.contains("no lockfile is committed"), "{what}");
1094        assert!(writes.starts_with("npm install"), "{writes}");
1095        // No dependencies: nothing to lock.
1096        let r = repo(&[("package.json", "{\"scripts\": {\"test\": \"node t.js\"}}")]);
1097        assert_eq!(missing_lockfile(r.path(), &roots(r.path())), None);
1098        let r = repo(&[(
1099            "pyproject.toml",
1100            "[project]\nname = \"x\"\n[build-system]\nrequires = [\"uv_build\"]\n",
1101        )]);
1102        let (what, writes) = missing_lockfile(r.path(), &roots(r.path())).unwrap();
1103        assert!(what.contains("uv.lock"), "{what}");
1104        assert!(writes.starts_with("uv lock"), "{writes}");
1105        let r = repo(&[("pyproject.toml", "[project]\nname = \"x\"\n")]);
1106        assert_eq!(missing_lockfile(r.path(), &roots(r.path())), None);
1107    }
1108
1109    #[test]
1110    fn a_workspace_members_lockfile_above_it_governs_it() {
1111        let r = repo(&[
1112            ("package.json", "{\"workspaces\": [\"app\"]}"),
1113            ("package-lock.json", "{}"),
1114            (
1115                "app/package.json",
1116                "{\"devDependencies\": {\"vitest\": \"^3\"}}",
1117            ),
1118            ("app/speccoder.toml", ""),
1119        ]);
1120        let app = r.path().join("app");
1121        let got = roots(&app);
1122        assert_eq!(
1123            got,
1124            [Root {
1125                dir: "..".to_owned(),
1126                toolchain: Toolchain::Npm,
1127                lockfile: "../package-lock.json".to_owned(),
1128            }]
1129        );
1130        assert_eq!(missing_lockfile(&app, &got), None);
1131        // Its folder is the repository root's, inside the tree.
1132        let folder = app.join(got[0].folder());
1133        assert_eq!(
1134            confined(r.path(), &folder).unwrap(),
1135            std::fs::canonicalize(r.path())
1136                .unwrap()
1137                .join("node_modules")
1138        );
1139        // A lockfile nobody committed governs nothing.
1140        let r = repo(&[(
1141            "app/package.json",
1142            "{\"devDependencies\": {\"vitest\": \"^3\"}}",
1143        )]);
1144        std::fs::write(r.path().join("package.json"), "{}").unwrap();
1145        std::fs::write(r.path().join("package-lock.json"), "{}").unwrap();
1146        let app = r.path().join("app");
1147        assert!(roots(&app).is_empty());
1148        assert!(missing_lockfile(&app, &[]).is_some());
1149    }
1150
1151    #[test]
1152    fn the_reason_is_the_toolchains_own_line() {
1153        let out = |stderr: &str, stdout: &str| Outcome {
1154            exit_code: Some(1),
1155            timed_out: false,
1156            spawn_error: None,
1157            started: std::time::SystemTime::now(),
1158            duration: Duration::ZERO,
1159            stdout: crate::verify::process::Captured {
1160                kept: stdout.as_bytes().to_vec(),
1161                total: stdout.len(),
1162            },
1163            stderr: crate::verify::process::Captured {
1164                kept: stderr.as_bytes().to_vec(),
1165                total: stderr.len(),
1166            },
1167        };
1168        assert_eq!(
1169            reason(&out(
1170                "npm error code ENOTCACHED\nnpm error request to https://registry.npmjs.org/x.tgz failed: cache mode is 'only-if-cached' but no cached response is available.\nnpm error A complete log of this run can be found in: /h/.npm/_logs/x.log\n",
1171                ""
1172            )),
1173            "request to https://registry.npmjs.org/x.tgz failed: cache mode is 'only-if-cached' but no cached response is available."
1174        );
1175        assert_eq!(
1176            reason(&out(
1177                "Resolved 7 packages in 3ms\nerror: Failed to download `pygments==2.21.0`\n  cause: Network connectivity is disabled, but the requested data wasn't found in the cache\n\nhint: `pygments` was included because …\n",
1178                ""
1179            )),
1180            "error: Failed to download `pygments==2.21.0` — cause: Network connectivity is disabled, but the requested data wasn't found in the cache"
1181        );
1182        assert_eq!(reason(&out("", "something odd\n")), "something odd");
1183    }
1184}