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