1use 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
21const INSTALL_TIMEOUT: Duration = Duration::from_secs(600);
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct Config {
27 pub prepare: bool,
30 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
53pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(rename_all = "kebab-case")]
96pub enum Toolchain {
97 Npm,
99 Pnpm,
101 Yarn,
103 YarnBerry,
105 Bun,
107 Uv,
109}
110
111const 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 #[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 #[must_use]
137 pub const fn folder(self) -> &'static str {
138 match self {
139 Self::Uv => ".venv",
140 _ => "node_modules",
141 }
142 }
143
144 #[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 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
189pub struct Root {
190 pub dir: String,
192 pub toolchain: Toolchain,
194 pub lockfile: String,
196}
197
198impl Root {
199 #[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#[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 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 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
279fn 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#[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#[must_use]
336pub fn missing_lockfile(project: &Path, roots: &[Root]) -> Option<(String, String)> {
337 let at_root = |uv: bool| {
339 roots.iter().any(|r| {
340 r.dir.split('/').all(|c| c.is_empty() || c == "..")
341 && (r.toolchain == Toolchain::Uv) == uv
342 })
343 };
344 let package = std::fs::read_to_string(project.join("package.json"))
345 .ok()
346 .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok());
347 if let Some(p) = package
348 && !at_root(false)
349 && ["dependencies", "devDependencies", "optionalDependencies"]
350 .iter()
351 .any(|k| p[*k].as_object().is_some_and(|o| !o.is_empty()))
352 {
353 return Some((
354 "package.json declares dependencies, but no lockfile is committed".to_owned(),
355 "npm install (writes package-lock.json; commit it)".to_owned(),
356 ));
357 }
358 let pyproject = std::fs::read_to_string(project.join("pyproject.toml")).unwrap_or_default();
359 let uv = pyproject.contains("[tool.uv") || pyproject.contains("uv_build");
360 if uv && !at_root(true) {
361 return Some((
362 "pyproject.toml is a uv project, but no uv.lock is committed".to_owned(),
363 "uv lock (writes uv.lock; commit it)".to_owned(),
364 ));
365 }
366 None
367}
368
369#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
371pub struct Entry {
372 pub dir: String,
374 pub toolchain: Toolchain,
376 pub lockfile: String,
378 pub lock_sha256: String,
380 #[serde(default, skip_serializing_if = "Option::is_none")]
382 pub why: Option<String>,
383 pub at: u64,
385}
386
387impl Entry {
388 #[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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
405pub struct Record {
406 #[serde(default)]
408 pub roots: Vec<Entry>,
409}
410
411#[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#[must_use]
424pub fn load(worktree: &Path) -> Record {
425 std::fs::read_to_string(record_path(worktree))
426 .ok()
427 .and_then(|t| serde_json::from_str(&t).ok())
428 .unwrap_or_default()
429}
430
431fn store(worktree: &Path, record: &Record) -> Result<(), Failure> {
432 let path = record_path(worktree);
433 let tmp = path.with_extension("json.tmp");
434 serde_json::to_string_pretty(record)
435 .map_err(|e| e.to_string())
436 .and_then(|text| {
437 std::fs::write(&tmp, text)
438 .and_then(|()| std::fs::rename(&tmp, &path))
439 .map_err(|e| format!("{}: {e}", path.display()))
440 })
441 .map_err(|e| Failure {
442 message: format!("the worktree's prepare record could not be written: {e}"),
443 fix: vec!["speccoder doctor".to_owned()],
444 })
445}
446
447pub fn forget(worktree: &Path) {
450 let _ = std::fs::remove_file(record_path(worktree));
451}
452
453#[must_use]
455pub fn sha256_file(path: &Path) -> Option<String> {
456 std::fs::read(path)
457 .ok()
458 .map(|b| crate::integrity::sha256_hex(&b))
459}
460
461pub struct Prepare<'a> {
463 pub worktree: &'a Path,
465 pub project: &'a Path,
467 pub config: &'a Config,
469 pub runner: &'a Runner,
471 pub state: Option<&'a Path>,
474}
475
476#[derive(Debug, Clone, Default, PartialEq, Eq)]
478pub struct Prepared {
479 pub done: Vec<String>,
482 pub record: Record,
484}
485
486#[derive(Debug, Clone, PartialEq, Eq)]
488pub struct Failure {
489 pub message: String,
491 pub fix: Vec<String>,
493}
494
495fn now() -> u64 {
496 std::time::SystemTime::now()
497 .duration_since(std::time::UNIX_EPOCH)
498 .map_or(0, |d| d.as_secs())
499}
500
501pub fn prepare(p: &Prepare<'_>) -> Result<Option<Prepared>, Failure> {
510 let roots = roots(p.project);
511 if let Some((what, writes)) = missing_lockfile(p.project, &roots) {
512 return Err(Failure {
513 message: format!("{what}: the worktree cannot get the dependencies it locks"),
514 fix: vec![
515 format!("→ in your checkout: {writes}"),
516 "→ or, when the checks need none of it: [worktree] prepare = \"off\"".to_owned(),
517 ],
518 });
519 }
520 if roots.is_empty() {
521 forget(p.worktree);
522 return Ok(None);
523 }
524 let old = load(p.worktree);
525 let mut out = Prepared::default();
526 let done = prepare_roots(p, &roots, &old, &mut out).and_then(|()| {
527 if out.record == old && record_path(p.worktree).is_file() {
528 Ok(())
529 } else {
530 store(p.worktree, &out.record)
531 }
532 });
533 if let Err(f) = done {
534 forget(p.worktree);
536 return Err(f);
537 }
538 Ok(Some(out))
539}
540
541fn prepare_roots(
542 p: &Prepare<'_>,
543 roots: &[Root],
544 old: &Record,
545 out: &mut Prepared,
546) -> Result<(), Failure> {
547 for root in roots {
548 let folder = p.project.join(root.folder());
549 let sha = sha256_file(&p.project.join(&root.lockfile)).unwrap_or_default();
550 let prev = old.roots.iter().find(|e| {
551 e.dir == root.dir && e.toolchain == root.toolchain && e.lockfile == root.lockfile
552 });
553 if let Some(e) = prev
554 && e.lock_sha256 == sha
555 && holds(p.worktree, &folder)
556 {
557 out.record.roots.push(e.clone());
558 continue;
559 }
560 out.done.push(install_at(p, root, &folder)?);
561 out.record.roots.push(Entry {
562 dir: root.dir.clone(),
563 toolchain: root.toolchain,
564 lockfile: root.lockfile.clone(),
565 lock_sha256: sha,
566 why: None,
567 at: now(),
568 });
569 }
570 Ok(())
571}
572
573fn holds(worktree: &Path, folder: &Path) -> bool {
576 confined(worktree, folder).is_ok()
577 && std::fs::symlink_metadata(folder).is_ok_and(|m| m.is_dir())
578}
579
580fn install_at(p: &Prepare<'_>, root: &Root, folder: &Path) -> Result<String, Failure> {
585 confined(p.worktree, folder).map_err(|why| Failure {
586 message: format!("the worktree's {} cannot be prepared: {why}", root.folder()),
587 fix: vec!["speccoder doctor".to_owned()],
588 })?;
589 remove_link(folder)?;
590 install(p, root)
591}
592
593pub fn recheck(p: &Prepare<'_>) -> Result<Vec<String>, Failure> {
601 let mut record = load(p.worktree);
602 let mut done = Vec::new();
603 for e in &mut record.roots {
604 let sha = sha256_file(&p.project.join(&e.lockfile)).unwrap_or_default();
605 if sha == e.lock_sha256 {
606 continue;
607 }
608 let folder = p.project.join(e.folder());
609 match install_at(p, &e.root(), &folder) {
610 Ok(ran) => {
611 done.push(ran);
612 e.lock_sha256 = sha;
613 e.why = Some("the attempt changed its lockfile".to_owned());
614 e.at = now();
615 }
616 Err(f) => {
617 forget(p.worktree);
618 return Err(f);
619 }
620 }
621 }
622 if !done.is_empty() {
623 store(p.worktree, &record)?;
624 }
625 Ok(done)
626}
627
628fn remove_link(folder: &Path) -> Result<(), Failure> {
631 if std::fs::symlink_metadata(folder).is_ok_and(|m| m.file_type().is_symlink()) {
632 std::fs::remove_file(folder).map_err(|e| Failure {
633 message: format!(
634 "the worktree's link {} could not be removed: {e}",
635 folder.display()
636 ),
637 fix: vec!["speccoder doctor".to_owned()],
638 })?;
639 }
640 Ok(())
641}
642
643fn lexical(p: &Path) -> PathBuf {
646 use std::path::Component;
647 let mut out = PathBuf::new();
648 for c in p.components() {
649 match c {
650 Component::CurDir => {}
651 Component::ParentDir => {
652 if !out.pop() {
653 out.push("..");
654 }
655 }
656 c => out.push(c),
657 }
658 }
659 out
660}
661
662fn confined(worktree: &Path, folder: &Path) -> Result<PathBuf, String> {
666 let root =
667 std::fs::canonicalize(worktree).map_err(|e| format!("{}: {e}", worktree.display()))?;
668 let (Some(parent), Some(name)) = (folder.parent(), folder.file_name()) else {
669 return Err("it is not a folder of the worktree".to_owned());
670 };
671 let lex = lexical(parent);
672 let rel = lex
673 .strip_prefix(lexical(worktree))
674 .or_else(|_| lex.strip_prefix(&root))
675 .map_err(|_| "it lies outside the worktree".to_owned())?
676 .to_path_buf();
677 let real = std::fs::canonicalize(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
678 if real != root.join(&rel) {
679 return Err(format!(
680 "`{}` is reached through a symlink (to {}), so it is not the worktree's own",
681 rel.display(),
682 real.display()
683 ));
684 }
685 Ok(real.join(name))
686}
687
688fn install(p: &Prepare<'_>, root: &Root) -> Result<String, Failure> {
693 let online = root.toolchain.install(false);
694 let argv = if p.runner.allows_network(&online) {
695 online.clone()
696 } else {
697 root.toolchain.install(true)
698 };
699 let cwd = p.project.join(&root.dir);
700 let mut env = scrubbed_env(std::env::vars());
701 env.push(("npm_config_update_notifier".to_owned(), "false".to_owned()));
702 let run = |runner: &Runner, env: Vec<(String, String)>| {
703 runner.run(&Invocation {
704 argv: argv.clone(),
705 cwd: cwd.clone(),
706 env,
707 timeout: INSTALL_TIMEOUT,
708 output_cap: OUTPUT_CAP,
709 })
710 };
711 let ok = |o: &Outcome| o.exit_code == Some(0) && !o.timed_out && o.spawn_error.is_none();
712 let began = std::time::Instant::now();
713 let mut out = run(p.runner, env.clone());
714 if !ok(&out)
715 && out.spawn_error.is_none()
716 && let Some((cache, vars)) = p.state.and_then(|s| {
717 crate::new_project::steps::filled_cache(s, argv.first().map_or("", String::as_str))
718 })
719 {
720 let mut env = env;
721 for (k, v) in vars {
722 env.retain(|(key, _)| *key != k);
723 env.push((k, v));
724 }
725 out = run(&p.runner.clone().with_writable(&cache), env);
726 }
727 let shown = argv.join(" ");
728 let at = if root.dir.is_empty() {
729 String::new()
730 } else {
731 format!(" in {}", root.dir)
732 };
733 if ok(&out) {
734 return Ok(format!(
735 "{shown}{at} ({:.1}s)",
736 began.elapsed().as_secs_f64()
737 ));
738 }
739 let mut fix = vec![format!(
740 "→ run `{}` once in your checkout{at} to fill {}'s cache",
741 root.toolchain.fill(),
742 argv.first().map_or("the toolchain", String::as_str)
743 )];
744 if online != argv {
745 fix.push(format!(
746 "→ or let `{}` reach the network: [sandbox] network_allow = [[{}]]",
747 online.join(" "),
748 online
749 .iter()
750 .map(|a| format!("\"{a}\""))
751 .collect::<Vec<_>>()
752 .join(", ")
753 ));
754 }
755 fix.push("speccoder doctor".to_owned());
756 Err(Failure {
757 message: format!("`{shown}` failed in the worktree{at}: {}", reason(&out)),
758 fix,
759 })
760}
761
762fn reason(out: &Outcome) -> String {
765 if let Some(e) = &out.spawn_error {
766 return e.clone();
767 }
768 if out.timed_out {
769 return format!("timed out after {}s", out.duration.as_secs());
770 }
771 let text = format!(
772 "{}\n{}",
773 String::from_utf8_lossy(&out.stderr.kept),
774 String::from_utf8_lossy(&out.stdout.kept)
775 );
776 let lines: Vec<&str> = text
777 .lines()
778 .map(str::trim)
779 .filter(|l| !l.is_empty())
780 .collect();
781 let noise = |l: &str| {
782 l.contains("complete log of this run")
783 || l.contains("Log files were not written")
784 || (l.starts_with("npm error code ") && !l["npm error code ".len()..].contains(' '))
785 };
786 let first = lines.iter().position(|l| {
787 let low = l.to_lowercase();
788 (low.starts_with("error") || low.starts_with("npm error") || low.starts_with("npm err!"))
789 && !noise(l)
790 });
791 match first {
792 Some(i) => {
793 let line = lines[i]
794 .trim_start_matches("npm error ")
795 .trim_start_matches("npm ERR! ");
796 match lines.get(i + 1).filter(|n| n.starts_with("cause:")) {
797 Some(cause) => format!("{line} — {cause}"),
798 None => line.to_owned(),
799 }
800 }
801 None => lines
802 .last()
803 .map_or_else(|| format!("exit {:?}", out.exit_code), |l| (*l).to_owned()),
804 }
805}
806
807#[cfg(test)]
808mod tests {
809 use super::*;
810
811 fn git(dir: &Path, args: &[&str]) {
812 let ok = std::process::Command::new("git")
813 .args(["-c", "user.name=t", "-c", "user.email=t@example.com"])
814 .args([
815 "-c",
816 "commit.gpgsign=false",
817 "-c",
818 "init.defaultBranch=main",
819 ])
820 .args(args)
821 .current_dir(dir)
822 .status()
823 .is_ok_and(|s| s.success());
824 assert!(ok, "git {args:?}");
825 }
826
827 fn repo(files: &[(&str, &str)]) -> tempfile::TempDir {
828 let dir = tempfile::tempdir().unwrap();
829 for (p, c) in files {
830 let f = dir.path().join(p);
831 std::fs::create_dir_all(f.parent().unwrap()).unwrap();
832 std::fs::write(f, c).unwrap();
833 }
834 git(dir.path(), &["init", "-q"]);
835 git(dir.path(), &["add", "-A"]);
836 git(dir.path(), &["commit", "-q", "-m", "init"]);
837 dir
838 }
839
840 #[test]
841 fn the_section_parses_with_its_defaults_and_refuses_patterns_outside() {
842 assert_eq!(parse("").unwrap(), Config::default());
843 let c = parse("[worktree]\nprepare = \"off\"\ninclude = [\".env\", \"config/*.local.json\", \".env\"]\n").unwrap();
844 assert!(!c.prepare);
845 assert_eq!(c.include, [".env", "config/*.local.json"]);
846 for bad in ["", "../up", "a/../b", "~/x", ".git/config"] {
847 let e = parse(&format!("[worktree]\ninclude = [\"{bad}\"]\n")).unwrap_err();
848 assert!(e.starts_with("[worktree] include: "), "{bad}: {e}");
849 }
850 assert!(parse("[worktree]\nprepare = \"always\"\n").is_err());
851 assert!(parse("[worktree]\nlink = []\n").is_err());
852 }
853
854 #[test]
855 fn the_record_is_kept_beside_the_worktree_and_round_trips() {
856 let dir = tempfile::tempdir().unwrap();
857 let wt = dir.path().join("001-x");
858 std::fs::create_dir_all(&wt).unwrap();
859 assert_eq!(record_path(&wt), dir.path().join("001-x.prepare.json"));
860 assert_eq!(load(&wt), Record::default());
861 let record = Record {
862 roots: vec![Entry {
863 dir: String::new(),
864 toolchain: Toolchain::Uv,
865 lockfile: "uv.lock".to_owned(),
866 lock_sha256: "ab".to_owned(),
867 why: None,
868 at: 1,
869 }],
870 };
871 store(&wt, &record).unwrap();
872 assert_eq!(load(&wt), record);
873 forget(&wt);
874 assert!(!record_path(&wt).exists());
875 }
876
877 #[test]
878 fn a_folder_reached_through_a_symlinked_parent_is_never_touched() {
879 let checkout = tempfile::tempdir().unwrap();
880 let api = checkout.path().join("api");
881 std::fs::create_dir_all(api.join("node_modules/left-pad")).unwrap();
882 std::fs::write(api.join("node_modules/left-pad/index.js"), "x").unwrap();
883 std::fs::write(api.join("package.json"), "{}").unwrap();
884 std::fs::write(api.join("package-lock.json"), "{}").unwrap();
885 let wt = repo(&[("api/package.json", "{}"), ("api/package-lock.json", "{}")]);
886 std::fs::remove_dir_all(wt.path().join("api")).unwrap();
888 std::os::unix::fs::symlink(&api, wt.path().join("api")).unwrap();
889 let entry = Entry {
890 dir: "api".to_owned(),
891 toolchain: Toolchain::Npm,
892 lockfile: "api/package-lock.json".to_owned(),
893 lock_sha256: "an older lockfile".to_owned(),
894 why: None,
895 at: 0,
896 };
897 let p = Prepare {
898 worktree: wt.path(),
899 project: wt.path(),
900 config: &Config::default(),
901 runner: &Runner::Plain,
902 state: None,
903 };
904 for _ in 0..2 {
905 store(
906 wt.path(),
907 &Record {
908 roots: vec![entry.clone()],
909 },
910 )
911 .unwrap();
912 let f = prepare(&p).unwrap_err();
913 assert!(
914 f.message.contains("reached through a symlink"),
915 "{}",
916 f.message
917 );
918 assert!(api.join("node_modules/left-pad/index.js").is_file());
919 assert!(!record_path(wt.path()).exists());
921 store(
922 wt.path(),
923 &Record {
924 roots: vec![entry.clone()],
925 },
926 )
927 .unwrap();
928 let f = recheck(&p).unwrap_err();
929 assert!(
930 f.message.contains("reached through a symlink"),
931 "{}",
932 f.message
933 );
934 assert!(api.join("node_modules/left-pad/index.js").is_file());
935 }
936 store(
938 wt.path(),
939 &Record {
940 roots: vec![Entry {
941 lock_sha256: sha256_file(&api.join("package-lock.json")).unwrap(),
942 ..entry
943 }],
944 },
945 )
946 .unwrap();
947 assert!(prepare(&p).is_err());
948 assert!(api.join("node_modules/left-pad/index.js").is_file());
949 }
950
951 #[test]
952 fn a_link_where_a_folder_belongs_goes_itself_and_never_what_it_points_to() {
953 let checkout = tempfile::tempdir().unwrap();
954 std::fs::create_dir_all(checkout.path().join("node_modules/a")).unwrap();
955 std::fs::write(checkout.path().join("node_modules/a/index.js"), "x").unwrap();
956 let wt = repo(&[("package.json", "{}"), ("package-lock.json", "{}")]);
957 std::os::unix::fs::symlink(
958 checkout.path().join("node_modules"),
959 wt.path().join("node_modules"),
960 )
961 .unwrap();
962 let _ = install_at(
964 &Prepare {
965 worktree: wt.path(),
966 project: wt.path(),
967 config: &Config::default(),
968 runner: &Runner::Plain,
969 state: None,
970 },
971 &roots(wt.path())[0],
972 &wt.path().join("node_modules"),
973 );
974 assert!(
975 std::fs::symlink_metadata(wt.path().join("node_modules"))
976 .map_or(true, |m| !m.file_type().is_symlink())
977 );
978 assert!(checkout.path().join("node_modules/a/index.js").is_file());
979 }
980
981 #[test]
982 fn roots_are_the_committed_lockfiles_beside_their_manifests() {
983 let berry =
984 "# This file is generated by running \"yarn install\"\n\n__metadata:\n version: 8\n";
985 type Files<'a> = &'a [(&'a str, &'a str)];
986 type Want<'a> = &'a [(&'a str, Toolchain, &'a str)];
987 let cases: &[(Files<'_>, Want<'_>)] = &[
988 (&[("Cargo.toml", ""), ("Cargo.lock", "")], &[]),
989 (&[("go.mod", ""), ("go.sum", "")], &[]),
990 (
991 &[("package.json", "{}"), ("package-lock.json", "{}")],
992 &[("", Toolchain::Npm, "package-lock.json")],
993 ),
994 (
995 &[("package.json", "{}"), ("pnpm-lock.yaml", "")],
996 &[("", Toolchain::Pnpm, "pnpm-lock.yaml")],
997 ),
998 (
999 &[
1000 ("package.json", "{}"),
1001 ("yarn.lock", "# yarn lockfile v1\n"),
1002 ],
1003 &[("", Toolchain::Yarn, "yarn.lock")],
1004 ),
1005 (
1006 &[("package.json", "{}"), ("yarn.lock", berry)],
1007 &[("", Toolchain::YarnBerry, "yarn.lock")],
1008 ),
1009 (
1010 &[("package.json", "{}"), ("bun.lock", "")],
1011 &[("", Toolchain::Bun, "bun.lock")],
1012 ),
1013 (
1014 &[("package.json", "{}"), ("bun.lockb", "")],
1015 &[("", Toolchain::Bun, "bun.lockb")],
1016 ),
1017 (
1019 &[
1020 ("package.json", "{}"),
1021 ("yarn.lock", ""),
1022 ("package-lock.json", "{}"),
1023 ],
1024 &[("", Toolchain::Npm, "package-lock.json")],
1025 ),
1026 (
1027 &[("pyproject.toml", ""), ("uv.lock", "")],
1028 &[("", Toolchain::Uv, "uv.lock")],
1029 ),
1030 (
1032 &[
1033 ("package.json", "{}"),
1034 ("package-lock.json", "{}"),
1035 ("api/pyproject.toml", ""),
1036 ("api/uv.lock", ""),
1037 ],
1038 &[
1039 ("", Toolchain::Npm, "package-lock.json"),
1040 ("api", Toolchain::Uv, "api/uv.lock"),
1041 ],
1042 ),
1043 (&[("uv.lock", "")], &[]),
1045 ];
1046 for (files, want) in cases {
1047 let r = repo(files);
1048 let got: Vec<(String, Toolchain, String)> = roots(r.path())
1049 .into_iter()
1050 .map(|x| (x.dir, x.toolchain, x.lockfile))
1051 .collect();
1052 let want: Vec<(String, Toolchain, String)> = want
1053 .iter()
1054 .map(|(d, t, l)| ((*d).to_owned(), *t, (*l).to_owned()))
1055 .collect();
1056 assert_eq!(got, want, "{files:?}");
1057 }
1058 let r = repo(&[("package.json", "{}")]);
1060 std::fs::write(r.path().join("package-lock.json"), "{}").unwrap();
1061 assert!(roots(r.path()).is_empty());
1062 }
1063
1064 #[test]
1065 fn each_toolchain_installs_locked_and_offline() {
1066 let shown = |t: Toolchain, offline| t.install(offline).join(" ");
1067 assert_eq!(shown(Toolchain::Npm, true), "npm ci --offline");
1068 assert_eq!(shown(Toolchain::Npm, false), "npm ci");
1069 assert_eq!(
1070 shown(Toolchain::Pnpm, true),
1071 "pnpm install --frozen-lockfile --offline"
1072 );
1073 assert_eq!(
1074 shown(Toolchain::Yarn, true),
1075 "yarn install --frozen-lockfile --offline"
1076 );
1077 assert_eq!(
1078 shown(Toolchain::YarnBerry, true),
1079 "yarn install --immutable"
1080 );
1081 assert_eq!(shown(Toolchain::Bun, true), "bun install --frozen-lockfile");
1082 assert_eq!(shown(Toolchain::Uv, true), "uv sync --locked --offline");
1083 assert_eq!(shown(Toolchain::Uv, false), "uv sync --locked");
1084 }
1085
1086 #[test]
1087 fn a_manifest_with_dependencies_and_no_lockfile_is_named() {
1088 let r = repo(&[(
1089 "package.json",
1090 "{\"devDependencies\": {\"vitest\": \"^3\"}}",
1091 )]);
1092 let (what, writes) = missing_lockfile(r.path(), &roots(r.path())).unwrap();
1093 assert!(what.contains("no lockfile is committed"), "{what}");
1094 assert!(writes.starts_with("npm install"), "{writes}");
1095 let r = repo(&[("package.json", "{\"scripts\": {\"test\": \"node t.js\"}}")]);
1097 assert_eq!(missing_lockfile(r.path(), &roots(r.path())), None);
1098 let r = repo(&[(
1099 "pyproject.toml",
1100 "[project]\nname = \"x\"\n[build-system]\nrequires = [\"uv_build\"]\n",
1101 )]);
1102 let (what, writes) = missing_lockfile(r.path(), &roots(r.path())).unwrap();
1103 assert!(what.contains("uv.lock"), "{what}");
1104 assert!(writes.starts_with("uv lock"), "{writes}");
1105 let r = repo(&[("pyproject.toml", "[project]\nname = \"x\"\n")]);
1106 assert_eq!(missing_lockfile(r.path(), &roots(r.path())), None);
1107 }
1108
1109 #[test]
1110 fn a_workspace_members_lockfile_above_it_governs_it() {
1111 let r = repo(&[
1112 ("package.json", "{\"workspaces\": [\"app\"]}"),
1113 ("package-lock.json", "{}"),
1114 (
1115 "app/package.json",
1116 "{\"devDependencies\": {\"vitest\": \"^3\"}}",
1117 ),
1118 ("app/speccoder.toml", ""),
1119 ]);
1120 let app = r.path().join("app");
1121 let got = roots(&app);
1122 assert_eq!(
1123 got,
1124 [Root {
1125 dir: "..".to_owned(),
1126 toolchain: Toolchain::Npm,
1127 lockfile: "../package-lock.json".to_owned(),
1128 }]
1129 );
1130 assert_eq!(missing_lockfile(&app, &got), None);
1131 let folder = app.join(got[0].folder());
1133 assert_eq!(
1134 confined(r.path(), &folder).unwrap(),
1135 std::fs::canonicalize(r.path())
1136 .unwrap()
1137 .join("node_modules")
1138 );
1139 let r = repo(&[(
1141 "app/package.json",
1142 "{\"devDependencies\": {\"vitest\": \"^3\"}}",
1143 )]);
1144 std::fs::write(r.path().join("package.json"), "{}").unwrap();
1145 std::fs::write(r.path().join("package-lock.json"), "{}").unwrap();
1146 let app = r.path().join("app");
1147 assert!(roots(&app).is_empty());
1148 assert!(missing_lockfile(&app, &[]).is_some());
1149 }
1150
1151 #[test]
1152 fn the_reason_is_the_toolchains_own_line() {
1153 let out = |stderr: &str, stdout: &str| Outcome {
1154 exit_code: Some(1),
1155 timed_out: false,
1156 spawn_error: None,
1157 started: std::time::SystemTime::now(),
1158 duration: Duration::ZERO,
1159 stdout: crate::verify::process::Captured {
1160 kept: stdout.as_bytes().to_vec(),
1161 total: stdout.len(),
1162 },
1163 stderr: crate::verify::process::Captured {
1164 kept: stderr.as_bytes().to_vec(),
1165 total: stderr.len(),
1166 },
1167 };
1168 assert_eq!(
1169 reason(&out(
1170 "npm error code ENOTCACHED\nnpm error request to https://registry.npmjs.org/x.tgz failed: cache mode is 'only-if-cached' but no cached response is available.\nnpm error A complete log of this run can be found in: /h/.npm/_logs/x.log\n",
1171 ""
1172 )),
1173 "request to https://registry.npmjs.org/x.tgz failed: cache mode is 'only-if-cached' but no cached response is available."
1174 );
1175 assert_eq!(
1176 reason(&out(
1177 "Resolved 7 packages in 3ms\nerror: Failed to download `pygments==2.21.0`\n cause: Network connectivity is disabled, but the requested data wasn't found in the cache\n\nhint: `pygments` was included because …\n",
1178 ""
1179 )),
1180 "error: Failed to download `pygments==2.21.0` — cause: Network connectivity is disabled, but the requested data wasn't found in the cache"
1181 );
1182 assert_eq!(reason(&out("", "something odd\n")), "something odd");
1183 }
1184}