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