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".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".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            commands: Vec::new(),
445        })
446}
447
448/// Forgets what a worktree was prepared with (its folders stay, the
449/// worktree's own): the next prepare installs again.
450pub fn forget(worktree: &Path) {
451    let _ = std::fs::remove_file(record_path(worktree));
452}
453
454/// sha256 of a file, hex; `None` when it cannot be read.
455#[must_use]
456pub fn sha256_file(path: &Path) -> Option<String> {
457    std::fs::read(path)
458        .ok()
459        .map(|b| crate::integrity::sha256_hex(&b))
460}
461
462/// What preparing needs.
463pub struct Prepare<'a> {
464    /// The worktree's root (the record is kept beside it).
465    pub worktree: &'a Path,
466    /// The project's directory inside it.
467    pub project: &'a Path,
468    /// `[worktree]`.
469    pub config: &'a Config,
470    /// The worktree's (sandboxed) runner.
471    pub runner: &'a Runner,
472    /// speccoder's state directory: the cache `speccoder new`'s steps
473    /// filled is tried after the person's own.
474    pub state: Option<&'a Path>,
475}
476
477/// What preparing did.
478#[derive(Debug, Clone, Default, PartialEq, Eq)]
479pub struct Prepared {
480    /// One line per root installed now (`npm ci --offline (2.1s)`); empty:
481    /// everything was reused.
482    pub done: Vec<String>,
483    /// The record as stored.
484    pub record: Record,
485}
486
487/// Why preparing stopped the task.
488#[derive(Debug, Clone, PartialEq, Eq)]
489pub struct Failure {
490    /// What failed, with the toolchain's own reason.
491    pub message: String,
492    /// How to resolve it.
493    pub fix: Vec<String>,
494    /// The person's own commands among them, as argvs.
495    pub commands: Vec<speccoder_core::step::Argvs>,
496}
497
498fn now() -> u64 {
499    std::time::SystemTime::now()
500        .duration_since(std::time::UNIX_EPOCH)
501        .map_or(0, |d| d.as_secs())
502}
503
504/// Prepares the worktree: each root's folder installed, unless the record
505/// shows it installed for the same lockfile and still there. `None`: the
506/// project has no root that needs it (Go, Rust).
507///
508/// # Errors
509///
510/// A lockfile is missing, a folder is not the worktree's own, or an
511/// install failed: the task stops (`PREPARE-FAILED`), never a verdict.
512pub fn prepare(p: &Prepare<'_>) -> Result<Option<Prepared>, Failure> {
513    let roots = roots(p.project);
514    if let Some((what, writes)) = missing_lockfile(p.project, &roots) {
515        let commands = vec![vec![writes.split_whitespace().map(str::to_owned).collect()]];
516        return Err(Failure {
517            message: format!(
518                "{what}: the worktree cannot get the dependencies it locks (it writes the lockfile: commit it)"
519            ),
520            fix: vec![
521                speccoder_core::step::render(&commands[0]),
522                "→ or, when the checks need none of it: [worktree] prepare = \"off\"".to_owned(),
523            ],
524            commands,
525        });
526    }
527    if roots.is_empty() {
528        forget(p.worktree);
529        return Ok(None);
530    }
531    let old = load(p.worktree);
532    let mut out = Prepared::default();
533    let done = prepare_roots(p, &roots, &old, &mut out).and_then(|()| {
534        if out.record == old && record_path(p.worktree).is_file() {
535            Ok(())
536        } else {
537            store(p.worktree, &out.record)
538        }
539    });
540    if let Err(f) = done {
541        // Half prepared: the next run starts over.
542        forget(p.worktree);
543        return Err(f);
544    }
545    Ok(Some(out))
546}
547
548fn prepare_roots(
549    p: &Prepare<'_>,
550    roots: &[Root],
551    old: &Record,
552    out: &mut Prepared,
553) -> Result<(), Failure> {
554    for root in roots {
555        let folder = p.project.join(root.folder());
556        let sha = sha256_file(&p.project.join(&root.lockfile)).unwrap_or_default();
557        let prev = old.roots.iter().find(|e| {
558            e.dir == root.dir && e.toolchain == root.toolchain && e.lockfile == root.lockfile
559        });
560        if let Some(e) = prev
561            && e.lock_sha256 == sha
562            && holds(p.worktree, &folder)
563        {
564            out.record.roots.push(e.clone());
565            continue;
566        }
567        out.done.push(install_at(p, root, &folder)?);
568        out.record.roots.push(Entry {
569            dir: root.dir.clone(),
570            toolchain: root.toolchain,
571            lockfile: root.lockfile.clone(),
572            lock_sha256: sha,
573            why: None,
574            at: now(),
575        });
576    }
577    Ok(())
578}
579
580/// Whether an installed folder is still the worktree's own: a directory,
581/// not a link, reached without one.
582fn holds(worktree: &Path, folder: &Path) -> bool {
583    confined(worktree, folder).is_ok()
584        && std::fs::symlink_metadata(folder).is_ok_and(|m| m.is_dir())
585}
586
587/// `root`'s install at its `folder`, once the folder is known to be the
588/// worktree's own: never at one outside it or reached through a symlink,
589/// and a link there (an attempt can make one) goes first — itself, never
590/// what it points to.
591fn install_at(p: &Prepare<'_>, root: &Root, folder: &Path) -> Result<String, Failure> {
592    confined(p.worktree, folder).map_err(|why| Failure {
593        message: format!("the worktree's {} cannot be prepared: {why}", root.folder()),
594        fix: vec!["speccoder doctor".to_owned()],
595        commands: Vec::new(),
596    })?;
597    remove_link(folder)?;
598    install(p, root)
599}
600
601/// After an attempt, before its checks: a root whose lockfile the attempt
602/// changed is installed again for it, so the checks and the tick are about
603/// the lockfile committed. The run log's lines; none when nothing changed.
604///
605/// # Errors
606///
607/// Its install failed: the task stops (`PREPARE-FAILED`).
608pub fn recheck(p: &Prepare<'_>) -> Result<Vec<String>, Failure> {
609    let mut record = load(p.worktree);
610    let mut done = Vec::new();
611    for e in &mut record.roots {
612        let sha = sha256_file(&p.project.join(&e.lockfile)).unwrap_or_default();
613        if sha == e.lock_sha256 {
614            continue;
615        }
616        let folder = p.project.join(e.folder());
617        match install_at(p, &e.root(), &folder) {
618            Ok(ran) => {
619                done.push(ran);
620                e.lock_sha256 = sha;
621                e.why = Some("the attempt changed its lockfile".to_owned());
622                e.at = now();
623            }
624            Err(f) => {
625                forget(p.worktree);
626                return Err(f);
627            }
628        }
629    }
630    if !done.is_empty() {
631        store(p.worktree, &record)?;
632    }
633    Ok(done)
634}
635
636/// Removes `folder` when it is a link — the link itself, never what it
637/// points to.
638fn remove_link(folder: &Path) -> Result<(), Failure> {
639    if std::fs::symlink_metadata(folder).is_ok_and(|m| m.file_type().is_symlink()) {
640        std::fs::remove_file(folder).map_err(|e| Failure {
641            message: format!(
642                "the worktree's link {} could not be removed: {e}",
643                folder.display()
644            ),
645            fix: vec!["speccoder doctor".to_owned()],
646            commands: Vec::new(),
647        })?;
648    }
649    Ok(())
650}
651
652/// `p` with each `..` taken back and each `.` dropped, without asking the
653/// file system.
654fn lexical(p: &Path) -> PathBuf {
655    use std::path::Component;
656    let mut out = PathBuf::new();
657    for c in p.components() {
658        match c {
659            Component::CurDir => {}
660            Component::ParentDir => {
661                if !out.pop() {
662                    out.push("..");
663                }
664            }
665            c => out.push(c),
666        }
667    }
668    out
669}
670
671/// Where `folder` really lies, when that is inside the worktree and its
672/// parent is reached without a symlink on the way; else why not. Nothing
673/// is removed or linked at a folder that is not.
674fn confined(worktree: &Path, folder: &Path) -> Result<PathBuf, String> {
675    let root =
676        std::fs::canonicalize(worktree).map_err(|e| format!("{}: {e}", worktree.display()))?;
677    let (Some(parent), Some(name)) = (folder.parent(), folder.file_name()) else {
678        return Err("it is not a folder of the worktree".to_owned());
679    };
680    let lex = lexical(parent);
681    let rel = lex
682        .strip_prefix(lexical(worktree))
683        .or_else(|_| lex.strip_prefix(&root))
684        .map_err(|_| "it lies outside the worktree".to_owned())?
685        .to_path_buf();
686    let real = std::fs::canonicalize(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
687    if real != root.join(&rel) {
688        return Err(format!(
689            "`{}` is reached through a symlink (to {}), so it is not the worktree's own",
690            rel.display(),
691            real.display()
692        ));
693    }
694    Ok(real.join(name))
695}
696
697/// Runs `root`'s install in its directory, sandboxed: the offline form,
698/// or the online one when `[sandbox] network_allow` names it exactly;
699/// once more from `speccoder new`'s own cache when the person's lacks a
700/// package. The line for the run log.
701fn install(p: &Prepare<'_>, root: &Root) -> Result<String, Failure> {
702    let online = root.toolchain.install(false);
703    let argv = if p.runner.allows_network(&online) {
704        online.clone()
705    } else {
706        root.toolchain.install(true)
707    };
708    let cwd = p.project.join(&root.dir);
709    let mut env = scrubbed_env(std::env::vars());
710    env.push(("npm_config_update_notifier".to_owned(), "false".to_owned()));
711    let run = |runner: &Runner, env: Vec<(String, String)>| {
712        runner.run(&Invocation {
713            argv: argv.clone(),
714            cwd: cwd.clone(),
715            env,
716            timeout: INSTALL_TIMEOUT,
717            output_cap: OUTPUT_CAP,
718        })
719    };
720    let ok = |o: &Outcome| o.exit_code == Some(0) && !o.timed_out && o.spawn_error.is_none();
721    let began = std::time::Instant::now();
722    let mut out = run(p.runner, env.clone());
723    if !ok(&out)
724        && out.spawn_error.is_none()
725        && let Some((cache, vars)) = p.state.and_then(|s| {
726            crate::new_project::steps::filled_cache(s, argv.first().map_or("", String::as_str))
727        })
728    {
729        let mut env = env;
730        for (k, v) in vars {
731            env.retain(|(key, _)| *key != k);
732            env.push((k, v));
733        }
734        out = run(&p.runner.clone().with_writable(&cache), env);
735    }
736    let shown = argv.join(" ");
737    let at = if root.dir.is_empty() {
738        String::new()
739    } else {
740        format!(" in {}", root.dir)
741    };
742    if ok(&out) {
743        return Ok(format!(
744            "{shown}{at} ({:.1}s)",
745            began.elapsed().as_secs_f64()
746        ));
747    }
748    // At the project's own directory, the fill is the person's command;
749    // elsewhere it is words (it runs in that folder).
750    let fill: Vec<String> = root
751        .toolchain
752        .fill()
753        .split_whitespace()
754        .map(str::to_owned)
755        .collect();
756    let commands: Vec<speccoder_core::step::Argvs> = if root.dir.is_empty() {
757        vec![vec![fill]]
758    } else {
759        Vec::new()
760    };
761    let mut fix = match commands.first() {
762        Some(c) => vec![speccoder_core::step::render(c)],
763        None => vec![format!(
764            "→ run `{}` once in your checkout{at} to fill {}'s cache",
765            root.toolchain.fill(),
766            argv.first().map_or("the toolchain", String::as_str)
767        )],
768    };
769    if online != argv {
770        fix.push(format!(
771            "→ or let `{}` reach the network: [sandbox] network_allow = [[{}]]",
772            online.join(" "),
773            online
774                .iter()
775                .map(|a| format!("\"{a}\""))
776                .collect::<Vec<_>>()
777                .join(", ")
778        ));
779    }
780    fix.push("speccoder doctor".to_owned());
781    Err(Failure {
782        message: format!("`{shown}` failed in the worktree{at}: {}", reason(&out)),
783        fix,
784        commands,
785    })
786}
787
788/// The toolchain's own reason, in a line: its first error line (and the
789/// cause uv gives on the next), else the last line it printed.
790fn reason(out: &Outcome) -> String {
791    if let Some(e) = &out.spawn_error {
792        return e.clone();
793    }
794    if out.timed_out {
795        return format!("timed out after {}s", out.duration.as_secs());
796    }
797    let text = format!(
798        "{}\n{}",
799        String::from_utf8_lossy(&out.stderr.kept),
800        String::from_utf8_lossy(&out.stdout.kept)
801    );
802    let lines: Vec<&str> = text
803        .lines()
804        .map(str::trim)
805        .filter(|l| !l.is_empty())
806        .collect();
807    let noise = |l: &str| {
808        l.contains("complete log of this run")
809            || l.contains("Log files were not written")
810            || (l.starts_with("npm error code ") && !l["npm error code ".len()..].contains(' '))
811    };
812    let first = lines.iter().position(|l| {
813        let low = l.to_lowercase();
814        (low.starts_with("error") || low.starts_with("npm error") || low.starts_with("npm err!"))
815            && !noise(l)
816    });
817    match first {
818        Some(i) => {
819            let line = lines[i]
820                .trim_start_matches("npm error ")
821                .trim_start_matches("npm ERR! ");
822            match lines.get(i + 1).filter(|n| n.starts_with("cause:")) {
823                Some(cause) => format!("{line} — {cause}"),
824                None => line.to_owned(),
825            }
826        }
827        None => lines
828            .last()
829            .map_or_else(|| format!("exit {:?}", out.exit_code), |l| (*l).to_owned()),
830    }
831}
832
833#[cfg(test)]
834mod tests {
835    use super::*;
836
837    fn git(dir: &Path, args: &[&str]) {
838        let ok = std::process::Command::new("git")
839            .args(["-c", "user.name=t", "-c", "user.email=t@example.com"])
840            .args([
841                "-c",
842                "commit.gpgsign=false",
843                "-c",
844                "init.defaultBranch=main",
845            ])
846            .args(args)
847            .current_dir(dir)
848            .status()
849            .is_ok_and(|s| s.success());
850        assert!(ok, "git {args:?}");
851    }
852
853    fn repo(files: &[(&str, &str)]) -> tempfile::TempDir {
854        let dir = tempfile::tempdir().unwrap();
855        for (p, c) in files {
856            let f = dir.path().join(p);
857            std::fs::create_dir_all(f.parent().unwrap()).unwrap();
858            std::fs::write(f, c).unwrap();
859        }
860        git(dir.path(), &["init", "-q"]);
861        git(dir.path(), &["add", "-A"]);
862        git(dir.path(), &["commit", "-q", "-m", "init"]);
863        dir
864    }
865
866    #[test]
867    fn the_section_parses_with_its_defaults_and_refuses_patterns_outside() {
868        assert_eq!(parse("").unwrap(), Config::default());
869        let c = parse("[worktree]\nprepare = \"off\"\ninclude = [\".env\", \"config/*.local.json\", \".env\"]\n").unwrap();
870        assert!(!c.prepare);
871        assert_eq!(c.include, [".env", "config/*.local.json"]);
872        for bad in ["", "../up", "a/../b", "~/x", ".git/config"] {
873            let e = parse(&format!("[worktree]\ninclude = [\"{bad}\"]\n")).unwrap_err();
874            assert!(e.starts_with("[worktree] include: "), "{bad}: {e}");
875        }
876        assert!(parse("[worktree]\nprepare = \"always\"\n").is_err());
877        assert!(parse("[worktree]\nlink = []\n").is_err());
878    }
879
880    #[test]
881    fn the_record_is_kept_beside_the_worktree_and_round_trips() {
882        let dir = tempfile::tempdir().unwrap();
883        let wt = dir.path().join("001-x");
884        std::fs::create_dir_all(&wt).unwrap();
885        assert_eq!(record_path(&wt), dir.path().join("001-x.prepare.json"));
886        assert_eq!(load(&wt), Record::default());
887        let record = Record {
888            roots: vec![Entry {
889                dir: String::new(),
890                toolchain: Toolchain::Uv,
891                lockfile: "uv.lock".to_owned(),
892                lock_sha256: "ab".to_owned(),
893                why: None,
894                at: 1,
895            }],
896        };
897        store(&wt, &record).unwrap();
898        assert_eq!(load(&wt), record);
899        forget(&wt);
900        assert!(!record_path(&wt).exists());
901    }
902
903    #[test]
904    fn a_folder_reached_through_a_symlinked_parent_is_never_touched() {
905        let checkout = tempfile::tempdir().unwrap();
906        let api = checkout.path().join("api");
907        std::fs::create_dir_all(api.join("node_modules/left-pad")).unwrap();
908        std::fs::write(api.join("node_modules/left-pad/index.js"), "x").unwrap();
909        std::fs::write(api.join("package.json"), "{}").unwrap();
910        std::fs::write(api.join("package-lock.json"), "{}").unwrap();
911        let wt = repo(&[("api/package.json", "{}"), ("api/package-lock.json", "{}")]);
912        // The worktree's `api` replaced by a link to the checkout's.
913        std::fs::remove_dir_all(wt.path().join("api")).unwrap();
914        std::os::unix::fs::symlink(&api, wt.path().join("api")).unwrap();
915        let entry = Entry {
916            dir: "api".to_owned(),
917            toolchain: Toolchain::Npm,
918            lockfile: "api/package-lock.json".to_owned(),
919            lock_sha256: "an older lockfile".to_owned(),
920            why: None,
921            at: 0,
922        };
923        let p = Prepare {
924            worktree: wt.path(),
925            project: wt.path(),
926            config: &Config::default(),
927            runner: &Runner::Plain,
928            state: None,
929        };
930        for _ in 0..2 {
931            store(
932                wt.path(),
933                &Record {
934                    roots: vec![entry.clone()],
935                },
936            )
937            .unwrap();
938            let f = prepare(&p).unwrap_err();
939            assert!(
940                f.message.contains("reached through a symlink"),
941                "{}",
942                f.message
943            );
944            assert!(api.join("node_modules/left-pad/index.js").is_file());
945            // Half prepared: forgotten, so the next run starts over.
946            assert!(!record_path(wt.path()).exists());
947            store(
948                wt.path(),
949                &Record {
950                    roots: vec![entry.clone()],
951                },
952            )
953            .unwrap();
954            let f = recheck(&p).unwrap_err();
955            assert!(
956                f.message.contains("reached through a symlink"),
957                "{}",
958                f.message
959            );
960            assert!(api.join("node_modules/left-pad/index.js").is_file());
961        }
962        // Reused as recorded, it would not be the worktree's own either.
963        store(
964            wt.path(),
965            &Record {
966                roots: vec![Entry {
967                    lock_sha256: sha256_file(&api.join("package-lock.json")).unwrap(),
968                    ..entry
969                }],
970            },
971        )
972        .unwrap();
973        assert!(prepare(&p).is_err());
974        assert!(api.join("node_modules/left-pad/index.js").is_file());
975    }
976
977    #[test]
978    fn a_link_where_a_folder_belongs_goes_itself_and_never_what_it_points_to() {
979        let checkout = tempfile::tempdir().unwrap();
980        std::fs::create_dir_all(checkout.path().join("node_modules/a")).unwrap();
981        std::fs::write(checkout.path().join("node_modules/a/index.js"), "x").unwrap();
982        let wt = repo(&[("package.json", "{}"), ("package-lock.json", "{}")]);
983        std::os::unix::fs::symlink(
984            checkout.path().join("node_modules"),
985            wt.path().join("node_modules"),
986        )
987        .unwrap();
988        // The install itself fails here (no npm cache holds this), after the link went.
989        let _ = install_at(
990            &Prepare {
991                worktree: wt.path(),
992                project: wt.path(),
993                config: &Config::default(),
994                runner: &Runner::Plain,
995                state: None,
996            },
997            &roots(wt.path())[0],
998            &wt.path().join("node_modules"),
999        );
1000        assert!(
1001            std::fs::symlink_metadata(wt.path().join("node_modules"))
1002                .map_or(true, |m| !m.file_type().is_symlink())
1003        );
1004        assert!(checkout.path().join("node_modules/a/index.js").is_file());
1005    }
1006
1007    #[test]
1008    fn roots_are_the_committed_lockfiles_beside_their_manifests() {
1009        let berry =
1010            "# This file is generated by running \"yarn install\"\n\n__metadata:\n  version: 8\n";
1011        type Files<'a> = &'a [(&'a str, &'a str)];
1012        type Want<'a> = &'a [(&'a str, Toolchain, &'a str)];
1013        let cases: &[(Files<'_>, Want<'_>)] = &[
1014            (&[("Cargo.toml", ""), ("Cargo.lock", "")], &[]),
1015            (&[("go.mod", ""), ("go.sum", "")], &[]),
1016            (
1017                &[("package.json", "{}"), ("package-lock.json", "{}")],
1018                &[("", Toolchain::Npm, "package-lock.json")],
1019            ),
1020            (
1021                &[("package.json", "{}"), ("pnpm-lock.yaml", "")],
1022                &[("", Toolchain::Pnpm, "pnpm-lock.yaml")],
1023            ),
1024            (
1025                &[
1026                    ("package.json", "{}"),
1027                    ("yarn.lock", "# yarn lockfile v1\n"),
1028                ],
1029                &[("", Toolchain::Yarn, "yarn.lock")],
1030            ),
1031            (
1032                &[("package.json", "{}"), ("yarn.lock", berry)],
1033                &[("", Toolchain::YarnBerry, "yarn.lock")],
1034            ),
1035            (
1036                &[("package.json", "{}"), ("bun.lock", "")],
1037                &[("", Toolchain::Bun, "bun.lock")],
1038            ),
1039            (
1040                &[("package.json", "{}"), ("bun.lockb", "")],
1041                &[("", Toolchain::Bun, "bun.lockb")],
1042            ),
1043            // Two Node lockfiles: npm's is chosen.
1044            (
1045                &[
1046                    ("package.json", "{}"),
1047                    ("yarn.lock", ""),
1048                    ("package-lock.json", "{}"),
1049                ],
1050                &[("", Toolchain::Npm, "package-lock.json")],
1051            ),
1052            (
1053                &[("pyproject.toml", ""), ("uv.lock", "")],
1054                &[("", Toolchain::Uv, "uv.lock")],
1055            ),
1056            // A workspace: each lockfile's directory on its own.
1057            (
1058                &[
1059                    ("package.json", "{}"),
1060                    ("package-lock.json", "{}"),
1061                    ("api/pyproject.toml", ""),
1062                    ("api/uv.lock", ""),
1063                ],
1064                &[
1065                    ("", Toolchain::Npm, "package-lock.json"),
1066                    ("api", Toolchain::Uv, "api/uv.lock"),
1067                ],
1068            ),
1069            // A lockfile without its manifest is no root.
1070            (&[("uv.lock", "")], &[]),
1071        ];
1072        for (files, want) in cases {
1073            let r = repo(files);
1074            let got: Vec<(String, Toolchain, String)> = roots(r.path())
1075                .into_iter()
1076                .map(|x| (x.dir, x.toolchain, x.lockfile))
1077                .collect();
1078            let want: Vec<(String, Toolchain, String)> = want
1079                .iter()
1080                .map(|(d, t, l)| ((*d).to_owned(), *t, (*l).to_owned()))
1081                .collect();
1082            assert_eq!(got, want, "{files:?}");
1083        }
1084        // Uncommitted lockfiles do not count.
1085        let r = repo(&[("package.json", "{}")]);
1086        std::fs::write(r.path().join("package-lock.json"), "{}").unwrap();
1087        assert!(roots(r.path()).is_empty());
1088    }
1089
1090    #[test]
1091    fn each_toolchain_installs_locked_and_offline() {
1092        let shown = |t: Toolchain, offline| t.install(offline).join(" ");
1093        assert_eq!(shown(Toolchain::Npm, true), "npm ci --offline");
1094        assert_eq!(shown(Toolchain::Npm, false), "npm ci");
1095        assert_eq!(
1096            shown(Toolchain::Pnpm, true),
1097            "pnpm install --frozen-lockfile --offline"
1098        );
1099        assert_eq!(
1100            shown(Toolchain::Yarn, true),
1101            "yarn install --frozen-lockfile --offline"
1102        );
1103        assert_eq!(
1104            shown(Toolchain::YarnBerry, true),
1105            "yarn install --immutable"
1106        );
1107        assert_eq!(shown(Toolchain::Bun, true), "bun install --frozen-lockfile");
1108        assert_eq!(shown(Toolchain::Uv, true), "uv sync --locked --offline");
1109        assert_eq!(shown(Toolchain::Uv, false), "uv sync --locked");
1110    }
1111
1112    #[test]
1113    fn a_manifest_with_dependencies_and_no_lockfile_is_named() {
1114        let r = repo(&[(
1115            "package.json",
1116            "{\"devDependencies\": {\"vitest\": \"^3\"}}",
1117        )]);
1118        let (what, writes) = missing_lockfile(r.path(), &roots(r.path())).unwrap();
1119        assert!(what.contains("no lockfile is committed"), "{what}");
1120        assert!(writes.starts_with("npm install"), "{writes}");
1121        // No dependencies: nothing to lock.
1122        let r = repo(&[("package.json", "{\"scripts\": {\"test\": \"node t.js\"}}")]);
1123        assert_eq!(missing_lockfile(r.path(), &roots(r.path())), None);
1124        let r = repo(&[(
1125            "pyproject.toml",
1126            "[project]\nname = \"x\"\n[build-system]\nrequires = [\"uv_build\"]\n",
1127        )]);
1128        let (what, writes) = missing_lockfile(r.path(), &roots(r.path())).unwrap();
1129        assert!(what.contains("uv.lock"), "{what}");
1130        assert!(writes.starts_with("uv lock"), "{writes}");
1131        let r = repo(&[("pyproject.toml", "[project]\nname = \"x\"\n")]);
1132        assert_eq!(missing_lockfile(r.path(), &roots(r.path())), None);
1133    }
1134
1135    #[test]
1136    fn a_workspace_members_lockfile_above_it_governs_it() {
1137        let r = repo(&[
1138            ("package.json", "{\"workspaces\": [\"app\"]}"),
1139            ("package-lock.json", "{}"),
1140            (
1141                "app/package.json",
1142                "{\"devDependencies\": {\"vitest\": \"^3\"}}",
1143            ),
1144            ("app/speccoder.toml", ""),
1145        ]);
1146        let app = r.path().join("app");
1147        let got = roots(&app);
1148        assert_eq!(
1149            got,
1150            [Root {
1151                dir: "..".to_owned(),
1152                toolchain: Toolchain::Npm,
1153                lockfile: "../package-lock.json".to_owned(),
1154            }]
1155        );
1156        assert_eq!(missing_lockfile(&app, &got), None);
1157        // Its folder is the repository root's, inside the tree.
1158        let folder = app.join(got[0].folder());
1159        assert_eq!(
1160            confined(r.path(), &folder).unwrap(),
1161            std::fs::canonicalize(r.path())
1162                .unwrap()
1163                .join("node_modules")
1164        );
1165        // A lockfile nobody committed governs nothing.
1166        let r = repo(&[(
1167            "app/package.json",
1168            "{\"devDependencies\": {\"vitest\": \"^3\"}}",
1169        )]);
1170        std::fs::write(r.path().join("package.json"), "{}").unwrap();
1171        std::fs::write(r.path().join("package-lock.json"), "{}").unwrap();
1172        let app = r.path().join("app");
1173        assert!(roots(&app).is_empty());
1174        assert!(missing_lockfile(&app, &[]).is_some());
1175    }
1176
1177    #[test]
1178    fn the_reason_is_the_toolchains_own_line() {
1179        let out = |stderr: &str, stdout: &str| Outcome {
1180            exit_code: Some(1),
1181            timed_out: false,
1182            spawn_error: None,
1183            started: std::time::SystemTime::now(),
1184            duration: Duration::ZERO,
1185            stdout: crate::verify::process::Captured {
1186                kept: stdout.as_bytes().to_vec(),
1187                total: stdout.len(),
1188            },
1189            stderr: crate::verify::process::Captured {
1190                kept: stderr.as_bytes().to_vec(),
1191                total: stderr.len(),
1192            },
1193        };
1194        assert_eq!(
1195            reason(&out(
1196                "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",
1197                ""
1198            )),
1199            "request to https://registry.npmjs.org/x.tgz failed: cache mode is 'only-if-cached' but no cached response is available."
1200        );
1201        assert_eq!(
1202            reason(&out(
1203                "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",
1204                ""
1205            )),
1206            "error: Failed to download `pygments==2.21.0` — cause: Network connectivity is disabled, but the requested data wasn't found in the cache"
1207        );
1208        assert_eq!(reason(&out("", "something odd\n")), "something odd");
1209    }
1210}