1use std::collections::BTreeSet;
19use std::fs::File;
20use std::path::{Component, Path, PathBuf};
21
22use crate::create::PAYLOAD_PREFIX;
23use crate::error::PackError;
24use crate::manifest::{Manifest, SkipRecord, SymlinkRecord};
25
26#[derive(Debug, Clone)]
28pub struct RestoreOptions {
29 pub archive: PathBuf,
31 pub dest: PathBuf,
33 pub force: bool,
41 pub dry_run: bool,
53}
54
55impl RestoreOptions {
56 pub fn new(archive: impl Into<PathBuf>, dest: impl Into<PathBuf>) -> Self {
58 Self {
59 archive: archive.into(),
60 dest: dest.into(),
61 force: false,
62 dry_run: false,
63 }
64 }
65}
66
67#[derive(Debug, Clone)]
69pub struct RestoreReport {
70 pub dest: PathBuf,
72 pub manifest: Manifest,
74 pub dry_run: bool,
76 pub entries_written: u64,
78 pub destination_exists: bool,
83 pub would_overwrite: Vec<String>,
87 pub would_remain: Vec<String>,
93 pub rewritten_worktrees: Vec<String>,
95 pub missing_worktrees: Vec<String>,
98 pub dangling_symlinks: Vec<SymlinkRecord>,
100 pub missing_claude_link_roots: Vec<String>,
102 pub regenerable_caches: Vec<SkipRecord>,
104 pub secrets_not_carried: Vec<SkipRecord>,
106}
107
108impl RestoreReport {
109 pub fn needs_attention(&self) -> bool {
115 !self.dangling_symlinks.is_empty()
116 || !self.missing_claude_link_roots.is_empty()
117 || !self.missing_worktrees.is_empty()
118 || !self.secrets_not_carried.is_empty()
119 || !self.would_overwrite.is_empty()
120 || !self.would_remain.is_empty()
121 }
122}
123
124pub fn restore(opts: &RestoreOptions) -> Result<RestoreReport, PackError> {
142 let manifest = crate::inspect::verify(&opts.archive)?;
143 let destination_exists = opts.dest.exists();
144
145 if opts.dry_run {
146 return predict(opts, manifest, destination_exists);
147 }
148
149 if destination_exists && !opts.force {
150 return Err(PackError::DestinationExists(opts.dest.clone()));
151 }
152 std::fs::create_dir_all(&opts.dest)?;
153 let dest = std::fs::canonicalize(&opts.dest).unwrap_or_else(|_| opts.dest.clone());
154
155 let entries_written = unpack_payload(&opts.archive, &dest)?;
156 let rewritten_worktrees = rewrite_worktree_pointers(&dest, &manifest)?;
157
158 let missing_worktrees = manifest
159 .worktrees
160 .iter()
161 .filter(|w| !w.included)
162 .map(|w| w.name.clone())
163 .collect();
164
165 let dangling_symlinks = manifest
166 .symlinks
167 .iter()
168 .filter(|s| is_dangling(&dest, s))
169 .cloned()
170 .collect();
171
172 let missing_claude_link_roots = manifest
173 .claude
174 .link_roots
175 .iter()
176 .filter(|r| !Path::new(r).exists())
177 .cloned()
178 .collect();
179
180 Ok(RestoreReport {
181 dest,
182 dry_run: false,
183 entries_written,
184 destination_exists,
185 would_overwrite: Vec::new(),
186 would_remain: Vec::new(),
187 rewritten_worktrees,
188 missing_worktrees,
189 dangling_symlinks,
190 missing_claude_link_roots,
191 regenerable_caches: manifest.skipped_cache.clone(),
192 secrets_not_carried: manifest.skipped_secret.clone(),
193 manifest,
194 })
195}
196
197fn predict(
204 opts: &RestoreOptions,
205 manifest: Manifest,
206 destination_exists: bool,
207) -> Result<RestoreReport, PackError> {
208 let dest = std::fs::canonicalize(&opts.dest).unwrap_or_else(|_| opts.dest.clone());
209 let payload = crate::inspect::list_payload_paths(&opts.archive)?;
210 let payload_set: BTreeSet<&str> = payload.iter().map(|s| s.as_str()).collect();
211
212 let (would_overwrite, would_remain) = if destination_exists {
213 compare_destination(&dest, &payload_set)
214 } else {
215 (Vec::new(), Vec::new())
216 };
217
218 let rewritten_worktrees = manifest
219 .worktrees
220 .iter()
221 .filter(|w| w.included)
222 .map(|w| w.name.clone())
223 .collect();
224
225 let missing_worktrees = manifest
226 .worktrees
227 .iter()
228 .filter(|w| !w.included)
229 .map(|w| w.name.clone())
230 .collect();
231
232 let dangling_symlinks = manifest
233 .symlinks
234 .iter()
235 .filter(|s| would_dangle(&dest, s, &payload_set))
236 .cloned()
237 .collect();
238
239 let missing_claude_link_roots = manifest
240 .claude
241 .link_roots
242 .iter()
243 .filter(|r| !Path::new(r).exists())
244 .cloned()
245 .collect();
246
247 Ok(RestoreReport {
248 dest,
249 dry_run: true,
250 entries_written: payload.len() as u64,
251 destination_exists,
252 would_overwrite,
253 would_remain,
254 rewritten_worktrees,
255 missing_worktrees,
256 dangling_symlinks,
257 missing_claude_link_roots,
258 regenerable_caches: manifest.skipped_cache.clone(),
259 secrets_not_carried: manifest.skipped_secret.clone(),
260 manifest,
261 })
262}
263
264fn compare_destination(dest: &Path, incoming: &BTreeSet<&str>) -> (Vec<String>, Vec<String>) {
270 let mut overwrite = Vec::new();
271 let mut remain = Vec::new();
272
273 let walker = walkdir::WalkDir::new(dest)
274 .follow_links(false)
275 .min_depth(1)
276 .sort_by_file_name();
277
278 for entry in walker.into_iter().filter_map(|e| e.ok()) {
279 if entry.file_type().is_dir() {
280 continue;
281 }
282 let Ok(rel) = entry.path().strip_prefix(dest) else {
283 continue;
284 };
285 let rel = rel
286 .components()
287 .map(|c| c.as_os_str().to_string_lossy())
288 .collect::<Vec<_>>()
289 .join("/");
290 if rel.is_empty() {
291 continue;
292 }
293 if incoming.contains(rel.as_str()) {
294 overwrite.push(rel);
295 } else {
296 remain.push(rel);
297 }
298 }
299
300 (overwrite, remain)
301}
302
303fn would_dangle(dest: &Path, record: &SymlinkRecord, payload: &BTreeSet<&str>) -> bool {
313 let target = Path::new(&record.target);
314
315 if target.is_absolute() {
316 return !target.exists();
317 }
318
319 let link_parent = Path::new(&record.path).parent().unwrap_or(Path::new(""));
320 let resolved = crate::scan::normalize(&link_parent.join(target));
321
322 let as_key = resolved
323 .components()
324 .map(|c| c.as_os_str().to_string_lossy())
325 .collect::<Vec<_>>()
326 .join("/");
327 if payload.contains(as_key.as_str()) {
328 return false;
330 }
331
332 !dest.join(&resolved).exists()
333}
334
335fn unpack_payload(archive: &Path, dest: &Path) -> Result<u64, PackError> {
337 let file = File::open(archive)?;
338 let decoder = zstd::stream::Decoder::new(file)?;
339 let mut tar = tar::Archive::new(decoder);
340
341 let mut written = 0u64;
342 for entry in tar.entries()? {
343 let mut entry = entry?;
344 let path = entry.path()?.to_path_buf();
345 let Ok(rel) = path.strip_prefix(PAYLOAD_PREFIX) else {
346 continue;
348 };
349 if rel.as_os_str().is_empty() {
350 continue;
351 }
352 if rel
356 .components()
357 .any(|c| matches!(c, Component::ParentDir | Component::RootDir))
358 {
359 tracing::warn!("skipping unsafe archive path: {}", rel.display());
360 continue;
361 }
362
363 let out = dest.join(rel);
364 if let Some(parent) = out.parent() {
365 std::fs::create_dir_all(parent)?;
366 }
367 if out.is_symlink() {
369 std::fs::remove_file(&out)?;
370 }
371 entry.unpack(&out)?;
372 written += 1;
373 }
374
375 Ok(written)
376}
377
378fn rewrite_worktree_pointers(dest: &Path, manifest: &Manifest) -> Result<Vec<String>, PackError> {
382 let mut rewritten = Vec::new();
383
384 for record in &manifest.worktrees {
385 let Some(rel) = record.path.as_deref() else {
386 continue;
387 };
388 let admin = dest.join(".git").join("worktrees").join(&record.name);
389 let worktree_root = dest.join(rel);
390 if !admin.is_dir() || !worktree_root.is_dir() {
391 continue;
394 }
395
396 let dot_git = worktree_root.join(".git");
397 std::fs::write(admin.join("gitdir"), format!("{}\n", dot_git.display()))?;
398 std::fs::write(&dot_git, format!("gitdir: {}\n", admin.display()))?;
399 rewritten.push(record.name.clone());
400 }
401
402 Ok(rewritten)
403}
404
405fn is_dangling(dest: &Path, record: &SymlinkRecord) -> bool {
407 let link = dest.join(&record.path);
408 if !link.is_symlink() {
409 return false;
411 }
412 !link.exists()
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418 use crate::create::{CreateOptions, create};
419 use std::fs;
420 use tempfile::TempDir;
421
422 fn touch(path: &Path, body: &str) {
423 if let Some(parent) = path.parent() {
424 fs::create_dir_all(parent).expect("mkdir");
425 }
426 fs::write(path, body).expect("write");
427 }
428
429 #[test]
431 fn test_round_trip_preserves_content() {
432 let dir = TempDir::new().expect("tempdir");
433 let root = dir.path().join("proj");
434 touch(&root.join("src/main.rs"), "fn main() {}");
435 touch(&root.join(".git/HEAD"), "ref: refs/heads/main\n");
436 touch(&root.join("workspace/journal.md"), "# journal\n");
437 touch(&root.join("workspace/.journal.db"), "sqlite");
438
439 let out = dir.path().join("proj.pack");
440 create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
441
442 let dest = dir.path().join("restored");
443 let report = restore(&RestoreOptions::new(&out, &dest)).expect("restore");
444
445 assert_eq!(
446 fs::read_to_string(dest.join("src/main.rs")).expect("read"),
447 "fn main() {}"
448 );
449 assert_eq!(
450 fs::read_to_string(dest.join(".git/HEAD")).expect("read"),
451 "ref: refs/heads/main\n"
452 );
453 assert_eq!(
454 fs::read_to_string(dest.join("workspace/.journal.db")).expect("read"),
455 "sqlite",
456 "local state must survive the round trip"
457 );
458 assert!(report.entries_written > 0);
459 }
460
461 #[test]
463 fn test_restore_refuses_existing_destination() {
464 let dir = TempDir::new().expect("tempdir");
465 let root = dir.path().join("proj");
466 touch(&root.join("a.txt"), "a");
467 let out = dir.path().join("proj.pack");
468 create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
469
470 let dest = dir.path().join("existing");
471 fs::create_dir_all(&dest).expect("mkdir");
472
473 assert!(matches!(
474 restore(&RestoreOptions::new(&out, &dest)),
475 Err(PackError::DestinationExists(_))
476 ));
477
478 let forced = RestoreOptions {
479 force: true,
480 ..RestoreOptions::new(&out, &dest)
481 };
482 restore(&forced).expect("force should proceed");
483 assert!(dest.join("a.txt").is_file());
484 }
485
486 #[test]
489 fn test_restore_rewrites_worktree_pointers() {
490 let dir = TempDir::new().expect("tempdir");
491 let root = dir.path().join("proj");
492 touch(&root.join(".git/HEAD"), "ref: refs/heads/main\n");
493
494 let wt = root.join(".worktrees/feature");
495 touch(&wt.join("file.txt"), "work");
496 let admin = root.join(".git/worktrees/feature");
497 fs::create_dir_all(&admin).expect("mkdir");
498 fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).expect("write");
499 fs::write(
500 admin.join("gitdir"),
501 format!("{}\n", wt.join(".git").display()),
502 )
503 .expect("write");
504 fs::write(admin.join("commondir"), "../..\n").expect("write");
505
506 let out = dir.path().join("proj.pack");
507 create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
508
509 let dest = dir.path().join("moved");
510 let report = restore(&RestoreOptions::new(&out, &dest)).expect("restore");
511
512 assert_eq!(report.rewritten_worktrees, vec!["feature".to_string()]);
513
514 let new_admin_gitdir =
515 fs::read_to_string(dest.join(".git/worktrees/feature/gitdir")).expect("read");
516 let new_dot_git = fs::read_to_string(dest.join(".worktrees/feature/.git")).expect("read");
517
518 let dest_real = fs::canonicalize(&dest).expect("canonicalize");
519 assert!(
520 new_admin_gitdir
521 .trim()
522 .starts_with(&dest_real.to_string_lossy().to_string()),
523 "gitdir must point into the new root, got {new_admin_gitdir}"
524 );
525 assert!(
526 new_dot_git
527 .trim()
528 .contains(&dest_real.to_string_lossy().to_string()),
529 "worktree .git must point into the new root, got {new_dot_git}"
530 );
531 assert!(
532 !new_admin_gitdir.contains("/proj/"),
533 "stale source path must not survive: {new_admin_gitdir}"
534 );
535 }
536
537 #[cfg(unix)]
539 #[test]
540 fn test_restore_reports_dangling_symlink() {
541 let dir = TempDir::new().expect("tempdir");
542 let root = dir.path().join("proj");
543 fs::create_dir_all(&root).expect("mkdir");
544 let vanishing = dir.path().join("vanishing");
545 fs::create_dir_all(&vanishing).expect("mkdir");
546 touch(&vanishing.join("target.md"), "t");
547 std::os::unix::fs::symlink(vanishing.join("target.md"), root.join("link.md"))
548 .expect("symlink");
549
550 let out = dir.path().join("proj.pack");
551 create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
552
553 fs::remove_dir_all(&vanishing).expect("rm");
555
556 let dest = dir.path().join("restored");
557 let report = restore(&RestoreOptions::new(&out, &dest)).expect("restore");
558
559 assert!(dest.join("link.md").is_symlink(), "link itself is restored");
560 assert_eq!(report.dangling_symlinks.len(), 1);
561 assert_eq!(report.dangling_symlinks[0].path, "link.md");
562 assert!(report.needs_attention());
563 }
564
565 #[cfg(unix)]
567 #[test]
568 fn test_restore_does_not_report_live_symlink() {
569 let dir = TempDir::new().expect("tempdir");
570 let root = dir.path().join("proj");
571 fs::create_dir_all(&root).expect("mkdir");
572 touch(&root.join("real.txt"), "r");
573 std::os::unix::fs::symlink("real.txt", root.join("rel-link")).expect("symlink");
574
575 let out = dir.path().join("proj.pack");
576 create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
577
578 let dest = dir.path().join("restored");
579 let report = restore(&RestoreOptions::new(&out, &dest)).expect("restore");
580
581 assert!(report.dangling_symlinks.is_empty());
582 }
583
584 #[test]
590 fn test_dry_run_writes_nothing() {
591 let dir = TempDir::new().expect("tempdir");
592 let root = dir.path().join("proj");
593 touch(&root.join("a.txt"), "a");
594 let out = dir.path().join("proj.pack");
595 create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
596
597 let dest = dir.path().join("nowhere");
598 let opts = RestoreOptions {
599 dry_run: true,
600 ..RestoreOptions::new(&out, &dest)
601 };
602 let report = restore(&opts).expect("dry run");
603
604 assert!(report.dry_run);
605 assert!(!dest.exists(), "dry run must not create the destination");
606 assert!(
607 report.entries_written > 0,
608 "it still counts what would land"
609 );
610 assert!(!report.destination_exists);
611 assert!(report.would_overwrite.is_empty());
612 assert!(report.would_remain.is_empty());
613 }
614
615 #[test]
619 fn test_dry_run_splits_existing_destination() {
620 let dir = TempDir::new().expect("tempdir");
621 let root = dir.path().join("proj");
622 touch(&root.join("shared.txt"), "from pack");
623 touch(&root.join("only-in-pack.txt"), "new");
624 let out = dir.path().join("proj.pack");
625 create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
626
627 let dest = dir.path().join("existing");
628 touch(&dest.join("shared.txt"), "old content");
629 touch(&dest.join("only-in-dest.txt"), "leftover");
630
631 let opts = RestoreOptions {
634 dry_run: true,
635 ..RestoreOptions::new(&out, &dest)
636 };
637 let report = restore(&opts).expect("dry run over existing dest");
638
639 assert!(report.destination_exists);
640 assert_eq!(report.would_overwrite, vec!["shared.txt".to_string()]);
641 assert_eq!(report.would_remain, vec!["only-in-dest.txt".to_string()]);
642 assert!(report.needs_attention());
643
644 assert_eq!(
646 fs::read_to_string(dest.join("shared.txt")).expect("read"),
647 "old content"
648 );
649 }
650
651 #[test]
653 fn test_dry_run_agrees_with_real_restore() {
654 let dir = TempDir::new().expect("tempdir");
655 let root = dir.path().join("proj");
656 touch(&root.join("a.txt"), "a");
657 touch(&root.join("sub/b.txt"), "b");
658 let out = dir.path().join("proj.pack");
659 create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
660
661 let dest = dir.path().join("dest");
662 let predicted = restore(&RestoreOptions {
663 dry_run: true,
664 ..RestoreOptions::new(&out, &dest)
665 })
666 .expect("dry run");
667
668 let actual = restore(&RestoreOptions::new(&out, &dest)).expect("real restore");
669
670 assert_eq!(
671 predicted.entries_written, actual.entries_written,
672 "a dry run that miscounts is worse than none"
673 );
674 assert_eq!(predicted.rewritten_worktrees, actual.rewritten_worktrees);
675 assert_eq!(
676 predicted.dangling_symlinks.len(),
677 actual.dangling_symlinks.len()
678 );
679 }
680
681 #[cfg(unix)]
683 #[test]
684 fn test_dry_run_predicts_dangling_symlink() {
685 let dir = TempDir::new().expect("tempdir");
686 let root = dir.path().join("proj");
687 fs::create_dir_all(&root).expect("mkdir");
688 let vanishing = dir.path().join("vanishing");
689 fs::create_dir_all(&vanishing).expect("mkdir");
690 touch(&vanishing.join("t.md"), "t");
691 std::os::unix::fs::symlink(vanishing.join("t.md"), root.join("link.md")).expect("symlink");
692
693 let out = dir.path().join("proj.pack");
694 create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
695 fs::remove_dir_all(&vanishing).expect("rm");
696
697 let dest = dir.path().join("dest");
698 let predicted = restore(&RestoreOptions {
699 dry_run: true,
700 ..RestoreOptions::new(&out, &dest)
701 })
702 .expect("dry run");
703
704 assert_eq!(predicted.dangling_symlinks.len(), 1);
705 assert_eq!(predicted.dangling_symlinks[0].path, "link.md");
706 assert!(!dest.exists(), "still nothing written");
707
708 let actual = restore(&RestoreOptions::new(&out, &dest)).expect("restore");
710 assert_eq!(actual.dangling_symlinks.len(), 1);
711 }
712
713 #[cfg(unix)]
715 #[test]
716 fn test_dry_run_does_not_predict_live_relative_link() {
717 let dir = TempDir::new().expect("tempdir");
718 let root = dir.path().join("proj");
719 fs::create_dir_all(&root).expect("mkdir");
720 touch(&root.join("real.txt"), "r");
721 std::os::unix::fs::symlink("real.txt", root.join("rel-link")).expect("symlink");
722
723 let out = dir.path().join("proj.pack");
724 create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
725
726 let dest = dir.path().join("dest");
727 let predicted = restore(&RestoreOptions {
728 dry_run: true,
729 ..RestoreOptions::new(&out, &dest)
730 })
731 .expect("dry run");
732
733 assert!(
734 predicted.dangling_symlinks.is_empty(),
735 "a link resolving inside the restored tree is fine"
736 );
737 }
738
739 #[test]
741 fn test_dry_run_announces_worktree_rewrite() {
742 let dir = TempDir::new().expect("tempdir");
743 let root = dir.path().join("proj");
744 touch(&root.join(".git/HEAD"), "ref: refs/heads/main\n");
745 let wt = root.join(".worktrees/feature");
746 touch(&wt.join("f.txt"), "w");
747 let admin = root.join(".git/worktrees/feature");
748 fs::create_dir_all(&admin).expect("mkdir");
749 fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).expect("write");
750 fs::write(
751 admin.join("gitdir"),
752 format!("{}\n", wt.join(".git").display()),
753 )
754 .expect("write");
755
756 let out = dir.path().join("proj.pack");
757 create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
758
759 let dest = dir.path().join("dest");
760 let predicted = restore(&RestoreOptions {
761 dry_run: true,
762 ..RestoreOptions::new(&out, &dest)
763 })
764 .expect("dry run");
765
766 assert_eq!(predicted.rewritten_worktrees, vec!["feature".to_string()]);
767 assert!(!dest.exists());
768 }
769
770 #[test]
773 fn test_restore_report_carries_skips() {
774 let dir = TempDir::new().expect("tempdir");
775 let root = dir.path().join("proj");
776 touch(&root.join("a.txt"), "a");
777 touch(&root.join(".env"), "S=1");
778 touch(&root.join("target/x"), "bin");
779
780 let out = dir.path().join("proj.pack");
781 create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
782
783 let dest = dir.path().join("restored");
784 let report = restore(&RestoreOptions::new(&out, &dest)).expect("restore");
785
786 assert!(report.secrets_not_carried.iter().any(|s| s.path == ".env"));
787 assert!(report.regenerable_caches.iter().any(|s| s.path == "target"));
788 assert!(!dest.join(".env").exists());
789 assert!(report.needs_attention());
790 }
791}