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 Cargo,
113}
114
115const 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 #[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 #[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 #[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 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct Root {
200 pub dir: String,
202 pub toolchain: Toolchain,
204 pub lockfile: String,
206}
207
208impl Root {
209 #[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#[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 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 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 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
303fn 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#[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#[must_use]
360pub fn missing_lockfile(project: &Path, roots: &[Root]) -> Option<(String, String)> {
361 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
396pub struct Entry {
397 pub dir: String,
399 pub toolchain: Toolchain,
401 pub lockfile: String,
403 pub lock_sha256: String,
405 #[serde(default, skip_serializing_if = "Option::is_none")]
407 pub why: Option<String>,
408 pub at: u64,
410}
411
412impl Entry {
413 #[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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
430pub struct Record {
431 #[serde(default)]
433 pub roots: Vec<Entry>,
434}
435
436#[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#[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
473pub fn forget(worktree: &Path) {
476 let _ = std::fs::remove_file(record_path(worktree));
477}
478
479#[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
487pub struct Prepare<'a> {
489 pub worktree: &'a Path,
491 pub project: &'a Path,
493 pub config: &'a Config,
495 pub runner: &'a Runner,
497 pub state: Option<&'a Path>,
500}
501
502#[derive(Debug, Clone, Default, PartialEq, Eq)]
504pub struct Prepared {
505 pub done: Vec<String>,
508 pub record: Record,
510}
511
512#[derive(Debug, Clone, PartialEq, Eq)]
514pub struct Failure {
515 pub message: String,
517 pub fix: Vec<String>,
519 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
529pub 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 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 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
606pub(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
613fn 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
627pub 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
662fn 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
678fn 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
697pub(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
723fn 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 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 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
816fn 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 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 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 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 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 (
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 (
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 (&[("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 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 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 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 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}