1use crate::error::{EngineError, Result};
73use crate::git_ops::GitRepo;
74use std::path::{Path, PathBuf};
75use std::time::{Duration, Instant};
76
77const PLAIN_COPY_MAX_BYTES: u64 = 2 * 1024 * 1024 * 1024;
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum TargetCopyTier {
87 Clonefile,
89 Reflink,
91 Copy,
93 Fresh,
96 Absent,
98}
99
100impl TargetCopyTier {
101 pub fn as_str(&self) -> &'static str {
103 match self {
104 TargetCopyTier::Clonefile => "clonefile",
105 TargetCopyTier::Reflink => "reflink",
106 TargetCopyTier::Copy => "copy",
107 TargetCopyTier::Fresh => "fresh",
108 TargetCopyTier::Absent => "absent",
109 }
110 }
111}
112
113fn pick_plain_or_fresh(target_bytes: u64) -> TargetCopyTier {
117 if target_bytes <= PLAIN_COPY_MAX_BYTES {
118 TargetCopyTier::Copy
119 } else {
120 TargetCopyTier::Fresh
121 }
122}
123
124pub struct ValidatorSnapshot {
130 repo: GitRepo,
133 path: PathBuf,
134 target_tier: TargetCopyTier,
135 detail: Option<String>,
136 creation: Duration,
137}
138
139impl ValidatorSnapshot {
140 pub fn validator_set_index_flags(&self) -> Result<Vec<String>> {
148 let repo = GitRepo::open(&self.path)?;
149 let flags = repo.ls_files_v()?;
150 Ok(flags
151 .lines()
152 .filter(|line| {
153 let Some(tag) = line.chars().next() else {
154 return false;
155 };
156 tag == 'S' || tag.is_ascii_lowercase()
157 })
158 .take(5)
159 .map(str::to_string)
160 .collect())
161 }
162
163 pub fn create(repo: &GitRepo, path: &Path) -> Result<Self> {
169 let started = Instant::now();
170 let _ = repo.remove_worktree(path);
171 let _ = std::fs::remove_dir_all(path);
172 if let Some(parent) = path.parent() {
173 std::fs::create_dir_all(parent)
174 .map_err(|e| EngineError::Git(format!("create {}: {e}", parent.display())))?;
175 }
176
177 let repo = &repo.with_hooks_disabled()?;
180 let head = repo.head_sha()?;
181 let diff = repo.diff_head()?;
182 let untracked = repo.untracked_files()?;
183 let flags = repo.ls_files_v()?;
190 let flagged: Vec<&str> = flags
191 .lines()
192 .filter(|line| {
193 let Some(tag) = line.chars().next() else {
194 return false;
195 };
196 tag == 'S' || tag.is_ascii_lowercase()
199 })
200 .collect();
201 if !flagged.is_empty() {
202 return Err(EngineError::InvalidState(format!(
203 "refusing validator snapshot over skip-worktree/assume-unchanged \
204 index flags (modifications hidden from git): {}",
205 flagged
206 .iter()
207 .take(5)
208 .copied()
209 .collect::<Vec<_>>()
210 .join(", ")
211 )));
212 }
213 repo.add_detached_worktree(path, &head)?;
214
215 let mut snapshot = ValidatorSnapshot {
218 repo: repo.clone(),
219 path: path.to_path_buf(),
220 target_tier: TargetCopyTier::Absent,
221 detail: None,
222 creation: Duration::default(),
223 };
224 let (tier, detail) = snapshot.populate(repo.root(), &diff, &untracked)?;
225 snapshot.target_tier = tier;
226 snapshot.detail = detail;
227 snapshot.creation = started.elapsed();
228 Ok(snapshot)
229 }
230
231 fn populate(
235 &self,
236 session_root: &Path,
237 diff: &str,
238 untracked: &[std::ffi::OsString],
239 ) -> Result<(TargetCopyTier, Option<String>)> {
240 let snap_repo = GitRepo::open(&self.path)?;
241 if !diff.trim().is_empty() {
242 let patch = self.path.with_extension("patch");
246 std::fs::write(&patch, diff).map_err(|e| {
247 EngineError::Git(format!("write snapshot patch {}: {e}", patch.display()))
248 })?;
249 let applied = snap_repo.apply_patch(&patch);
250 let _ = std::fs::remove_file(&patch);
251 applied?;
252 }
253 let skip_note = copy_untracked(session_root, &self.path, untracked)?;
254 let (tier, mut detail) = warm_target(session_root, &self.path);
255 if let (Some(note), Some(existing)) = (&skip_note, &mut detail) {
256 existing.push_str("; ");
257 existing.push_str(note);
258 } else if let Some(note) = skip_note {
259 detail = Some(note);
260 }
261 Ok((tier, detail))
262 }
263
264 pub fn path(&self) -> &Path {
266 &self.path
267 }
268
269 pub fn target_tier(&self) -> TargetCopyTier {
271 self.target_tier
272 }
273
274 pub fn detail(&self) -> Option<&str> {
277 self.detail.as_deref()
278 }
279
280 pub fn creation(&self) -> Duration {
282 self.creation
283 }
284}
285
286impl Drop for ValidatorSnapshot {
287 fn drop(&mut self) {
288 let _ = self.repo.remove_worktree(&self.path);
289 let _ = std::fs::remove_dir_all(&self.path);
290 let _ = self.repo.prune_worktrees();
291 }
292}
293
294fn copy_untracked(
307 session_root: &Path,
308 snapshot_root: &Path,
309 untracked: &[std::ffi::OsString],
310) -> Result<Option<String>> {
311 let mut skipped: Vec<String> = Vec::new();
312 for rel in untracked {
313 let src = session_root.join(rel);
314 let dst = snapshot_root.join(rel);
315 let metadata = match std::fs::symlink_metadata(&src) {
316 Ok(m) => m,
317 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
318 Err(e) => {
319 return Err(EngineError::Git(format!(
320 "snapshot untracked stat {}: {e}",
321 src.display()
322 )))
323 }
324 };
325 if !metadata.file_type().is_file() {
326 let kind = if metadata.file_type().is_symlink() {
327 "symlink"
328 } else if metadata.file_type().is_dir() {
329 "dir"
330 } else {
331 "special"
332 };
333 skipped.push(format!("{} ({kind})", rel.to_string_lossy()));
334 continue;
335 }
336 if let Some(parent) = dst.parent() {
337 std::fs::create_dir_all(parent).map_err(|e| {
338 EngineError::Git(format!("snapshot untracked {}: {e}", parent.display()))
339 })?;
340 }
341 match std::fs::copy(&src, &dst) {
342 Ok(_) => {}
343 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
344 Err(e) => {
345 return Err(EngineError::Git(format!(
346 "snapshot untracked copy {} -> {}: {e}",
347 src.display(),
348 dst.display()
349 )))
350 }
351 }
352 }
353 Ok((!skipped.is_empty()).then(|| {
354 format!(
355 "skipped {} non-regular untracked entr{}: {}",
356 skipped.len(),
357 if skipped.len() == 1 { "y" } else { "ies" },
358 skipped
359 .iter()
360 .take(5)
361 .cloned()
362 .collect::<Vec<_>>()
363 .join(", ")
364 )
365 }))
366}
367
368fn warm_target(session_root: &Path, snapshot_root: &Path) -> (TargetCopyTier, Option<String>) {
373 let src = session_root.join("target");
374 if !src.is_dir() {
375 return (TargetCopyTier::Absent, None);
376 }
377 let dst = snapshot_root.join("target");
378 if copy_dir_clonefile(&src, &dst) {
379 return (TargetCopyTier::Clonefile, None);
380 }
381 if copy_dir_reflink(&src, &dst) {
382 return (TargetCopyTier::Reflink, None);
383 }
384 let bytes = dir_size_bytes(&src);
385 match pick_plain_or_fresh(bytes) {
386 TargetCopyTier::Copy => match copy_dir_plain(&src, &dst) {
387 Ok(()) => (TargetCopyTier::Copy, None),
388 Err(e) => {
389 let _ = std::fs::remove_dir_all(&dst);
390 let detail = format!(
391 "target/ plain copy failed ({e}); snapshot starts with an empty target \
392 and pays a cold rebuild"
393 );
394 tracing::warn!("{detail}");
395 (TargetCopyTier::Fresh, Some(detail))
396 }
397 },
398 _ => {
399 let detail = format!(
400 "no clonefile/reflink acceleration and target/ is {} GiB (plain-copy cap {} GiB); \
401 snapshot starts with an empty target and pays a cold rebuild rather than copy",
402 bytes / (1024 * 1024 * 1024),
403 PLAIN_COPY_MAX_BYTES / (1024 * 1024 * 1024),
404 );
405 tracing::warn!("{detail}");
406 (TargetCopyTier::Fresh, Some(detail))
407 }
408 }
409}
410
411pub(crate) fn copy_dir_clonefile(src: &Path, dst: &Path) -> bool {
419 run_cp(&["-c", "-R"], src, dst)
420}
421
422pub(crate) fn copy_dir_reflink(src: &Path, dst: &Path) -> bool {
426 run_cp(&["--reflink=always", "-R"], src, dst)
427}
428
429fn run_cp(extra_flags: &[&str], src: &Path, dst: &Path) -> bool {
430 let status = std::process::Command::new("cp")
431 .args(extra_flags)
432 .arg(src)
433 .arg(dst)
434 .status();
435 match status {
436 Ok(s) if s.success() => true,
437 _ => {
438 let _ = std::fs::remove_dir_all(dst);
439 false
440 }
441 }
442}
443
444pub(crate) fn dir_size_bytes(dir: &Path) -> u64 {
448 let mut total = 0u64;
449 let mut stack = vec![dir.to_path_buf()];
450 while let Some(dir) = stack.pop() {
451 if let Ok(entries) = std::fs::read_dir(&dir) {
452 for entry in entries.flatten() {
453 if let Ok(meta) = entry.metadata() {
454 if meta.is_dir() {
455 stack.push(entry.path());
456 } else {
457 total += meta.len();
458 }
459 }
460 }
461 }
462 }
463 total
464}
465
466pub(crate) fn dir_size_exceeds(dir: &Path, limit: u64) -> bool {
472 let mut total = 0u64;
473 let mut stack = vec![dir.to_path_buf()];
474 while let Some(dir) = stack.pop() {
475 if let Ok(entries) = std::fs::read_dir(&dir) {
476 for entry in entries.flatten() {
477 if let Ok(meta) = entry.metadata() {
478 if meta.is_dir() {
479 stack.push(entry.path());
480 } else {
481 total += meta.len();
482 if total > limit {
483 return true;
484 }
485 }
486 }
487 }
488 }
489 }
490 false
491}
492
493pub(crate) fn copy_dir_plain(src: &Path, dst: &Path) -> std::io::Result<()> {
497 use cap_fs_ext::DirExt as _;
498 let (src_parent, src_name) =
499 crate::paths::open_parent_nofollow(src).map_err(std::io::Error::other)?;
500 let source = src_parent.open_dir_nofollow(src_name)?;
501 let (dst_parent, dst_name) =
502 crate::paths::open_parent_nofollow(dst).map_err(std::io::Error::other)?;
503 match dst_parent.create_dir(&dst_name) {
504 Ok(()) => {}
505 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
506 Err(e) => return Err(e),
507 }
508 let destination = dst_parent.open_dir_nofollow(dst_name)?;
509 copy_dir_contents(&source, &destination)
510}
511
512fn copy_dir_contents(src: &cap_std::fs::Dir, dst: &cap_std::fs::Dir) -> std::io::Result<()> {
513 use cap_fs_ext::{DirExt as _, OpenOptionsFollowExt as _};
514 use cap_primitives::fs::FollowSymlinks;
515 for entry in src.entries()? {
516 let entry = entry?;
517 let name = entry.file_name();
518 let kind = entry.file_type()?;
519 if kind.is_dir() {
520 let source = src.open_dir_nofollow(&name)?;
521 dst.create_dir(&name)?;
522 let destination = dst.open_dir_nofollow(&name)?;
523 copy_dir_contents(&source, &destination)?;
524 } else if kind.is_file() {
525 let mut options = cap_std::fs::OpenOptions::new();
526 options.read(true).follow(FollowSymlinks::No);
527 #[cfg(unix)]
528 {
529 use cap_std::fs::OpenOptionsExt as _;
530 options.custom_flags(libc::O_NONBLOCK);
532 }
533 let mut source = src.open_with(&name, &options)?.into_std();
534 let metadata = source.metadata()?;
535 if !metadata.is_file() {
536 return Err(std::io::Error::other("cache entry is not a regular file"));
537 }
538 let mut options = cap_std::fs::OpenOptions::new();
539 options
540 .write(true)
541 .create_new(true)
542 .follow(FollowSymlinks::No);
543 let mut destination = dst.open_with(&name, &options)?.into_std();
544 std::io::copy(&mut source, &mut destination)?;
545 destination.set_permissions(metadata.permissions())?;
546 } else {
547 return Err(std::io::Error::other(
548 "refusing a symlink or special cache entry",
549 ));
550 }
551 }
552 Ok(())
553}
554
555#[cfg(test)]
556mod tests {
557 use super::*;
558
559 #[cfg(unix)]
560 #[test]
561 fn snapshot_plain_copy_refuses_authority_links_and_preserves_executables() {
562 use std::os::unix::fs::{symlink, PermissionsExt};
563 let dir = tempfile::tempdir().unwrap();
564 let source = dir.path().join("target");
565 std::fs::create_dir(&source).unwrap();
566 let authority = dir.path().join("serve.token");
567 std::fs::write(&authority, "fake-authority").unwrap();
568 symlink(&authority, source.join("leak")).unwrap();
569 let destination = dir.path().join("copy");
570 assert!(copy_dir_plain(&source, &destination).is_err());
571 assert!(!destination.join("leak").exists());
572 assert_eq!(
573 std::fs::read_to_string(&authority).unwrap(),
574 "fake-authority"
575 );
576
577 std::fs::remove_file(source.join("leak")).unwrap();
578 std::fs::write(source.join("test-bin"), "executable").unwrap();
579 std::fs::set_permissions(
580 source.join("test-bin"),
581 std::fs::Permissions::from_mode(0o755),
582 )
583 .unwrap();
584 copy_dir_plain(&source, &destination).unwrap();
585 assert_eq!(
586 std::fs::read(destination.join("test-bin")).unwrap(),
587 b"executable"
588 );
589 assert_ne!(
590 std::fs::metadata(destination.join("test-bin"))
591 .unwrap()
592 .permissions()
593 .mode()
594 & 0o111,
595 0
596 );
597
598 let linked_destination = dir.path().join("linked-copy");
599 symlink(&source, &linked_destination).unwrap();
600 assert!(copy_dir_plain(&source, &linked_destination).is_err());
601 }
602
603 fn test_repo() -> Option<(tempfile::TempDir, PathBuf)> {
606 let git_ok = std::process::Command::new("git")
607 .arg("--version")
608 .output()
609 .map(|o| o.status.success())
610 .unwrap_or(false);
611 if !git_ok {
612 crate::test_capability::skip(
613 crate::test_capability::capability::GIT,
614 "git is not on PATH",
615 );
616 return None;
617 }
618 let dir = tempfile::tempdir().expect("tempdir");
619 let run = |args: &[&str]| {
620 let out = std::process::Command::new("git")
621 .args(args)
622 .current_dir(dir.path())
623 .output()
624 .expect("spawn git");
625 assert!(out.status.success(), "git {args:?} failed: {out:?}");
626 };
627 if !std::process::Command::new("git")
628 .args(["init", "-b", "main"])
629 .current_dir(dir.path())
630 .output()
631 .map(|o| o.status.success())
632 .unwrap_or(false)
633 {
634 run(&["init"]);
635 run(&["symbolic-ref", "HEAD", "refs/heads/main"]);
636 }
637 run(&["config", "user.name", "test"]);
638 run(&["config", "user.email", "test@example.com"]);
639 std::fs::write(dir.path().join("README.md"), "hello\n").unwrap();
640 std::fs::write(dir.path().join(".gitignore"), "target/\n").unwrap();
641 run(&["add", "-A"]);
642 run(&["commit", "-m", "init"]);
643 let root = dir.path().to_path_buf();
644 Some((dir, root))
645 }
646
647 #[test]
650 fn pick_plain_or_fresh_caps_plain_copy() {
651 assert_eq!(pick_plain_or_fresh(0), TargetCopyTier::Copy);
652 assert_eq!(
653 pick_plain_or_fresh(PLAIN_COPY_MAX_BYTES),
654 TargetCopyTier::Copy
655 );
656 assert_eq!(
657 pick_plain_or_fresh(PLAIN_COPY_MAX_BYTES + 1),
658 TargetCopyTier::Fresh
659 );
660 }
661
662 #[test]
666 fn create_refuses_skip_worktree_flags() {
667 let Some((dir, root)) = test_repo() else {
668 return;
669 };
670 let repo = GitRepo::open(&root).unwrap();
671 let run = |args: &[&str]| {
672 let out = std::process::Command::new("git")
673 .args(args)
674 .current_dir(&root)
675 .output()
676 .expect("spawn git");
677 assert!(out.status.success(), "git {args:?} failed: {out:?}");
678 };
679 run(&["update-index", "--skip-worktree", "README.md"]);
682 std::fs::write(root.join("README.md"), "hidden modification\n").unwrap();
683
684 let err = match ValidatorSnapshot::create(&repo, &root.join("snap")) {
685 Ok(_) => panic!("a flagged checkout must not build a snapshot"),
686 Err(err) => err,
687 };
688 assert!(
689 err.to_string().contains("skip-worktree"),
690 "error must name the flag class: {err}"
691 );
692 drop(dir);
693 }
694
695 #[test]
698 fn validator_set_index_flags_detects_flags_set_inside_the_snapshot() {
699 let Some((_dir, root)) = test_repo() else {
700 return;
701 };
702 let repo = GitRepo::open(&root).unwrap();
703 let snapshot =
704 ValidatorSnapshot::create(&repo, &root.join("snap")).expect("clean checkout builds");
705 assert!(
706 snapshot.validator_set_index_flags().unwrap().is_empty(),
707 "a fresh snapshot has no flags"
708 );
709
710 let out = std::process::Command::new("git")
712 .args(["update-index", "--skip-worktree", "README.md"])
713 .current_dir(snapshot.path())
714 .output()
715 .expect("spawn git");
716 assert!(out.status.success(), "git update-index failed: {out:?}");
717
718 let flags = snapshot.validator_set_index_flags().unwrap();
719 assert_eq!(flags.len(), 1, "{flags:?}");
720 assert!(flags[0].starts_with('S'), "{flags:?}");
721 assert!(flags[0].contains("README.md"), "{flags:?}");
722 }
723
724 #[test]
728 fn snapshot_replays_uncommitted_state_and_cleans_up() {
729 let Some((_dir, root)) = test_repo() else {
730 return;
731 };
732 let root = std::fs::canonicalize(root).unwrap();
735 #[cfg(windows)]
736 assert!(root.to_string_lossy().starts_with(r"\\?\"));
737 let repo = GitRepo::open(&root).unwrap();
738 let head = repo.head_sha().unwrap();
739
740 std::fs::write(root.join("README.md"), "hello\nworker edit\n").unwrap();
744 std::fs::write(root.join("staged.rs"), "fn staged() {}\n").unwrap();
745 let staged = std::process::Command::new("git")
746 .args(["add", "staged.rs"])
747 .current_dir(&root)
748 .output()
749 .expect("git add");
750 assert!(staged.status.success(), "git add: {staged:?}");
751 std::fs::create_dir_all(root.join("notes")).unwrap();
752 std::fs::write(root.join("notes/todo.txt"), "uncommitted\n").unwrap();
753 std::fs::create_dir_all(root.join("target/debug")).unwrap();
754 std::fs::write(root.join("target/debug/obj.o"), "obj").unwrap();
755
756 let snapshot_dir = tempfile::tempdir().unwrap();
757 let snap_path = std::fs::canonicalize(snapshot_dir.path())
758 .unwrap()
759 .join("snapshot under test");
760 let snapshot = ValidatorSnapshot::create(&repo, &snap_path).unwrap();
761
762 let read_normalized =
766 |path: &Path| std::fs::read_to_string(path).unwrap().replace("\r\n", "\n");
767 assert_eq!(
768 GitRepo::open(&snap_path).unwrap().head_sha().unwrap(),
769 head,
770 "snapshot is detached at the real checkout's HEAD"
771 );
772 assert_eq!(
773 read_normalized(&snap_path.join("README.md")),
774 "hello\nworker edit\n",
775 "unstaged tracked edit must be visible in the snapshot"
776 );
777 assert_eq!(
778 read_normalized(&snap_path.join("staged.rs")),
779 "fn staged() {}\n",
780 "staged new file must be visible in the snapshot"
781 );
782 assert_eq!(
783 read_normalized(&snap_path.join("notes/todo.txt")),
784 "uncommitted\n",
785 "untracked files must be visible in the snapshot"
786 );
787 assert_eq!(
790 std::fs::read_to_string(snap_path.join("target/debug/obj.o")).unwrap(),
791 "obj"
792 );
793 std::fs::write(snap_path.join("target/debug/obj.o"), "poisoned").unwrap();
794 assert_eq!(
795 std::fs::read_to_string(root.join("target/debug/obj.o")).unwrap(),
796 "obj",
797 "the warmed target is a copy, never a share of the real target/"
798 );
799
800 assert_eq!(
802 repo.head_sha().unwrap(),
803 head,
804 "snapshot creation must not move the real HEAD"
805 );
806
807 let snap_path_copy = snap_path.clone();
808 drop(snapshot);
809 assert!(
810 !snap_path_copy.exists(),
811 "drop discards the snapshot worktree"
812 );
813 let listed = std::process::Command::new("git")
815 .args(["worktree", "list", "--porcelain"])
816 .current_dir(&root)
817 .output()
818 .expect("git worktree list");
819 let listed = String::from_utf8_lossy(&listed.stdout);
820 assert!(!listed.contains("snap-shot-under-test"), "{listed}");
821 }
822
823 #[test]
826 fn create_clears_stale_leftover() {
827 let Some((_dir, root)) = test_repo() else {
828 return;
829 };
830 let repo = GitRepo::open(&root).unwrap();
831 let snap_path = root.parent().unwrap().join("snap-stale");
832 std::fs::create_dir_all(&snap_path).unwrap();
833 std::fs::write(snap_path.join("leftover.txt"), "stale").unwrap();
834
835 let snapshot = ValidatorSnapshot::create(&repo, &snap_path).unwrap();
836 assert!(snap_path.join("README.md").exists());
837 assert!(
838 !snap_path.join("leftover.txt").exists(),
839 "the stale dir is swept, not merged into"
840 );
841 drop(snapshot);
842 }
843
844 #[test]
846 fn warm_target_absent_without_target_dir() {
847 let dir = tempfile::tempdir().unwrap();
848 let (tier, detail) = warm_target(dir.path(), &dir.path().join("snap"));
849 assert_eq!(tier, TargetCopyTier::Absent);
850 assert_eq!(detail, None);
851 }
852
853 #[test]
857 fn warm_target_copies_content_on_any_tier() {
858 let dir = tempfile::tempdir().unwrap();
859 let session = dir.path().join("session");
860 let snap = dir.path().join("snap");
861 std::fs::create_dir_all(session.join("target/debug/deps")).unwrap();
862 std::fs::write(session.join("target/debug/deps/lib.rlib"), "rlib-bytes").unwrap();
863 std::fs::create_dir_all(&snap).unwrap();
864
865 let (tier, detail) = warm_target(&session, &snap);
866 assert!(
867 matches!(
868 tier,
869 TargetCopyTier::Clonefile | TargetCopyTier::Reflink | TargetCopyTier::Copy
870 ),
871 "a small target warms via some copy tier, got {tier:?} ({detail:?})"
872 );
873 assert_eq!(
874 std::fs::read_to_string(snap.join("target/debug/deps/lib.rlib")).unwrap(),
875 "rlib-bytes",
876 "the warm copy carries the bytes regardless of tier"
877 );
878 }
879
880 #[cfg(target_os = "macos")]
884 #[test]
885 fn warm_target_prefers_clonefile_on_apfs() {
886 let dir = tempfile::tempdir().unwrap();
887 let probe_src = dir.path().join("probe");
888 std::fs::write(&probe_src, "probe").unwrap();
889 if !copy_dir_clonefile(&probe_src, &dir.path().join("probe-clone")) {
890 eprintln!("skipping test: clonefile unsupported on this volume");
891 return;
892 }
893 let session = dir.path().join("session");
894 let snap = dir.path().join("snap");
895 std::fs::create_dir_all(session.join("target")).unwrap();
896 std::fs::write(session.join("target/artifact.o"), "bytes").unwrap();
897 std::fs::create_dir_all(&snap).unwrap();
898
899 let (tier, _) = warm_target(&session, &snap);
900 assert_eq!(tier, TargetCopyTier::Clonefile);
901 assert_eq!(
902 std::fs::read_to_string(snap.join("target/artifact.o")).unwrap(),
903 "bytes"
904 );
905 }
906
907 #[cfg(unix)]
911 #[test]
912 fn warm_target_fresh_when_copy_prohibitive() {
913 let dir = tempfile::tempdir().unwrap();
914 let session = dir.path().join("session");
915 let snap = dir.path().join("snap");
916 std::fs::create_dir_all(session.join("target")).unwrap();
917 std::fs::write(session.join("target/big.bin"), "").unwrap();
918 std::fs::File::options()
919 .write(true)
920 .open(session.join("target/big.bin"))
921 .unwrap()
922 .set_len(PLAIN_COPY_MAX_BYTES + 1)
923 .unwrap();
924 std::fs::create_dir_all(&snap).unwrap();
925
926 assert_eq!(
928 pick_plain_or_fresh(dir_size_bytes(&session.join("target"))),
929 TargetCopyTier::Fresh,
930 "over-cap sparse target must select fresh"
931 );
932 let (tier, detail) = warm_target(&session, &snap);
937 if tier == TargetCopyTier::Fresh {
938 let detail = detail.expect("fresh tier names the cost");
939 assert!(detail.contains("GiB"), "{detail}");
940 assert!(detail.contains("cold rebuild"), "{detail}");
941 assert!(
942 !snap.join("target/big.bin").exists(),
943 "fresh tier does not attempt the copy"
944 );
945 } else {
946 assert!(
947 snap.join("target/big.bin").exists(),
948 "a fast tier won on this clone-capable host: the copy exists"
949 );
950 }
951 }
952}