1use std::path::{Component, Path, PathBuf};
38use std::process::{Command, Stdio};
39
40use crate::ui::{highlight, warning_sign};
41
42const CARRY: &str = "amont.snapshotCarry";
43const DEPS: &str = "amont.snapshotDeps";
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum DepsMode {
48 Install,
51 Reuse,
53 Off,
55}
56
57pub fn deps_mode(settings: &crate::config::Settings) -> DepsMode {
58 match crate::config::enumerated_or(settings, DEPS, &["install", "reuse", "off"], "install") {
59 "reuse" => DepsMode::Reuse,
60 "off" => DepsMode::Off,
61 _ => DepsMode::Install,
62 }
63}
64
65pub fn carry_list(settings: &crate::config::Settings) -> Vec<String> {
67 crate::config::string_value(settings, CARRY)
68 .map(|v| {
69 v.split(|c: char| c.is_whitespace() || c == ',')
70 .filter(|s| !s.is_empty())
71 .map(str::to_string)
72 .collect()
73 })
74 .unwrap_or_default()
75}
76
77pub fn snapshot_files(snapshot: &Path) -> Result<Vec<String>, String> {
82 let mut cmd = Command::new("git");
83 cmd.arg("-C")
84 .arg(snapshot)
85 .args(["ls-files", "-z"])
86 .stdin(Stdio::null())
87 .stderr(Stdio::null());
88 crate::hooks::common::strip_git_env(&mut cmd);
89 let out = cmd
90 .output()
91 .map_err(|e| format!("could not list the snapshot's files: {e}"))?;
92 if !out.status.success() {
93 return Err("could not list the snapshot's files".to_string());
94 }
95 Ok(crate::git::split_nul_paths(&out.stdout))
96}
97
98struct Carried {
104 entry: String,
105 src: PathBuf,
106 dst: PathBuf,
107}
108
109fn lexical_problem(entry: &str) -> Option<&'static str> {
112 if entry.is_empty() {
113 return Some("empty");
114 }
115 let bytes = entry.as_bytes();
116 if entry.starts_with('/') || entry.starts_with('\\') || (bytes.len() >= 2 && bytes[1] == b':') {
117 return Some("not a repo-relative path");
118 }
119 for part in entry.split(['/', '\\']) {
120 if part.is_empty() {
121 return Some("has an empty path component");
122 }
123 if part == "." || part == ".." {
124 return Some("has a `.` or `..` component");
125 }
126 if part.eq_ignore_ascii_case(".git") {
127 return Some("is inside .git");
128 }
129 }
130 None
131}
132
133fn tracked_collision(entry: &str, files: &[String]) -> Option<String> {
137 let inside = format!("{entry}/");
138 files
139 .iter()
140 .find(|f| {
141 f.as_str() == entry || f.starts_with(&inside) || entry.starts_with(&format!("{f}/"))
142 })
143 .cloned()
144}
145
146fn is_symlink(p: &Path) -> bool {
147 std::fs::symlink_metadata(p)
148 .map(|m| m.file_type().is_symlink())
149 .unwrap_or(false)
150}
151
152fn symlinked_ancestor(root: &Path, rel: &Path) -> Option<PathBuf> {
155 let mut at = root.to_path_buf();
156 let parts: Vec<_> = rel.components().collect();
157 for part in parts.iter().take(parts.len().saturating_sub(1)) {
158 at.push(part.as_os_str());
159 if is_symlink(&at) {
160 return Some(at);
161 }
162 }
163 None
164}
165
166fn symlink_inside(dir: &Path) -> std::io::Result<Option<PathBuf>> {
168 for entry in std::fs::read_dir(dir)? {
169 let entry = entry?;
170 let kind = entry.file_type()?;
171 if kind.is_symlink() {
172 return Ok(Some(entry.path()));
173 }
174 if kind.is_dir() {
175 if let Some(found) = symlink_inside(&entry.path())? {
176 return Ok(Some(found));
177 }
178 }
179 }
180 Ok(None)
181}
182
183fn copy_tree(src: &Path, dst: &Path) -> std::io::Result<()> {
184 let meta = std::fs::symlink_metadata(src)?;
185 if meta.is_dir() {
186 std::fs::create_dir(dst)?;
187 for entry in std::fs::read_dir(src)? {
188 let entry = entry?;
189 copy_tree(&entry.path(), &dst.join(entry.file_name()))?;
190 }
191 Ok(())
192 } else {
193 std::fs::copy(src, dst).map(|_| ())
194 }
195}
196
197pub fn carry(
208 entries: &[String],
209 source: &Path,
210 snapshot: &Path,
211 files: &[String],
212) -> Result<(), String> {
213 let mut refused: Vec<String> = Vec::new();
214 let mut missing: Vec<&str> = Vec::new();
215 let mut plan: Vec<Carried> = Vec::new();
216 for entry in entries {
217 let norm = entry.replace('\\', "/");
218 let norm = norm.trim_end_matches('/').to_string();
219 if let Some(why) = lexical_problem(&norm) {
220 refused.push(format!("{entry}: {why}"));
221 continue;
222 }
223 if let Some(f) = tracked_collision(&norm, files) {
224 refused.push(format!(
225 "{entry}: tracked content ({f}) — only untracked files can be carried"
226 ));
227 continue;
228 }
229 let rel = PathBuf::from(&norm);
230 let src = source.join(&rel);
231 let dst = snapshot.join(&rel);
232 if let Some(link) = symlinked_ancestor(source, &rel) {
233 refused.push(format!(
234 "{entry}: {} is a symlink in the working tree",
235 link.display()
236 ));
237 continue;
238 }
239 if let Some(link) = symlinked_ancestor(snapshot, &rel) {
240 refused.push(format!(
241 "{entry}: {} is a symlink in the snapshot",
242 link.display()
243 ));
244 continue;
245 }
246 let meta = match std::fs::symlink_metadata(&src) {
247 Ok(m) => m,
248 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
249 missing.push(entry);
250 continue;
251 }
252 Err(e) => {
253 refused.push(format!("{entry}: cannot be read: {e}"));
254 continue;
255 }
256 };
257 if meta.file_type().is_symlink() {
258 refused.push(format!("{entry}: is a symlink"));
259 continue;
260 }
261 if meta.is_dir() {
262 match symlink_inside(&src) {
263 Ok(Some(link)) => {
264 refused.push(format!("{entry}: holds a symlink ({})", link.display()));
265 continue;
266 }
267 Ok(None) => {}
268 Err(e) => {
269 refused.push(format!("{entry}: cannot be read: {e}"));
270 continue;
271 }
272 }
273 }
274 if std::fs::symlink_metadata(&dst).is_ok() {
275 refused.push(format!("{entry}: already exists in the snapshot"));
276 continue;
277 }
278 plan.push(Carried {
279 entry: entry.clone(),
280 src,
281 dst,
282 });
283 }
284 if !refused.is_empty() {
285 return Err(format!(
286 "{CARRY} refused {}: {}",
287 if refused.len() == 1 {
288 "an entry".to_string()
289 } else {
290 format!("{} entries", refused.len())
291 },
292 refused.join("; ")
293 ));
294 }
295 if !missing.is_empty() {
296 println!(
297 "snapshot: not carrying {} — not in the working tree",
298 missing.join(", ")
299 );
300 }
301 for c in &plan {
302 if let Some(parent) = c.dst.parent() {
303 std::fs::create_dir_all(parent).map_err(|e| format!("{CARRY}: {}: {e}", c.entry))?;
304 }
305 copy_tree(&c.src, &c.dst)
306 .map_err(|e| format!("{CARRY}: {} could not be copied: {e}", c.entry))?;
307 }
308 if !plan.is_empty() {
309 let names: Vec<&str> = plan.iter().map(|c| c.entry.as_str()).collect();
310 println!("snapshot: carried {}", names.join(", "));
311 }
312 Ok(())
313}
314
315#[derive(Debug, Clone, Copy, PartialEq, Eq)]
320pub enum Manager {
321 Npm,
322 Pnpm,
323 Yarn,
324 Bun,
328}
329
330impl Manager {
331 const ALL: [Manager; 4] = [Manager::Npm, Manager::Pnpm, Manager::Yarn, Manager::Bun];
332
333 fn lockfiles(self) -> &'static [&'static str] {
336 match self {
337 Manager::Npm => &["package-lock.json"],
338 Manager::Pnpm => &["pnpm-lock.yaml"],
339 Manager::Yarn => &["yarn.lock"],
340 Manager::Bun => &["bun.lock", "bun.lockb"],
341 }
342 }
343 fn tool(self) -> &'static str {
344 match self {
345 Manager::Npm => "npm",
346 Manager::Pnpm => "pnpm",
347 Manager::Yarn => "yarn",
348 Manager::Bun => "bun",
349 }
350 }
351}
352
353#[derive(Debug, Clone, PartialEq, Eq)]
356pub struct Unit {
357 pub dir: String,
359 pub manager: Manager,
360 pub lockfile: &'static str,
362 pub standalone: bool,
365 pub berry: bool,
369}
370
371pub fn is_berry_lock(text: &str) -> bool {
375 text.lines().any(|l| l.starts_with("__metadata:"))
376}
377
378pub fn lockfile_dirs_in(files: &[String], name: &str) -> Vec<String> {
382 let mut dirs: Vec<String> = files
383 .iter()
384 .filter(|p| p.rsplit('/').next().unwrap_or(p) == name)
385 .map(|p| {
386 p.rsplit_once('/')
387 .map(|(d, _)| d.to_string())
388 .unwrap_or_default()
389 })
390 .collect();
391 dirs.sort();
392 dirs.dedup();
393 dirs
394}
395
396fn join_rel(dir: &str, name: &str) -> String {
397 if dir.is_empty() {
398 name.to_string()
399 } else {
400 format!("{dir}/{name}")
401 }
402}
403
404pub fn declared_manager(package_json: &str) -> Option<Manager> {
408 let key = "\"packageManager\"";
409 let mut hits = package_json.match_indices(key);
410 let (at, _) = hits.next()?;
411 if hits.next().is_some() {
412 return None;
413 }
414 let rest = package_json[at + key.len()..].trim_start();
415 let rest = rest.strip_prefix(':')?.trim_start();
416 let rest = rest.strip_prefix('"')?;
417 let value = &rest[..rest.find('"')?];
418 let name = value.split('@').next().unwrap_or(value);
419 Manager::ALL.into_iter().find(|m| m.tool() == name)
420}
421
422pub fn units(files: &[String], snapshot: &Path) -> Result<Vec<Unit>, String> {
426 let mut found: Vec<(String, Manager, &'static str)> = Vec::new();
428 for m in Manager::ALL {
429 for lock in m.lockfiles() {
430 found.extend(
431 lockfile_dirs_in(files, lock)
432 .into_iter()
433 .map(|dir| (dir, m, *lock)),
434 );
435 }
436 }
437 let mut dirs: Vec<String> = found.iter().map(|f| f.0.clone()).collect();
438 dirs.sort();
439 dirs.dedup();
440 let mut out = Vec::new();
441 for dir in dirs {
442 let here: Vec<&(String, Manager, &'static str)> =
443 found.iter().filter(|f| f.0 == dir).collect();
444 let (manager, lockfile) = if here.iter().all(|f| f.1 == here[0].1) {
445 (here[0].1, here[0].2)
446 } else {
447 let manifest = snapshot.join(join_rel(&dir, "package.json"));
448 let body = std::fs::read_to_string(&manifest).unwrap_or_default();
449 declared_manager(&body)
450 .and_then(|m| here.iter().find(|f| f.1 == m))
451 .map(|f| (f.1, f.2))
452 .ok_or_else(|| {
453 let names: Vec<&str> = here.iter().map(|f| f.2).collect();
454 format!(
455 "both {} in {} and no `packageManager` to choose — set \
456 amont.snapshotPrepare",
457 names.join(" and "),
458 if dir.is_empty() { "the root" } else { &dir }
459 )
460 })?
461 };
462 let standalone =
463 manager == Manager::Pnpm && !files.contains(&join_rel(&dir, "pnpm-workspace.yaml"));
464 let berry = manager == Manager::Yarn
465 && std::fs::read_to_string(snapshot.join(join_rel(&dir, lockfile)))
466 .map(|t| is_berry_lock(&t))
467 .unwrap_or(false);
468 out.push(Unit {
469 dir,
470 manager,
471 lockfile,
472 standalone,
473 berry,
474 });
475 }
476 Ok(out)
477}
478
479fn bun_refusal(u: &Unit) -> String {
485 format!(
486 "{} in {}: amont cannot install bun dependencies — set amont.snapshotPrepare \
487 to the install command, or amont.snapshotDeps off",
488 u.lockfile,
489 unit_label(u)
490 )
491}
492
493fn unit_label(u: &Unit) -> String {
494 if u.dir.is_empty() {
495 "the root".to_string()
496 } else {
497 format!("{}/", u.dir)
498 }
499}
500
501fn manager_cmd(u: &Unit, at: &Path, args: &[&str]) -> Command {
502 let mut cmd = Command::new(crate::hooks::common::program(u.manager.tool()));
503 cmd.args(args).current_dir(at).stdin(Stdio::null());
504 if u.manager == Manager::Pnpm {
505 cmd.arg("--config.confirmModulesPurge=false");
507 if u.standalone {
508 cmd.arg("--ignore-workspace");
509 }
510 }
511 crate::hooks::common::strip_git_env(&mut cmd);
512 cmd.env(crate::pushed_tree::SNAPSHOT_ENV, "1");
515 cmd
516}
517
518fn quiet(settings: &crate::config::Settings, mut cmd: Command) -> Result<String, String> {
521 match crate::hooks::common::capture_within(settings, &mut cmd) {
522 Some((crate::hooks::common::Ran::Status(s), text)) if s.success() => Ok(text),
523 Some((_, text)) => Err(text),
524 None => Err("could not start".to_string()),
525 }
526}
527
528fn last_line(text: &str) -> String {
530 text.lines()
531 .map(str::trim)
532 .rfind(|l| !l.is_empty())
533 .unwrap_or("no output")
534 .chars()
535 .take(200)
536 .collect()
537}
538
539fn install(settings: &crate::config::Settings, u: &Unit, at: &Path) -> Result<(), String> {
540 let args: &[&str] = match u.manager {
541 Manager::Npm => &["ci", "--no-audit", "--no-fund", "--prefer-offline"],
542 Manager::Pnpm => &["install", "--frozen-lockfile", "--prefer-offline"],
543 Manager::Yarn if u.berry => &["install", "--immutable"],
547 Manager::Yarn => &[
548 "install",
549 "--frozen-lockfile",
550 "--non-interactive",
551 "--prefer-offline",
552 ],
553 Manager::Bun => return Err(bun_refusal(u)),
554 };
555 let mut cmd = manager_cmd(u, at, args);
556 let what = format!("{} {}", u.manager.tool(), args[0]);
557 if crate::hooks::common::bounded_success(settings, &mut cmd, &what) {
558 Ok(())
559 } else {
560 Err(format!("{what} failed in {}", unit_label(u)))
561 }
562}
563
564fn clone_dir(src: &Path, dst: &Path) -> Result<(), String> {
569 #[cfg(target_os = "macos")]
570 let attempts: &[&[&str]] = &[&["/bin/cp", "-cR"], &["/bin/cp", "-R"]];
571 #[cfg(all(unix, not(target_os = "macos")))]
572 let attempts: &[&[&str]] = &[
573 &["/bin/cp", "-R", "--reflink=auto"],
574 &["/usr/bin/cp", "-R", "--reflink=auto"],
575 ];
576 #[cfg(not(unix))]
577 let attempts: &[&[&str]] = &[];
578 for argv in attempts {
579 if !Path::new(argv[0]).exists() {
580 continue;
581 }
582 let ok = Command::new(argv[0])
583 .args(&argv[1..])
584 .arg(src)
585 .arg(dst)
586 .stdin(Stdio::null())
587 .stdout(Stdio::null())
588 .stderr(Stdio::null())
589 .status()
590 .map(|s| s.success())
591 .unwrap_or(false);
592 if ok {
593 return Ok(());
594 }
595 let _ = std::fs::remove_dir_all(dst);
596 }
597 Err(format!("could not clone {}", src.display()))
598}
599
600fn normalize(p: &Path) -> PathBuf {
602 let mut out = PathBuf::new();
603 for c in p.components() {
604 match c {
605 Component::ParentDir => {
606 out.pop();
607 }
608 Component::CurDir => {}
609 other => out.push(other.as_os_str()),
610 }
611 }
612 out
613}
614
615fn link_into(dir: &Path, sources: &[PathBuf]) -> std::io::Result<Option<PathBuf>> {
619 let mut stack = vec![dir.to_path_buf()];
620 while let Some(at) = stack.pop() {
621 for entry in std::fs::read_dir(&at)? {
622 let entry = entry?;
623 let kind = entry.file_type()?;
624 let path = entry.path();
625 if kind.is_symlink() {
626 let target = std::fs::read_link(&path)?;
627 let resolved = if target.is_absolute() {
628 normalize(&target)
629 } else {
630 normalize(&at.join(target))
631 };
632 let real = path.canonicalize().ok();
636 if sources.iter().any(|s| {
637 resolved.starts_with(s) || real.as_ref().is_some_and(|r| r.starts_with(s))
638 }) {
639 return Ok(Some(path));
640 }
641 } else if kind.is_dir() {
642 stack.push(path);
643 }
644 }
645 }
646 Ok(None)
647}
648
649fn newer_than(nm: &Path, record: std::time::SystemTime) -> std::io::Result<Option<PathBuf>> {
658 if !nm.is_dir() {
659 return Ok(None);
660 }
661 let mut stack = vec![nm.to_path_buf()];
662 while let Some(at) = stack.pop() {
663 for entry in std::fs::read_dir(&at)? {
664 let entry = entry?;
665 let name = entry.file_name();
666 let name = name.to_string_lossy();
667 let kind = entry.file_type()?;
668 if kind.is_dir() {
669 if name == ".bin" || name == ".cache" || name.starts_with(".vite") {
670 continue;
671 }
672 stack.push(entry.path());
673 } else if kind.is_file() {
674 if name == ".modules.yaml"
675 || name.starts_with(".pnpm-workspace-state")
676 || (name == "lock.yaml" && at.file_name().is_some_and(|d| d == ".pnpm"))
677 {
678 continue;
679 }
680 if entry.metadata()?.modified()? > record {
681 return Ok(Some(entry.path()));
682 }
683 }
684 }
685 }
686 Ok(None)
687}
688
689fn members(settings: &crate::config::Settings, u: &Unit, at: &Path) -> Result<Vec<String>, String> {
692 if u.standalone {
693 return Ok(Vec::new());
694 }
695 let text = quiet(
696 settings,
697 manager_cmd(u, at, &["ls", "-r", "--depth", "-1", "--parseable"]),
698 )
699 .map_err(|t| format!("pnpm could not list the workspace: {}", last_line(&t)))?;
700 let root = normalize(at);
701 let canon = at.canonicalize().unwrap_or_else(|_| root.clone());
702 Ok(text
703 .lines()
704 .filter_map(|l| {
705 let p = PathBuf::from(l.trim());
706 let rel = p
707 .strip_prefix(&root)
708 .or_else(|_| p.strip_prefix(&canon))
709 .ok()?;
710 let rel = rel.to_string_lossy().replace('\\', "/");
711 (!rel.is_empty()).then_some(rel)
712 })
713 .collect())
714}
715
716fn pnpm_layout_problem(nm: &Path, is_root: bool) -> Option<String> {
721 if is_root {
722 let modules = std::fs::read_to_string(nm.join(".modules.yaml")).unwrap_or_default();
723 let isolated = modules
724 .lines()
725 .any(|l| l.contains("nodeLinker") && l.contains("isolated"));
726 if !isolated {
727 return Some("not installed with pnpm's isolated linker".to_string());
728 }
729 }
730 let entries = std::fs::read_dir(nm).ok()?;
731 for e in entries.flatten() {
732 let name = e.file_name().to_string_lossy().into_owned();
733 if name.starts_with('.') {
734 continue;
735 }
736 let kind = e.file_type().ok()?;
737 if kind.is_symlink() {
738 continue;
739 }
740 if name.starts_with('@') && kind.is_dir() {
741 for inner in std::fs::read_dir(e.path()).ok()?.flatten() {
742 if !inner.file_type().map(|k| k.is_symlink()).unwrap_or(false) {
743 return Some(format!(
744 "{} is not a link pnpm made",
745 inner.path().display()
746 ));
747 }
748 }
749 continue;
750 }
751 return Some(format!("{} is not a link pnpm made", e.path().display()));
752 }
753 None
754}
755
756fn reuse(
772 settings: &crate::config::Settings,
773 u: &Unit,
774 source: &Path,
775 snapshot: &Path,
776 unit_dirs: &[String],
777) -> Result<(), String> {
778 match u.manager {
779 Manager::Npm => {
780 return Err(
781 "npm cannot check an installed tree against its lockfile, so npm \
782 snapshots always install"
783 .to_string(),
784 )
785 }
786 Manager::Yarn => {
787 return Err(
788 "yarn cannot check an installed tree against its lockfile, so yarn \
789 snapshots always install"
790 .to_string(),
791 )
792 }
793 Manager::Bun => return Err(bun_refusal(u)),
794 Manager::Pnpm => {}
795 }
796 let src_dir = source.join(&u.dir);
797 let snap_dir = snapshot.join(&u.dir);
798 let lock = u.lockfile;
799 let src_lock = std::fs::read(src_dir.join(lock))
800 .map_err(|_| "no lockfile in the working tree".to_string())?;
801 let snap_lock = std::fs::read(snap_dir.join(lock)).map_err(|e| format!("{lock}: {e}"))?;
802 if src_lock != snap_lock {
803 return Err("the working tree's lockfile differs from the commit's".to_string());
804 }
805 if !src_dir.join("node_modules").is_dir() {
806 return Err("nothing installed in the working tree".to_string());
807 }
808 let mut set = vec![String::new()];
809 for m in members(settings, u, &snap_dir)? {
810 let repo_rel = join_rel(&u.dir, &m);
811 if unit_dirs.contains(&repo_rel) {
813 continue;
814 }
815 if src_dir.join(&m).join("node_modules").is_dir() {
816 set.push(m);
817 }
818 }
819 let record = std::fs::metadata(src_dir.join("node_modules/.modules.yaml"))
823 .and_then(|m| m.modified())
824 .map_err(|_| "no pnpm install record (node_modules/.modules.yaml)".to_string())?;
825 for m in &set {
826 match newer_than(&src_dir.join(m).join("node_modules"), record) {
827 Ok(None) => {}
828 Ok(Some(f)) => {
829 let shown = f.strip_prefix(source).unwrap_or(&f);
830 return Err(format!(
831 "{} changed after the install — an in-place edit pnpm does not check",
832 shown.display()
833 ));
834 }
835 Err(e) => return Err(format!("could not inspect the working tree's install: {e}")),
836 }
837 }
838 let mut cloned: Vec<PathBuf> = Vec::new();
839 let rollback = |cloned: &[PathBuf]| {
840 for d in cloned {
841 let _ = std::fs::remove_dir_all(d);
842 }
843 };
844 let sources = [
845 normalize(source),
846 source.canonicalize().unwrap_or_else(|_| normalize(source)),
847 ];
848 for m in &set {
849 let from = src_dir.join(m).join("node_modules");
850 let to = snap_dir.join(m).join("node_modules");
851 if std::fs::symlink_metadata(&to).is_ok() {
852 rollback(&cloned);
853 return Err(format!("{} already exists in the snapshot", to.display()));
854 }
855 if let Err(e) = clone_dir(&from, &to) {
856 rollback(&cloned);
857 return Err(e);
858 }
859 cloned.push(to.clone());
860 let problem = match link_into(&to, &sources) {
861 Ok(Some(link)) => Some(format!(
862 "{} links back into the working tree",
863 link.display()
864 )),
865 Ok(None) => pnpm_layout_problem(&to, m.is_empty()),
866 Err(e) => Some(format!("could not inspect the clone: {e}")),
867 };
868 if let Some(why) = problem {
869 rollback(&cloned);
870 return Err(why);
871 }
872 }
873 if let Ok(rd) = std::fs::read_dir(snap_dir.join("node_modules")) {
876 for e in rd.flatten() {
877 if e.file_name()
878 .to_string_lossy()
879 .starts_with(".pnpm-workspace-state")
880 {
881 let _ = std::fs::remove_file(e.path());
882 }
883 }
884 }
885 if let Err(text) = quiet(
889 settings,
890 manager_cmd(u, &snap_dir, &["install", "--frozen-lockfile", "--offline"]),
891 ) {
892 rollback(&cloned);
893 return Err(format!(
894 "pnpm did not accept the clone ({})",
895 last_line(&text)
896 ));
897 }
898 Ok(())
899}
900
901pub fn changed_since_upstream(source: &Path, tip: &str) -> Option<Vec<String>> {
905 let git = |args: &[&str]| -> Option<Vec<u8>> {
906 let mut cmd = Command::new("git");
907 cmd.arg("-C")
908 .arg(source)
909 .args(args)
910 .stdin(Stdio::null())
911 .stderr(Stdio::null());
912 crate::hooks::common::strip_git_env(&mut cmd);
913 let out = cmd.output().ok()?;
914 out.status.success().then_some(out.stdout)
915 };
916 let base = git(&["merge-base", tip, "@{upstream}"])?;
917 let base = String::from_utf8_lossy(&base).trim().to_string();
918 if base.is_empty() {
919 return None;
920 }
921 let raw = git(&["diff", "--name-only", "-z", &base, tip])?;
922 Some(crate::git::split_nul_paths(&raw))
923}
924
925pub fn select_units(units: Vec<Unit>, changed: Option<&[String]>) -> (Vec<Unit>, Vec<Unit>) {
934 let Some(changed) = changed else {
935 return (units, Vec::new());
936 };
937 let nearest = |f: &str| -> Option<&str> {
938 units
939 .iter()
940 .map(|u| u.dir.as_str())
941 .filter(|d| d.is_empty() || f.starts_with(&format!("{d}/")))
942 .max_by_key(|d| d.len())
943 };
944 let touched: Vec<&str> = changed.iter().filter_map(|f| nearest(f)).collect();
945 let (kept, skipped): (Vec<Unit>, Vec<Unit>) = units
946 .clone()
947 .into_iter()
948 .partition(|u| u.dir.is_empty() || touched.contains(&u.dir.as_str()));
949 (kept, skipped)
950}
951
952pub fn deps(
954 settings: &crate::config::Settings,
955 mode: DepsMode,
956 source: &Path,
957 snapshot: &Path,
958 files: &[String],
959 changed: Option<&[String]>,
960) -> Result<(), String> {
961 if mode == DepsMode::Off {
962 return Ok(());
963 }
964 let all = units(files, snapshot)?;
965 let unit_dirs: Vec<String> = all.iter().map(|u| u.dir.clone()).collect();
966 let (units, skipped) = select_units(all, changed);
967 if !skipped.is_empty() {
968 let names: Vec<String> = skipped.iter().map(unit_label).collect();
969 println!(
970 "snapshot: not preparing {} — the push changes nothing there",
971 names.join(", ")
972 );
973 }
974 for u in &units {
975 let at = snapshot.join(&u.dir);
976 let label = unit_label(u);
977 if u.manager == Manager::Bun {
978 return Err(bun_refusal(u));
981 }
982 if mode == DepsMode::Reuse {
983 match reuse(settings, u, source, snapshot, &unit_dirs) {
986 Ok(()) => {
987 println!(
988 "snapshot: node_modules in {label} reused from the working tree — {} \
989 accepted it against the lockfile",
990 u.manager.tool()
991 );
992 continue;
993 }
994 Err(why) => println!(
995 "snapshot: {} in {label} — not reusing the working tree's install: {why}",
996 highlight(&format!("{} {}", u.manager.tool(), install_verb(u)))
997 ),
998 }
999 } else {
1000 println!(
1001 "snapshot: {} in {label}",
1002 highlight(&format!("{} {}", u.manager.tool(), install_verb(u)))
1003 );
1004 }
1005 install(settings, u, &at)?;
1006 }
1007 Ok(())
1008}
1009
1010fn install_verb(u: &Unit) -> &'static str {
1011 match u.manager {
1012 Manager::Npm => "ci",
1013 Manager::Pnpm => "install --frozen-lockfile",
1014 Manager::Yarn if u.berry => "install --immutable",
1015 Manager::Yarn => "install --frozen-lockfile",
1016 Manager::Bun => "install",
1017 }
1018}
1019
1020pub fn run(
1024 settings: &crate::config::Settings,
1025 source: &Path,
1026 snapshot: &Path,
1027 tip: &str,
1028 command_owns_deps: bool,
1029) -> Result<(), String> {
1030 let entries = carry_list(settings);
1031 let mode = if command_owns_deps {
1032 DepsMode::Off
1033 } else {
1034 deps_mode(settings)
1035 };
1036 if entries.is_empty() && mode == DepsMode::Off {
1037 return Ok(());
1038 }
1039 let files = snapshot_files(snapshot)?;
1040 if !entries.is_empty() {
1041 carry(&entries, source, snapshot, &files)?;
1042 }
1043 let changed = if mode == DepsMode::Off {
1044 None
1045 } else {
1046 changed_since_upstream(source, tip)
1047 };
1048 deps(settings, mode, source, snapshot, &files, changed.as_deref()).map_err(|e| {
1049 println!("{} {e}", warning_sign());
1050 e
1051 })
1052}
1053
1054#[cfg(test)]
1055mod tests {
1056 use super::*;
1057
1058 fn s(v: &[&str]) -> Vec<String> {
1059 v.iter().map(|x| x.to_string()).collect()
1060 }
1061
1062 #[test]
1063 fn lexical_refusals() {
1064 for bad in [
1065 "",
1066 "/etc/hosts",
1067 "\\x",
1068 "C:/x",
1069 "a//b",
1070 ".",
1071 "..",
1072 "a/../b",
1073 ".git/config",
1074 "x/.GIT/y",
1075 ] {
1076 assert!(lexical_problem(bad).is_some(), "{bad:?} must be refused");
1077 }
1078 for good in [".env", "config/local.json", ".npmrc", "a.b/c"] {
1079 assert!(lexical_problem(good).is_none(), "{good:?} must pass");
1080 }
1081 }
1082
1083 #[test]
1084 fn a_tracked_path_inside_above_or_equal_collides() {
1085 let files = s(&["src/a.ts", "vendor", "README.md"]);
1086 assert!(tracked_collision("src", &files).is_some());
1087 assert!(tracked_collision("src/a.ts", &files).is_some());
1088 assert!(tracked_collision("vendor/x", &files).is_some());
1089 assert!(tracked_collision(".env", &files).is_none());
1090 assert!(tracked_collision("sr", &files).is_none());
1091 }
1092
1093 #[test]
1094 fn lockfile_dirs_come_from_the_list_given() {
1095 let files = s(&[
1096 "package-lock.json",
1097 "web/package-lock.json",
1098 "web/x/pnpm-lock.yaml",
1099 "notpackage-lock.json",
1100 ]);
1101 assert_eq!(
1102 lockfile_dirs_in(&files, "package-lock.json"),
1103 s(&["", "web"])
1104 );
1105 assert_eq!(lockfile_dirs_in(&files, "pnpm-lock.yaml"), s(&["web/x"]));
1106 }
1107
1108 #[test]
1109 fn package_manager_field_is_read_narrowly() {
1110 assert_eq!(
1111 declared_manager(r#"{"packageManager": "pnpm@10.1.0"}"#),
1112 Some(Manager::Pnpm)
1113 );
1114 assert_eq!(
1115 declared_manager(r#"{"packageManager":"npm@11"}"#),
1116 Some(Manager::Npm)
1117 );
1118 assert_eq!(
1119 declared_manager(r#"{"packageManager":"yarn@4"}"#),
1120 Some(Manager::Yarn)
1121 );
1122 assert_eq!(
1123 declared_manager(r#"{"packageManager":"bun@1.2.0"}"#),
1124 Some(Manager::Bun)
1125 );
1126 assert_eq!(
1127 declared_manager(r#"{"packageManager":"yarnpkg@1"}"#),
1128 None,
1129 "a name, not a prefix"
1130 );
1131 assert_eq!(declared_manager(r#"{"name":"x"}"#), None);
1132 assert_eq!(
1133 declared_manager(r#"{"packageManager":"npm@1","x":{"packageManager":"pnpm@1"}}"#),
1134 None,
1135 "two answers is no answer"
1136 );
1137 }
1138
1139 fn unit(dir: &str) -> Unit {
1140 Unit {
1141 dir: dir.to_string(),
1142 manager: Manager::Pnpm,
1143 lockfile: "pnpm-lock.yaml",
1144 standalone: !dir.is_empty(),
1145 berry: false,
1146 }
1147 }
1148
1149 #[test]
1150 fn a_berry_lockfile_is_told_by_its_metadata_block() {
1151 assert!(is_berry_lock(
1152 "# This file is generated by running \"yarn install\" inside your project.\n\n__metadata:\n version: 8\n"
1153 ));
1154 assert!(!is_berry_lock(
1155 "# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.\n# yarn lockfile v1\n\n"
1156 ));
1157 assert!(!is_berry_lock(""));
1158 }
1159
1160 #[test]
1161 fn every_manager_lockfile_is_a_unit_and_a_mixed_directory_needs_the_field() {
1162 let d = std::env::temp_dir().join(format!("units-{}", std::process::id()));
1163 let _ = std::fs::remove_dir_all(&d);
1164 std::fs::create_dir_all(d.join("berry")).unwrap();
1165 std::fs::write(d.join("yarn.lock"), "# yarn lockfile v1\n").unwrap();
1166 std::fs::write(d.join("berry/yarn.lock"), "__metadata:\n version: 8\n").unwrap();
1167 let files = s(&[
1168 "yarn.lock",
1169 "berry/yarn.lock",
1170 "old/bun.lockb",
1171 "new/bun.lock",
1172 "mixed/package-lock.json",
1173 "mixed/yarn.lock",
1174 ]);
1175 let err = units(&files, &d).unwrap_err();
1176 assert!(
1177 err.contains("both package-lock.json and yarn.lock in mixed"),
1178 "{err}"
1179 );
1180 std::fs::create_dir_all(d.join("mixed")).unwrap();
1181 std::fs::write(
1182 d.join("mixed/package.json"),
1183 r#"{"packageManager":"yarn@1.22.22"}"#,
1184 )
1185 .unwrap();
1186 let got = units(&files, &d).unwrap();
1187 let brief: Vec<(&str, Manager, &str, bool)> = got
1188 .iter()
1189 .map(|u| (u.dir.as_str(), u.manager, u.lockfile, u.berry))
1190 .collect();
1191 assert_eq!(
1192 brief,
1193 vec![
1194 ("", Manager::Yarn, "yarn.lock", false),
1195 ("berry", Manager::Yarn, "yarn.lock", true),
1196 ("mixed", Manager::Yarn, "yarn.lock", false),
1197 ("new", Manager::Bun, "bun.lock", false),
1198 ("old", Manager::Bun, "bun.lockb", false),
1199 ]
1200 );
1201 std::fs::write(
1202 d.join("mixed/package.json"),
1203 r#"{"packageManager":"pnpm@10"}"#,
1204 )
1205 .unwrap();
1206 let err = units(&files, &d).unwrap_err();
1207 assert!(
1208 err.contains("no `packageManager` to choose"),
1209 "a field naming an absent lockfile chooses nothing: {err}"
1210 );
1211 let _ = std::fs::remove_dir_all(&d);
1212 }
1213
1214 #[test]
1215 fn only_the_root_and_the_touched_units_are_kept() {
1216 let all = vec![unit(""), unit("spikes/a"), unit("spikes/b"), unit("tools")];
1217 let changed = s(&["src/x.ts", "spikes/b/index.ts", "spikes/bb/y.ts"]);
1218 let (kept, skipped) = select_units(all.clone(), Some(&changed));
1219 let dirs = |v: &[Unit]| v.iter().map(|u| u.dir.clone()).collect::<Vec<_>>();
1220 assert_eq!(
1221 dirs(&kept),
1222 s(&["", "spikes/b"]),
1223 "spikes/bb is not spikes/b"
1224 );
1225 assert_eq!(dirs(&skipped), s(&["spikes/a", "tools"]));
1226 let (kept, skipped) = select_units(all.clone(), None);
1227 assert_eq!(kept.len(), 4, "an unknown range keeps everything");
1228 assert!(skipped.is_empty());
1229 }
1230
1231 #[test]
1232 fn a_nested_unit_claims_its_files_from_the_outer_one() {
1233 let all = vec![unit("web"), unit("web/nested")];
1234 let changed = s(&["web/nested/a.ts"]);
1235 let (kept, _) = select_units(all, Some(&changed));
1236 assert_eq!(kept.len(), 1);
1237 assert_eq!(kept[0].dir, "web/nested");
1238 }
1239
1240 #[test]
1241 fn a_file_newer_than_the_install_record_is_found() {
1242 let d = std::env::temp_dir().join(format!("newer-{}", std::process::id()));
1243 let _ = std::fs::remove_dir_all(&d);
1244 let pkg = d.join(".pnpm/a@1/node_modules/a");
1245 std::fs::create_dir_all(&pkg).unwrap();
1246 std::fs::create_dir_all(d.join(".bin")).unwrap();
1247 std::fs::create_dir_all(d.join(".cache")).unwrap();
1248 std::fs::write(pkg.join("index.js"), "old").unwrap();
1249 let record = std::time::SystemTime::now();
1250 std::thread::sleep(std::time::Duration::from_millis(20));
1251 for skipped in [".bin/tsc", ".cache/x", ".pnpm/lock.yaml", ".modules.yaml"] {
1252 std::fs::write(d.join(skipped), "new").unwrap();
1253 }
1254 assert_eq!(
1255 newer_than(&d, record).unwrap(),
1256 None,
1257 "the install's own writes"
1258 );
1259 std::fs::write(pkg.join("index.js"), "edited").unwrap();
1260 assert_eq!(newer_than(&d, record).unwrap(), Some(pkg.join("index.js")));
1261 let _ = std::fs::remove_dir_all(&d);
1262 }
1263
1264 #[test]
1265 fn normalize_folds_dots_lexically() {
1266 assert_eq!(
1267 normalize(Path::new("/a/b/../c/./d")),
1268 PathBuf::from("/a/c/d")
1269 );
1270 }
1271}