Skip to main content

lds_pack/
restore.rs

1//! Restore a pack into a directory, then repair what a plain extract leaves
2//! broken and report what the pack could not carry.
3//!
4//! Two things survive extraction only with help:
5//!
6//! - **worktree pointers.** A registered worktree is wired with two absolute
7//!   paths — `.git/worktrees/<name>/gitdir` names the worktree's `.git` file,
8//!   and that file names the admin directory back. Both still point at the
9//!   machine the pack came from, so they are rewritten here.
10//! - **symlinks leaving the project.** They are restored verbatim; the ones
11//!   whose targets do not exist on this machine are reported rather than
12//!   silently left broken.
13//!
14//! Restoring over an existing directory requires `force`, and that flag means
15//! *overwrite*, not *wipe*: files already present that the pack does not carry
16//! survive the restore. Nothing is deleted on the operator's behalf.
17
18use 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/// Inputs for [`restore`].
27#[derive(Debug, Clone)]
28pub struct RestoreOptions {
29    /// Archive to restore.
30    pub archive: PathBuf,
31    /// Directory to create and unpack into.
32    pub dest: PathBuf,
33    /// Unpack into `dest` even if it already exists.
34    ///
35    /// This overwrites, it does not wipe: entries in the pack replace their
36    /// counterparts in `dest`, and files already there that the pack does not
37    /// contain are left untouched. Restoring over a working copy therefore
38    /// recovers everything the pack holds without deleting anything it does
39    /// not know about — at the cost of leaving unrelated leftovers in place.
40    pub force: bool,
41    /// Predict the restore and report it, writing nothing.
42    ///
43    /// A restore has consequences a listing of the archive cannot show: which
44    /// files in the destination would be overwritten, which would survive
45    /// untouched, which symlinks would land dangling on *this* machine, and
46    /// which worktree pointers would be rewritten. A dry run answers those
47    /// before the first byte is written.
48    ///
49    /// An existing destination is not an error during a dry run — reporting
50    /// what would collide is precisely the point — so `force` is not required
51    /// to preview one.
52    pub dry_run: bool,
53}
54
55impl RestoreOptions {
56    /// Build options that refuse to touch an existing destination.
57    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/// What [`restore`] did — or, on a dry run, what it would do.
68#[derive(Debug, Clone)]
69pub struct RestoreReport {
70    /// Where the project was restored, or would be.
71    pub dest: PathBuf,
72    /// Manifest read from the archive.
73    pub manifest: Manifest,
74    /// Whether this was a prediction rather than a restore.
75    pub dry_run: bool,
76    /// Number of entries written, or that would be written.
77    pub entries_written: u64,
78    /// Whether the destination already exists.
79    ///
80    /// Only meaningful on a dry run; a real restore either refused or was
81    /// given `force`.
82    pub destination_exists: bool,
83    /// Existing files the restore would replace.
84    ///
85    /// Populated on a dry run only. Empty when the destination is new.
86    pub would_overwrite: Vec<String>,
87    /// Existing files the pack does not carry, which would survive the restore.
88    ///
89    /// This is the concrete form of "`--force` overwrites, it does not wipe":
90    /// everything listed here is still there afterwards. Populated on a dry
91    /// run only.
92    pub would_remain: Vec<String>,
93    /// Worktrees whose pointer files were rewritten for the new location.
94    pub rewritten_worktrees: Vec<String>,
95    /// Worktrees that were registered at pack time but live outside the root,
96    /// so their contents are not in the pack.
97    pub missing_worktrees: Vec<String>,
98    /// Restored symlinks whose targets do not exist on this machine.
99    pub dangling_symlinks: Vec<SymlinkRecord>,
100    /// `.claude/` link roots that are absent here, if any.
101    pub missing_claude_link_roots: Vec<String>,
102    /// Cache directories the pack deliberately dropped; regenerate as needed.
103    pub regenerable_caches: Vec<SkipRecord>,
104    /// Secrets the pack deliberately did not carry; move them out of band.
105    pub secrets_not_carried: Vec<SkipRecord>,
106}
107
108impl RestoreReport {
109    /// Whether anything needs operator attention after the restore.
110    ///
111    /// On a dry run this reads as "would need attention", and additionally
112    /// counts a destination that already holds files: proceeding there needs
113    /// `--force`, and whatever the pack does not carry stays behind.
114    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
124/// Restore a pack.
125///
126/// # Arguments
127///
128/// * `opts` — Archive path, destination, and whether to reuse an existing
129///   destination directory.
130///
131/// # Returns
132///
133/// A [`RestoreReport`] describing the repairs made and the follow-up the
134/// operator still owns.
135///
136/// # Errors
137///
138/// - [`PackError::DestinationExists`] if `dest` exists and `force` is unset.
139/// - [`PackError::UnsupportedFormat`] if the pack is newer than this build.
140/// - [`PackError::Io`] on read or write failure.
141pub 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
197/// Work out what a restore would do, touching nothing.
198///
199/// Everything here is derived from the archive plus the current state of the
200/// destination, so the prediction is about *this* machine — a symlink is
201/// reported as dangling because its target is absent here, not because it was
202/// absent where the pack was made.
203fn 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
264/// Split the destination's existing files into "would be replaced" and
265/// "would survive".
266///
267/// Directories are ignored on both sides: only file contents can be lost, and
268/// a directory that exists in both places is not a collision worth reporting.
269fn 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
303/// Whether a symlink from the archive would land dangling here.
304///
305/// The link does not exist yet, so its target is resolved as it *would* be.
306/// An absolute target is checked as written — it keeps pointing at the machine
307/// it named, which is exactly why moving a project can break it. A relative
308/// target is resolved inside the restored tree, and may well be satisfied by
309/// the pack itself: the target file does not exist on disk yet, but it is in
310/// the payload and will be there by the time the link is. Checking the
311/// filesystem alone would report every such link as dangling.
312fn 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        // The pack supplies it; it will exist alongside the link.
329        return false;
330    }
331
332    !dest.join(&resolved).exists()
333}
334
335/// Extract every `payload/` entry into `dest`, stripping the prefix.
336fn 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            // The manifest entry, and anything else outside the payload.
347            continue;
348        };
349        if rel.as_os_str().is_empty() {
350            continue;
351        }
352        // Refuse anything that would escape the destination. A pack is normally
353        // produced by this same crate, but an archive is an untrusted input the
354        // moment it arrives from elsewhere.
355        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        // Re-extracting over an existing link fails on some platforms; clear it.
368        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
378/// Point every included worktree at its new location.
379///
380/// Returns the names of the worktrees whose pointers were rewritten.
381fn 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            // The admin directory or the worktree itself did not make it into
392            // the payload; nothing to repair.
393            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
405/// Whether a restored symlink points at something that does not exist here.
406fn is_dangling(dest: &Path, record: &SymlinkRecord) -> bool {
407    let link = dest.join(&record.path);
408    if !link.is_symlink() {
409        // Not restored at all; not this check's concern.
410        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    /// A pack round-trips: every packed file comes back with its contents.
430    #[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    /// An existing destination is refused unless `force` is set.
462    #[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    /// Worktree pointers are rewritten to the new root, not left aimed at the
487    /// machine the pack came from.
488    #[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    /// A symlink whose target is gone is restored and reported, not hidden.
538    #[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        // The link target disappears before the restore.
554        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    /// A symlink whose target still exists is not reported as dangling.
566    #[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    // ------------------------------------------------------------------
585    // dry run
586    // ------------------------------------------------------------------
587
588    /// A dry run creates nothing at all, not even the destination directory.
589    #[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    /// An existing destination is previewable without `--force`, and the split
616    /// between "replaced" and "remains" is what makes the overwrite semantics
617    /// legible before committing to them.
618    #[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        // No force, and yet the preview succeeds — refusing here would defeat
632        // the purpose of asking what would happen.
633        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        // And the destination is untouched by the preview itself.
645        assert_eq!(
646            fs::read_to_string(dest.join("shared.txt")).expect("read"),
647            "old content"
648        );
649    }
650
651    /// The prediction matches what the real restore then does.
652    #[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    /// A dangling symlink is predicted before the link exists.
682    #[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        // The real restore then agrees.
709        let actual = restore(&RestoreOptions::new(&out, &dest)).expect("restore");
710        assert_eq!(actual.dangling_symlinks.len(), 1);
711    }
712
713    /// A relative link that stays inside the project is not predicted dangling.
714    #[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    /// Worktree pointer rewrites are announced ahead of time.
740    #[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    /// Skipped secrets and caches are carried into the report so the operator
771    /// learns what still needs doing.
772    #[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}