1use std::path::{Component, Path, PathBuf};
33use std::process::{Command, Stdio};
34
35use crate::ui::{highlight, warning_sign};
36
37const CARRY: &str = "amont.snapshotCarry";
38const DEPS: &str = "amont.snapshotDeps";
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum DepsMode {
43 Install,
45 Reuse,
47 Off,
49}
50
51pub fn deps_mode(settings: &crate::config::Settings) -> DepsMode {
52 match crate::config::enumerated_or(settings, DEPS, &["install", "reuse", "off"], "install") {
53 "reuse" => DepsMode::Reuse,
54 "off" => DepsMode::Off,
55 _ => DepsMode::Install,
56 }
57}
58
59pub fn carry_list(settings: &crate::config::Settings) -> Vec<String> {
61 crate::config::string_value(settings, CARRY)
62 .map(|v| {
63 v.split(|c: char| c.is_whitespace() || c == ',')
64 .filter(|s| !s.is_empty())
65 .map(str::to_string)
66 .collect()
67 })
68 .unwrap_or_default()
69}
70
71pub fn snapshot_files(snapshot: &Path) -> Result<Vec<String>, String> {
76 let mut cmd = Command::new("git");
77 cmd.arg("-C")
78 .arg(snapshot)
79 .args(["ls-files", "-z"])
80 .stdin(Stdio::null())
81 .stderr(Stdio::null());
82 crate::hooks::common::strip_git_env(&mut cmd);
83 let out = cmd
84 .output()
85 .map_err(|e| format!("could not list the snapshot's files: {e}"))?;
86 if !out.status.success() {
87 return Err("could not list the snapshot's files".to_string());
88 }
89 Ok(crate::git::split_nul_paths(&out.stdout))
90}
91
92struct Carried {
98 entry: String,
99 src: PathBuf,
100 dst: PathBuf,
101}
102
103fn lexical_problem(entry: &str) -> Option<&'static str> {
106 if entry.is_empty() {
107 return Some("empty");
108 }
109 let bytes = entry.as_bytes();
110 if entry.starts_with('/') || entry.starts_with('\\') || (bytes.len() >= 2 && bytes[1] == b':') {
111 return Some("not a repo-relative path");
112 }
113 for part in entry.split(['/', '\\']) {
114 if part.is_empty() {
115 return Some("has an empty path component");
116 }
117 if part == "." || part == ".." {
118 return Some("has a `.` or `..` component");
119 }
120 if part.eq_ignore_ascii_case(".git") {
121 return Some("is inside .git");
122 }
123 }
124 None
125}
126
127fn tracked_collision(entry: &str, files: &[String]) -> Option<String> {
131 let inside = format!("{entry}/");
132 files
133 .iter()
134 .find(|f| {
135 f.as_str() == entry || f.starts_with(&inside) || entry.starts_with(&format!("{f}/"))
136 })
137 .cloned()
138}
139
140fn is_symlink(p: &Path) -> bool {
141 std::fs::symlink_metadata(p)
142 .map(|m| m.file_type().is_symlink())
143 .unwrap_or(false)
144}
145
146fn symlinked_ancestor(root: &Path, rel: &Path) -> Option<PathBuf> {
149 let mut at = root.to_path_buf();
150 let parts: Vec<_> = rel.components().collect();
151 for part in parts.iter().take(parts.len().saturating_sub(1)) {
152 at.push(part.as_os_str());
153 if is_symlink(&at) {
154 return Some(at);
155 }
156 }
157 None
158}
159
160fn symlink_inside(dir: &Path) -> std::io::Result<Option<PathBuf>> {
162 for entry in std::fs::read_dir(dir)? {
163 let entry = entry?;
164 let kind = entry.file_type()?;
165 if kind.is_symlink() {
166 return Ok(Some(entry.path()));
167 }
168 if kind.is_dir() {
169 if let Some(found) = symlink_inside(&entry.path())? {
170 return Ok(Some(found));
171 }
172 }
173 }
174 Ok(None)
175}
176
177fn copy_tree(src: &Path, dst: &Path) -> std::io::Result<()> {
178 let meta = std::fs::symlink_metadata(src)?;
179 if meta.is_dir() {
180 std::fs::create_dir(dst)?;
181 for entry in std::fs::read_dir(src)? {
182 let entry = entry?;
183 copy_tree(&entry.path(), &dst.join(entry.file_name()))?;
184 }
185 Ok(())
186 } else {
187 std::fs::copy(src, dst).map(|_| ())
188 }
189}
190
191pub fn carry(
202 entries: &[String],
203 source: &Path,
204 snapshot: &Path,
205 files: &[String],
206) -> Result<(), String> {
207 let mut refused: Vec<String> = Vec::new();
208 let mut missing: Vec<&str> = Vec::new();
209 let mut plan: Vec<Carried> = Vec::new();
210 for entry in entries {
211 let norm = entry.replace('\\', "/");
212 let norm = norm.trim_end_matches('/').to_string();
213 if let Some(why) = lexical_problem(&norm) {
214 refused.push(format!("{entry}: {why}"));
215 continue;
216 }
217 if let Some(f) = tracked_collision(&norm, files) {
218 refused.push(format!(
219 "{entry}: tracked content ({f}) — only untracked files can be carried"
220 ));
221 continue;
222 }
223 let rel = PathBuf::from(&norm);
224 let src = source.join(&rel);
225 let dst = snapshot.join(&rel);
226 if let Some(link) = symlinked_ancestor(source, &rel) {
227 refused.push(format!(
228 "{entry}: {} is a symlink in the working tree",
229 link.display()
230 ));
231 continue;
232 }
233 if let Some(link) = symlinked_ancestor(snapshot, &rel) {
234 refused.push(format!(
235 "{entry}: {} is a symlink in the snapshot",
236 link.display()
237 ));
238 continue;
239 }
240 let meta = match std::fs::symlink_metadata(&src) {
241 Ok(m) => m,
242 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
243 missing.push(entry);
244 continue;
245 }
246 Err(e) => {
247 refused.push(format!("{entry}: cannot be read: {e}"));
248 continue;
249 }
250 };
251 if meta.file_type().is_symlink() {
252 refused.push(format!("{entry}: is a symlink"));
253 continue;
254 }
255 if meta.is_dir() {
256 match symlink_inside(&src) {
257 Ok(Some(link)) => {
258 refused.push(format!("{entry}: holds a symlink ({})", link.display()));
259 continue;
260 }
261 Ok(None) => {}
262 Err(e) => {
263 refused.push(format!("{entry}: cannot be read: {e}"));
264 continue;
265 }
266 }
267 }
268 if std::fs::symlink_metadata(&dst).is_ok() {
269 refused.push(format!("{entry}: already exists in the snapshot"));
270 continue;
271 }
272 plan.push(Carried {
273 entry: entry.clone(),
274 src,
275 dst,
276 });
277 }
278 if !refused.is_empty() {
279 return Err(format!(
280 "{CARRY} refused {}: {}",
281 if refused.len() == 1 {
282 "an entry".to_string()
283 } else {
284 format!("{} entries", refused.len())
285 },
286 refused.join("; ")
287 ));
288 }
289 if !missing.is_empty() {
290 println!(
291 "snapshot: not carrying {} — not in the working tree",
292 missing.join(", ")
293 );
294 }
295 for c in &plan {
296 if let Some(parent) = c.dst.parent() {
297 std::fs::create_dir_all(parent).map_err(|e| format!("{CARRY}: {}: {e}", c.entry))?;
298 }
299 copy_tree(&c.src, &c.dst)
300 .map_err(|e| format!("{CARRY}: {} could not be copied: {e}", c.entry))?;
301 }
302 if !plan.is_empty() {
303 let names: Vec<&str> = plan.iter().map(|c| c.entry.as_str()).collect();
304 println!("snapshot: carried {}", names.join(", "));
305 }
306 Ok(())
307}
308
309#[derive(Debug, Clone, Copy, PartialEq, Eq)]
314pub enum Manager {
315 Npm,
316 Pnpm,
317}
318
319impl Manager {
320 fn lockfile(self) -> &'static str {
321 match self {
322 Manager::Npm => "package-lock.json",
323 Manager::Pnpm => "pnpm-lock.yaml",
324 }
325 }
326 fn tool(self) -> &'static str {
327 match self {
328 Manager::Npm => "npm",
329 Manager::Pnpm => "pnpm",
330 }
331 }
332}
333
334#[derive(Debug, Clone, PartialEq, Eq)]
337pub struct Unit {
338 pub dir: String,
340 pub manager: Manager,
341 pub standalone: bool,
344}
345
346pub fn lockfile_dirs_in(files: &[String], name: &str) -> Vec<String> {
350 let mut dirs: Vec<String> = files
351 .iter()
352 .filter(|p| p.rsplit('/').next().unwrap_or(p) == name)
353 .map(|p| {
354 p.rsplit_once('/')
355 .map(|(d, _)| d.to_string())
356 .unwrap_or_default()
357 })
358 .collect();
359 dirs.sort();
360 dirs.dedup();
361 dirs
362}
363
364fn join_rel(dir: &str, name: &str) -> String {
365 if dir.is_empty() {
366 name.to_string()
367 } else {
368 format!("{dir}/{name}")
369 }
370}
371
372pub fn declared_manager(package_json: &str) -> Option<Manager> {
376 let key = "\"packageManager\"";
377 let mut hits = package_json.match_indices(key);
378 let (at, _) = hits.next()?;
379 if hits.next().is_some() {
380 return None;
381 }
382 let rest = package_json[at + key.len()..].trim_start();
383 let rest = rest.strip_prefix(':')?.trim_start();
384 let rest = rest.strip_prefix('"')?;
385 let value = &rest[..rest.find('"')?];
386 if value.starts_with("pnpm@") || value == "pnpm" {
387 Some(Manager::Pnpm)
388 } else if value.starts_with("npm@") || value == "npm" {
389 Some(Manager::Npm)
390 } else {
391 None
392 }
393}
394
395pub fn units(files: &[String], snapshot: &Path) -> Result<Vec<Unit>, String> {
399 let npm = lockfile_dirs_in(files, Manager::Npm.lockfile());
400 let pnpm = lockfile_dirs_in(files, Manager::Pnpm.lockfile());
401 let mut out = Vec::new();
402 let mut dirs: Vec<&String> = npm.iter().chain(pnpm.iter()).collect();
403 dirs.sort();
404 dirs.dedup();
405 for dir in dirs {
406 let manager = match (npm.contains(dir), pnpm.contains(dir)) {
407 (true, true) => {
408 let manifest = snapshot.join(join_rel(dir, "package.json"));
409 let body = std::fs::read_to_string(&manifest).unwrap_or_default();
410 declared_manager(&body).ok_or_else(|| {
411 format!(
412 "both package-lock.json and pnpm-lock.yaml in {} and no \
413 `packageManager` to choose — set amont.snapshotPrepare",
414 if dir.is_empty() { "the root" } else { dir }
415 )
416 })?
417 }
418 (true, false) => Manager::Npm,
419 _ => Manager::Pnpm,
420 };
421 let standalone =
422 manager == Manager::Pnpm && !files.contains(&join_rel(dir, "pnpm-workspace.yaml"));
423 out.push(Unit {
424 dir: dir.clone(),
425 manager,
426 standalone,
427 });
428 }
429 Ok(out)
430}
431
432fn unit_label(u: &Unit) -> String {
433 if u.dir.is_empty() {
434 "the root".to_string()
435 } else {
436 format!("{}/", u.dir)
437 }
438}
439
440fn manager_cmd(u: &Unit, at: &Path, args: &[&str]) -> Command {
441 let mut cmd = Command::new(crate::hooks::common::program(u.manager.tool()));
442 cmd.args(args).current_dir(at).stdin(Stdio::null());
443 if u.manager == Manager::Pnpm {
444 cmd.arg("--config.confirmModulesPurge=false");
446 if u.standalone {
447 cmd.arg("--ignore-workspace");
448 }
449 }
450 crate::hooks::common::strip_git_env(&mut cmd);
451 cmd.env(crate::pushed_tree::SNAPSHOT_ENV, "1");
454 cmd
455}
456
457fn quiet(settings: &crate::config::Settings, mut cmd: Command) -> Result<String, String> {
460 match crate::hooks::common::capture_within(settings, &mut cmd) {
461 Some((crate::hooks::common::Ran::Status(s), text)) if s.success() => Ok(text),
462 Some((_, text)) => Err(text),
463 None => Err("could not start".to_string()),
464 }
465}
466
467fn last_line(text: &str) -> String {
469 text.lines()
470 .map(str::trim)
471 .rfind(|l| !l.is_empty())
472 .unwrap_or("no output")
473 .chars()
474 .take(200)
475 .collect()
476}
477
478fn install(settings: &crate::config::Settings, u: &Unit, at: &Path) -> Result<(), String> {
479 let args: &[&str] = match u.manager {
480 Manager::Npm => &["ci", "--no-audit", "--no-fund", "--prefer-offline"],
481 Manager::Pnpm => &["install", "--frozen-lockfile", "--prefer-offline"],
482 };
483 let mut cmd = manager_cmd(u, at, args);
484 let what = format!("{} {}", u.manager.tool(), args[0]);
485 if crate::hooks::common::bounded_success(settings, &mut cmd, &what) {
486 Ok(())
487 } else {
488 Err(format!("{what} failed in {}", unit_label(u)))
489 }
490}
491
492fn clone_dir(src: &Path, dst: &Path) -> Result<(), String> {
497 #[cfg(target_os = "macos")]
498 let attempts: &[&[&str]] = &[&["/bin/cp", "-cR"], &["/bin/cp", "-R"]];
499 #[cfg(all(unix, not(target_os = "macos")))]
500 let attempts: &[&[&str]] = &[
501 &["/bin/cp", "-R", "--reflink=auto"],
502 &["/usr/bin/cp", "-R", "--reflink=auto"],
503 ];
504 #[cfg(not(unix))]
505 let attempts: &[&[&str]] = &[];
506 for argv in attempts {
507 if !Path::new(argv[0]).exists() {
508 continue;
509 }
510 let ok = Command::new(argv[0])
511 .args(&argv[1..])
512 .arg(src)
513 .arg(dst)
514 .stdin(Stdio::null())
515 .stdout(Stdio::null())
516 .stderr(Stdio::null())
517 .status()
518 .map(|s| s.success())
519 .unwrap_or(false);
520 if ok {
521 return Ok(());
522 }
523 let _ = std::fs::remove_dir_all(dst);
524 }
525 Err(format!("could not clone {}", src.display()))
526}
527
528fn normalize(p: &Path) -> PathBuf {
530 let mut out = PathBuf::new();
531 for c in p.components() {
532 match c {
533 Component::ParentDir => {
534 out.pop();
535 }
536 Component::CurDir => {}
537 other => out.push(other.as_os_str()),
538 }
539 }
540 out
541}
542
543fn link_into(dir: &Path, sources: &[PathBuf]) -> std::io::Result<Option<PathBuf>> {
547 let mut stack = vec![dir.to_path_buf()];
548 while let Some(at) = stack.pop() {
549 for entry in std::fs::read_dir(&at)? {
550 let entry = entry?;
551 let kind = entry.file_type()?;
552 let path = entry.path();
553 if kind.is_symlink() {
554 let target = std::fs::read_link(&path)?;
555 let resolved = if target.is_absolute() {
556 normalize(&target)
557 } else {
558 normalize(&at.join(target))
559 };
560 let real = path.canonicalize().ok();
564 if sources.iter().any(|s| {
565 resolved.starts_with(s) || real.as_ref().is_some_and(|r| r.starts_with(s))
566 }) {
567 return Ok(Some(path));
568 }
569 } else if kind.is_dir() {
570 stack.push(path);
571 }
572 }
573 }
574 Ok(None)
575}
576
577fn newer_than(nm: &Path, record: std::time::SystemTime) -> std::io::Result<Option<PathBuf>> {
586 if !nm.is_dir() {
587 return Ok(None);
588 }
589 let mut stack = vec![nm.to_path_buf()];
590 while let Some(at) = stack.pop() {
591 for entry in std::fs::read_dir(&at)? {
592 let entry = entry?;
593 let name = entry.file_name();
594 let name = name.to_string_lossy();
595 let kind = entry.file_type()?;
596 if kind.is_dir() {
597 if name == ".bin" || name == ".cache" || name.starts_with(".vite") {
598 continue;
599 }
600 stack.push(entry.path());
601 } else if kind.is_file() {
602 if name == ".modules.yaml"
603 || name.starts_with(".pnpm-workspace-state")
604 || (name == "lock.yaml" && at.file_name().is_some_and(|d| d == ".pnpm"))
605 {
606 continue;
607 }
608 if entry.metadata()?.modified()? > record {
609 return Ok(Some(entry.path()));
610 }
611 }
612 }
613 }
614 Ok(None)
615}
616
617fn members(settings: &crate::config::Settings, u: &Unit, at: &Path) -> Result<Vec<String>, String> {
620 if u.standalone {
621 return Ok(Vec::new());
622 }
623 let text = quiet(
624 settings,
625 manager_cmd(u, at, &["ls", "-r", "--depth", "-1", "--parseable"]),
626 )
627 .map_err(|t| format!("pnpm could not list the workspace: {}", last_line(&t)))?;
628 let root = normalize(at);
629 let canon = at.canonicalize().unwrap_or_else(|_| root.clone());
630 Ok(text
631 .lines()
632 .filter_map(|l| {
633 let p = PathBuf::from(l.trim());
634 let rel = p
635 .strip_prefix(&root)
636 .or_else(|_| p.strip_prefix(&canon))
637 .ok()?;
638 let rel = rel.to_string_lossy().replace('\\', "/");
639 (!rel.is_empty()).then_some(rel)
640 })
641 .collect())
642}
643
644fn pnpm_layout_problem(nm: &Path, is_root: bool) -> Option<String> {
649 if is_root {
650 let modules = std::fs::read_to_string(nm.join(".modules.yaml")).unwrap_or_default();
651 let isolated = modules
652 .lines()
653 .any(|l| l.contains("nodeLinker") && l.contains("isolated"));
654 if !isolated {
655 return Some("not installed with pnpm's isolated linker".to_string());
656 }
657 }
658 let entries = std::fs::read_dir(nm).ok()?;
659 for e in entries.flatten() {
660 let name = e.file_name().to_string_lossy().into_owned();
661 if name.starts_with('.') {
662 continue;
663 }
664 let kind = e.file_type().ok()?;
665 if kind.is_symlink() {
666 continue;
667 }
668 if name.starts_with('@') && kind.is_dir() {
669 for inner in std::fs::read_dir(e.path()).ok()?.flatten() {
670 if !inner.file_type().map(|k| k.is_symlink()).unwrap_or(false) {
671 return Some(format!(
672 "{} is not a link pnpm made",
673 inner.path().display()
674 ));
675 }
676 }
677 continue;
678 }
679 return Some(format!("{} is not a link pnpm made", e.path().display()));
680 }
681 None
682}
683
684fn reuse(
694 settings: &crate::config::Settings,
695 u: &Unit,
696 source: &Path,
697 snapshot: &Path,
698 unit_dirs: &[String],
699) -> Result<(), String> {
700 if u.manager == Manager::Npm {
701 return Err(
702 "npm cannot check an installed tree against its lockfile, so npm \
703 snapshots always install"
704 .to_string(),
705 );
706 }
707 let src_dir = source.join(&u.dir);
708 let snap_dir = snapshot.join(&u.dir);
709 let lock = u.manager.lockfile();
710 let src_lock = std::fs::read(src_dir.join(lock))
711 .map_err(|_| "no lockfile in the working tree".to_string())?;
712 let snap_lock = std::fs::read(snap_dir.join(lock)).map_err(|e| format!("{lock}: {e}"))?;
713 if src_lock != snap_lock {
714 return Err("the working tree's lockfile differs from the commit's".to_string());
715 }
716 if !src_dir.join("node_modules").is_dir() {
717 return Err("nothing installed in the working tree".to_string());
718 }
719 let mut set = vec![String::new()];
720 for m in members(settings, u, &snap_dir)? {
721 let repo_rel = join_rel(&u.dir, &m);
722 if unit_dirs.contains(&repo_rel) {
724 continue;
725 }
726 if src_dir.join(&m).join("node_modules").is_dir() {
727 set.push(m);
728 }
729 }
730 let record = std::fs::metadata(src_dir.join("node_modules/.modules.yaml"))
734 .and_then(|m| m.modified())
735 .map_err(|_| "no pnpm install record (node_modules/.modules.yaml)".to_string())?;
736 for m in &set {
737 match newer_than(&src_dir.join(m).join("node_modules"), record) {
738 Ok(None) => {}
739 Ok(Some(f)) => {
740 let shown = f.strip_prefix(source).unwrap_or(&f);
741 return Err(format!(
742 "{} changed after the install — an in-place edit pnpm does not check",
743 shown.display()
744 ));
745 }
746 Err(e) => return Err(format!("could not inspect the working tree's install: {e}")),
747 }
748 }
749 let mut cloned: Vec<PathBuf> = Vec::new();
750 let rollback = |cloned: &[PathBuf]| {
751 for d in cloned {
752 let _ = std::fs::remove_dir_all(d);
753 }
754 };
755 let sources = [
756 normalize(source),
757 source.canonicalize().unwrap_or_else(|_| normalize(source)),
758 ];
759 for m in &set {
760 let from = src_dir.join(m).join("node_modules");
761 let to = snap_dir.join(m).join("node_modules");
762 if std::fs::symlink_metadata(&to).is_ok() {
763 rollback(&cloned);
764 return Err(format!("{} already exists in the snapshot", to.display()));
765 }
766 if let Err(e) = clone_dir(&from, &to) {
767 rollback(&cloned);
768 return Err(e);
769 }
770 cloned.push(to.clone());
771 let problem = match link_into(&to, &sources) {
772 Ok(Some(link)) => Some(format!(
773 "{} links back into the working tree",
774 link.display()
775 )),
776 Ok(None) => pnpm_layout_problem(&to, m.is_empty()),
777 Err(e) => Some(format!("could not inspect the clone: {e}")),
778 };
779 if let Some(why) = problem {
780 rollback(&cloned);
781 return Err(why);
782 }
783 }
784 if let Ok(rd) = std::fs::read_dir(snap_dir.join("node_modules")) {
787 for e in rd.flatten() {
788 if e.file_name()
789 .to_string_lossy()
790 .starts_with(".pnpm-workspace-state")
791 {
792 let _ = std::fs::remove_file(e.path());
793 }
794 }
795 }
796 if let Err(text) = quiet(
800 settings,
801 manager_cmd(u, &snap_dir, &["install", "--frozen-lockfile", "--offline"]),
802 ) {
803 rollback(&cloned);
804 return Err(format!(
805 "pnpm did not accept the clone ({})",
806 last_line(&text)
807 ));
808 }
809 Ok(())
810}
811
812pub fn changed_since_upstream(source: &Path, tip: &str) -> Option<Vec<String>> {
816 let git = |args: &[&str]| -> Option<Vec<u8>> {
817 let mut cmd = Command::new("git");
818 cmd.arg("-C")
819 .arg(source)
820 .args(args)
821 .stdin(Stdio::null())
822 .stderr(Stdio::null());
823 crate::hooks::common::strip_git_env(&mut cmd);
824 let out = cmd.output().ok()?;
825 out.status.success().then_some(out.stdout)
826 };
827 let base = git(&["merge-base", tip, "@{upstream}"])?;
828 let base = String::from_utf8_lossy(&base).trim().to_string();
829 if base.is_empty() {
830 return None;
831 }
832 let raw = git(&["diff", "--name-only", "-z", &base, tip])?;
833 Some(crate::git::split_nul_paths(&raw))
834}
835
836pub fn select_units(units: Vec<Unit>, changed: Option<&[String]>) -> (Vec<Unit>, Vec<Unit>) {
845 let Some(changed) = changed else {
846 return (units, Vec::new());
847 };
848 let nearest = |f: &str| -> Option<&str> {
849 units
850 .iter()
851 .map(|u| u.dir.as_str())
852 .filter(|d| d.is_empty() || f.starts_with(&format!("{d}/")))
853 .max_by_key(|d| d.len())
854 };
855 let touched: Vec<&str> = changed.iter().filter_map(|f| nearest(f)).collect();
856 let (kept, skipped): (Vec<Unit>, Vec<Unit>) = units
857 .clone()
858 .into_iter()
859 .partition(|u| u.dir.is_empty() || touched.contains(&u.dir.as_str()));
860 (kept, skipped)
861}
862
863pub fn deps(
865 settings: &crate::config::Settings,
866 mode: DepsMode,
867 source: &Path,
868 snapshot: &Path,
869 files: &[String],
870 changed: Option<&[String]>,
871) -> Result<(), String> {
872 if mode == DepsMode::Off {
873 return Ok(());
874 }
875 let all = units(files, snapshot)?;
876 let unit_dirs: Vec<String> = all.iter().map(|u| u.dir.clone()).collect();
877 let (units, skipped) = select_units(all, changed);
878 if !skipped.is_empty() {
879 let names: Vec<String> = skipped.iter().map(unit_label).collect();
880 println!(
881 "snapshot: not preparing {} — the push changes nothing there",
882 names.join(", ")
883 );
884 }
885 for u in &units {
886 let at = snapshot.join(&u.dir);
887 let label = unit_label(u);
888 if mode == DepsMode::Reuse {
889 match reuse(settings, u, source, snapshot, &unit_dirs) {
892 Ok(()) => {
893 println!(
894 "snapshot: node_modules in {label} reused from the working tree — {} \
895 accepted it against the lockfile",
896 u.manager.tool()
897 );
898 continue;
899 }
900 Err(why) => println!(
901 "snapshot: {} in {label} — not reusing the working tree's install: {why}",
902 highlight(&format!("{} {}", u.manager.tool(), install_verb(u.manager)))
903 ),
904 }
905 } else {
906 println!(
907 "snapshot: {} in {label}",
908 highlight(&format!("{} {}", u.manager.tool(), install_verb(u.manager)))
909 );
910 }
911 install(settings, u, &at)?;
912 }
913 Ok(())
914}
915
916fn install_verb(m: Manager) -> &'static str {
917 match m {
918 Manager::Npm => "ci",
919 Manager::Pnpm => "install --frozen-lockfile",
920 }
921}
922
923pub fn run(
927 settings: &crate::config::Settings,
928 source: &Path,
929 snapshot: &Path,
930 tip: &str,
931 command_owns_deps: bool,
932) -> Result<(), String> {
933 let entries = carry_list(settings);
934 let mode = if command_owns_deps {
935 DepsMode::Off
936 } else {
937 deps_mode(settings)
938 };
939 if entries.is_empty() && mode == DepsMode::Off {
940 return Ok(());
941 }
942 let files = snapshot_files(snapshot)?;
943 if !entries.is_empty() {
944 carry(&entries, source, snapshot, &files)?;
945 }
946 let changed = if mode == DepsMode::Off {
947 None
948 } else {
949 changed_since_upstream(source, tip)
950 };
951 deps(settings, mode, source, snapshot, &files, changed.as_deref()).map_err(|e| {
952 println!("{} {e}", warning_sign());
953 e
954 })
955}
956
957#[cfg(test)]
958mod tests {
959 use super::*;
960
961 fn s(v: &[&str]) -> Vec<String> {
962 v.iter().map(|x| x.to_string()).collect()
963 }
964
965 #[test]
966 fn lexical_refusals() {
967 for bad in [
968 "",
969 "/etc/hosts",
970 "\\x",
971 "C:/x",
972 "a//b",
973 ".",
974 "..",
975 "a/../b",
976 ".git/config",
977 "x/.GIT/y",
978 ] {
979 assert!(lexical_problem(bad).is_some(), "{bad:?} must be refused");
980 }
981 for good in [".env", "config/local.json", ".npmrc", "a.b/c"] {
982 assert!(lexical_problem(good).is_none(), "{good:?} must pass");
983 }
984 }
985
986 #[test]
987 fn a_tracked_path_inside_above_or_equal_collides() {
988 let files = s(&["src/a.ts", "vendor", "README.md"]);
989 assert!(tracked_collision("src", &files).is_some());
990 assert!(tracked_collision("src/a.ts", &files).is_some());
991 assert!(tracked_collision("vendor/x", &files).is_some());
992 assert!(tracked_collision(".env", &files).is_none());
993 assert!(tracked_collision("sr", &files).is_none());
994 }
995
996 #[test]
997 fn lockfile_dirs_come_from_the_list_given() {
998 let files = s(&[
999 "package-lock.json",
1000 "web/package-lock.json",
1001 "web/x/pnpm-lock.yaml",
1002 "notpackage-lock.json",
1003 ]);
1004 assert_eq!(
1005 lockfile_dirs_in(&files, "package-lock.json"),
1006 s(&["", "web"])
1007 );
1008 assert_eq!(lockfile_dirs_in(&files, "pnpm-lock.yaml"), s(&["web/x"]));
1009 }
1010
1011 #[test]
1012 fn package_manager_field_is_read_narrowly() {
1013 assert_eq!(
1014 declared_manager(r#"{"packageManager": "pnpm@10.1.0"}"#),
1015 Some(Manager::Pnpm)
1016 );
1017 assert_eq!(
1018 declared_manager(r#"{"packageManager":"npm@11"}"#),
1019 Some(Manager::Npm)
1020 );
1021 assert_eq!(declared_manager(r#"{"packageManager":"yarn@4"}"#), None);
1022 assert_eq!(declared_manager(r#"{"name":"x"}"#), None);
1023 assert_eq!(
1024 declared_manager(r#"{"packageManager":"npm@1","x":{"packageManager":"pnpm@1"}}"#),
1025 None,
1026 "two answers is no answer"
1027 );
1028 }
1029
1030 fn unit(dir: &str) -> Unit {
1031 Unit {
1032 dir: dir.to_string(),
1033 manager: Manager::Pnpm,
1034 standalone: !dir.is_empty(),
1035 }
1036 }
1037
1038 #[test]
1039 fn only_the_root_and_the_touched_units_are_kept() {
1040 let all = vec![unit(""), unit("spikes/a"), unit("spikes/b"), unit("tools")];
1041 let changed = s(&["src/x.ts", "spikes/b/index.ts", "spikes/bb/y.ts"]);
1042 let (kept, skipped) = select_units(all.clone(), Some(&changed));
1043 let dirs = |v: &[Unit]| v.iter().map(|u| u.dir.clone()).collect::<Vec<_>>();
1044 assert_eq!(
1045 dirs(&kept),
1046 s(&["", "spikes/b"]),
1047 "spikes/bb is not spikes/b"
1048 );
1049 assert_eq!(dirs(&skipped), s(&["spikes/a", "tools"]));
1050 let (kept, skipped) = select_units(all.clone(), None);
1051 assert_eq!(kept.len(), 4, "an unknown range keeps everything");
1052 assert!(skipped.is_empty());
1053 }
1054
1055 #[test]
1056 fn a_nested_unit_claims_its_files_from_the_outer_one() {
1057 let all = vec![unit("web"), unit("web/nested")];
1058 let changed = s(&["web/nested/a.ts"]);
1059 let (kept, _) = select_units(all, Some(&changed));
1060 assert_eq!(kept.len(), 1);
1061 assert_eq!(kept[0].dir, "web/nested");
1062 }
1063
1064 #[test]
1065 fn a_file_newer_than_the_install_record_is_found() {
1066 let d = std::env::temp_dir().join(format!("newer-{}", std::process::id()));
1067 let _ = std::fs::remove_dir_all(&d);
1068 let pkg = d.join(".pnpm/a@1/node_modules/a");
1069 std::fs::create_dir_all(&pkg).unwrap();
1070 std::fs::create_dir_all(d.join(".bin")).unwrap();
1071 std::fs::create_dir_all(d.join(".cache")).unwrap();
1072 std::fs::write(pkg.join("index.js"), "old").unwrap();
1073 let record = std::time::SystemTime::now();
1074 std::thread::sleep(std::time::Duration::from_millis(20));
1075 for skipped in [".bin/tsc", ".cache/x", ".pnpm/lock.yaml", ".modules.yaml"] {
1076 std::fs::write(d.join(skipped), "new").unwrap();
1077 }
1078 assert_eq!(
1079 newer_than(&d, record).unwrap(),
1080 None,
1081 "the install's own writes"
1082 );
1083 std::fs::write(pkg.join("index.js"), "edited").unwrap();
1084 assert_eq!(newer_than(&d, record).unwrap(), Some(pkg.join("index.js")));
1085 let _ = std::fs::remove_dir_all(&d);
1086 }
1087
1088 #[test]
1089 fn normalize_folds_dots_lexically() {
1090 assert_eq!(
1091 normalize(Path::new("/a/b/../c/./d")),
1092 PathBuf::from("/a/c/d")
1093 );
1094 }
1095}