use std::collections::BTreeSet;
use std::fs::File;
use std::path::{Component, Path, PathBuf};
use crate::create::PAYLOAD_PREFIX;
use crate::error::PackError;
use crate::manifest::{Manifest, SkipRecord, SymlinkRecord};
#[derive(Debug, Clone)]
pub struct RestoreOptions {
pub archive: PathBuf,
pub dest: PathBuf,
pub force: bool,
pub dry_run: bool,
}
impl RestoreOptions {
pub fn new(archive: impl Into<PathBuf>, dest: impl Into<PathBuf>) -> Self {
Self {
archive: archive.into(),
dest: dest.into(),
force: false,
dry_run: false,
}
}
}
#[derive(Debug, Clone)]
pub struct RestoreReport {
pub dest: PathBuf,
pub manifest: Manifest,
pub dry_run: bool,
pub entries_written: u64,
pub destination_exists: bool,
pub would_overwrite: Vec<String>,
pub would_remain: Vec<String>,
pub rewritten_worktrees: Vec<String>,
pub missing_worktrees: Vec<String>,
pub dangling_symlinks: Vec<SymlinkRecord>,
pub missing_claude_link_roots: Vec<String>,
pub regenerable_caches: Vec<SkipRecord>,
pub secrets_not_carried: Vec<SkipRecord>,
}
impl RestoreReport {
pub fn needs_attention(&self) -> bool {
!self.dangling_symlinks.is_empty()
|| !self.missing_claude_link_roots.is_empty()
|| !self.missing_worktrees.is_empty()
|| !self.secrets_not_carried.is_empty()
|| !self.would_overwrite.is_empty()
|| !self.would_remain.is_empty()
}
}
pub fn restore(opts: &RestoreOptions) -> Result<RestoreReport, PackError> {
let manifest = crate::inspect::verify(&opts.archive)?;
let destination_exists = opts.dest.exists();
if opts.dry_run {
return predict(opts, manifest, destination_exists);
}
if destination_exists && !opts.force {
return Err(PackError::DestinationExists(opts.dest.clone()));
}
std::fs::create_dir_all(&opts.dest)?;
let dest = std::fs::canonicalize(&opts.dest).unwrap_or_else(|_| opts.dest.clone());
let entries_written = unpack_payload(&opts.archive, &dest)?;
let rewritten_worktrees = rewrite_worktree_pointers(&dest, &manifest)?;
let missing_worktrees = manifest
.worktrees
.iter()
.filter(|w| !w.included)
.map(|w| w.name.clone())
.collect();
let dangling_symlinks = manifest
.symlinks
.iter()
.filter(|s| is_dangling(&dest, s))
.cloned()
.collect();
let missing_claude_link_roots = manifest
.claude
.link_roots
.iter()
.filter(|r| !Path::new(r).exists())
.cloned()
.collect();
Ok(RestoreReport {
dest,
dry_run: false,
entries_written,
destination_exists,
would_overwrite: Vec::new(),
would_remain: Vec::new(),
rewritten_worktrees,
missing_worktrees,
dangling_symlinks,
missing_claude_link_roots,
regenerable_caches: manifest.skipped_cache.clone(),
secrets_not_carried: manifest.skipped_secret.clone(),
manifest,
})
}
fn predict(
opts: &RestoreOptions,
manifest: Manifest,
destination_exists: bool,
) -> Result<RestoreReport, PackError> {
let dest = std::fs::canonicalize(&opts.dest).unwrap_or_else(|_| opts.dest.clone());
let payload = crate::inspect::list_payload_paths(&opts.archive)?;
let payload_set: BTreeSet<&str> = payload.iter().map(|s| s.as_str()).collect();
let (would_overwrite, would_remain) = if destination_exists {
compare_destination(&dest, &payload_set)
} else {
(Vec::new(), Vec::new())
};
let rewritten_worktrees = manifest
.worktrees
.iter()
.filter(|w| w.included)
.map(|w| w.name.clone())
.collect();
let missing_worktrees = manifest
.worktrees
.iter()
.filter(|w| !w.included)
.map(|w| w.name.clone())
.collect();
let dangling_symlinks = manifest
.symlinks
.iter()
.filter(|s| would_dangle(&dest, s, &payload_set))
.cloned()
.collect();
let missing_claude_link_roots = manifest
.claude
.link_roots
.iter()
.filter(|r| !Path::new(r).exists())
.cloned()
.collect();
Ok(RestoreReport {
dest,
dry_run: true,
entries_written: payload.len() as u64,
destination_exists,
would_overwrite,
would_remain,
rewritten_worktrees,
missing_worktrees,
dangling_symlinks,
missing_claude_link_roots,
regenerable_caches: manifest.skipped_cache.clone(),
secrets_not_carried: manifest.skipped_secret.clone(),
manifest,
})
}
fn compare_destination(dest: &Path, incoming: &BTreeSet<&str>) -> (Vec<String>, Vec<String>) {
let mut overwrite = Vec::new();
let mut remain = Vec::new();
let walker = walkdir::WalkDir::new(dest)
.follow_links(false)
.min_depth(1)
.sort_by_file_name();
for entry in walker.into_iter().filter_map(|e| e.ok()) {
if entry.file_type().is_dir() {
continue;
}
let Ok(rel) = entry.path().strip_prefix(dest) else {
continue;
};
let rel = rel
.components()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/");
if rel.is_empty() {
continue;
}
if incoming.contains(rel.as_str()) {
overwrite.push(rel);
} else {
remain.push(rel);
}
}
(overwrite, remain)
}
fn would_dangle(dest: &Path, record: &SymlinkRecord, payload: &BTreeSet<&str>) -> bool {
let target = Path::new(&record.target);
if target.is_absolute() {
return !target.exists();
}
let link_parent = Path::new(&record.path).parent().unwrap_or(Path::new(""));
let resolved = crate::scan::normalize(&link_parent.join(target));
let as_key = resolved
.components()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/");
if payload.contains(as_key.as_str()) {
return false;
}
!dest.join(&resolved).exists()
}
fn unpack_payload(archive: &Path, dest: &Path) -> Result<u64, PackError> {
let file = File::open(archive)?;
let decoder = zstd::stream::Decoder::new(file)?;
let mut tar = tar::Archive::new(decoder);
let mut written = 0u64;
for entry in tar.entries()? {
let mut entry = entry?;
let path = entry.path()?.to_path_buf();
let Ok(rel) = path.strip_prefix(PAYLOAD_PREFIX) else {
continue;
};
if rel.as_os_str().is_empty() {
continue;
}
if rel
.components()
.any(|c| matches!(c, Component::ParentDir | Component::RootDir))
{
tracing::warn!("skipping unsafe archive path: {}", rel.display());
continue;
}
let out = dest.join(rel);
if let Some(parent) = out.parent() {
std::fs::create_dir_all(parent)?;
}
if out.is_symlink() {
std::fs::remove_file(&out)?;
}
entry.unpack(&out)?;
written += 1;
}
Ok(written)
}
fn rewrite_worktree_pointers(dest: &Path, manifest: &Manifest) -> Result<Vec<String>, PackError> {
let mut rewritten = Vec::new();
for record in &manifest.worktrees {
let Some(rel) = record.path.as_deref() else {
continue;
};
let admin = dest.join(".git").join("worktrees").join(&record.name);
let worktree_root = dest.join(rel);
if !admin.is_dir() || !worktree_root.is_dir() {
continue;
}
let dot_git = worktree_root.join(".git");
std::fs::write(admin.join("gitdir"), format!("{}\n", dot_git.display()))?;
std::fs::write(&dot_git, format!("gitdir: {}\n", admin.display()))?;
rewritten.push(record.name.clone());
}
Ok(rewritten)
}
fn is_dangling(dest: &Path, record: &SymlinkRecord) -> bool {
let link = dest.join(&record.path);
if !link.is_symlink() {
return false;
}
!link.exists()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::create::{CreateOptions, create};
use std::fs;
use tempfile::TempDir;
fn touch(path: &Path, body: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("mkdir");
}
fs::write(path, body).expect("write");
}
#[test]
fn test_round_trip_preserves_content() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path().join("proj");
touch(&root.join("src/main.rs"), "fn main() {}");
touch(&root.join(".git/HEAD"), "ref: refs/heads/main\n");
touch(&root.join("workspace/journal.md"), "# journal\n");
touch(&root.join("workspace/.journal.db"), "sqlite");
let out = dir.path().join("proj.pack");
create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
let dest = dir.path().join("restored");
let report = restore(&RestoreOptions::new(&out, &dest)).expect("restore");
assert_eq!(
fs::read_to_string(dest.join("src/main.rs")).expect("read"),
"fn main() {}"
);
assert_eq!(
fs::read_to_string(dest.join(".git/HEAD")).expect("read"),
"ref: refs/heads/main\n"
);
assert_eq!(
fs::read_to_string(dest.join("workspace/.journal.db")).expect("read"),
"sqlite",
"local state must survive the round trip"
);
assert!(report.entries_written > 0);
}
#[test]
fn test_restore_refuses_existing_destination() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path().join("proj");
touch(&root.join("a.txt"), "a");
let out = dir.path().join("proj.pack");
create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
let dest = dir.path().join("existing");
fs::create_dir_all(&dest).expect("mkdir");
assert!(matches!(
restore(&RestoreOptions::new(&out, &dest)),
Err(PackError::DestinationExists(_))
));
let forced = RestoreOptions {
force: true,
..RestoreOptions::new(&out, &dest)
};
restore(&forced).expect("force should proceed");
assert!(dest.join("a.txt").is_file());
}
#[test]
fn test_restore_rewrites_worktree_pointers() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path().join("proj");
touch(&root.join(".git/HEAD"), "ref: refs/heads/main\n");
let wt = root.join(".worktrees/feature");
touch(&wt.join("file.txt"), "work");
let admin = root.join(".git/worktrees/feature");
fs::create_dir_all(&admin).expect("mkdir");
fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).expect("write");
fs::write(
admin.join("gitdir"),
format!("{}\n", wt.join(".git").display()),
)
.expect("write");
fs::write(admin.join("commondir"), "../..\n").expect("write");
let out = dir.path().join("proj.pack");
create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
let dest = dir.path().join("moved");
let report = restore(&RestoreOptions::new(&out, &dest)).expect("restore");
assert_eq!(report.rewritten_worktrees, vec!["feature".to_string()]);
let new_admin_gitdir =
fs::read_to_string(dest.join(".git/worktrees/feature/gitdir")).expect("read");
let new_dot_git = fs::read_to_string(dest.join(".worktrees/feature/.git")).expect("read");
let dest_real = fs::canonicalize(&dest).expect("canonicalize");
assert!(
new_admin_gitdir
.trim()
.starts_with(&dest_real.to_string_lossy().to_string()),
"gitdir must point into the new root, got {new_admin_gitdir}"
);
assert!(
new_dot_git
.trim()
.contains(&dest_real.to_string_lossy().to_string()),
"worktree .git must point into the new root, got {new_dot_git}"
);
assert!(
!new_admin_gitdir.contains("/proj/"),
"stale source path must not survive: {new_admin_gitdir}"
);
}
#[cfg(unix)]
#[test]
fn test_restore_reports_dangling_symlink() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path().join("proj");
fs::create_dir_all(&root).expect("mkdir");
let vanishing = dir.path().join("vanishing");
fs::create_dir_all(&vanishing).expect("mkdir");
touch(&vanishing.join("target.md"), "t");
std::os::unix::fs::symlink(vanishing.join("target.md"), root.join("link.md"))
.expect("symlink");
let out = dir.path().join("proj.pack");
create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
fs::remove_dir_all(&vanishing).expect("rm");
let dest = dir.path().join("restored");
let report = restore(&RestoreOptions::new(&out, &dest)).expect("restore");
assert!(dest.join("link.md").is_symlink(), "link itself is restored");
assert_eq!(report.dangling_symlinks.len(), 1);
assert_eq!(report.dangling_symlinks[0].path, "link.md");
assert!(report.needs_attention());
}
#[cfg(unix)]
#[test]
fn test_restore_does_not_report_live_symlink() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path().join("proj");
fs::create_dir_all(&root).expect("mkdir");
touch(&root.join("real.txt"), "r");
std::os::unix::fs::symlink("real.txt", root.join("rel-link")).expect("symlink");
let out = dir.path().join("proj.pack");
create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
let dest = dir.path().join("restored");
let report = restore(&RestoreOptions::new(&out, &dest)).expect("restore");
assert!(report.dangling_symlinks.is_empty());
}
#[test]
fn test_dry_run_writes_nothing() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path().join("proj");
touch(&root.join("a.txt"), "a");
let out = dir.path().join("proj.pack");
create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
let dest = dir.path().join("nowhere");
let opts = RestoreOptions {
dry_run: true,
..RestoreOptions::new(&out, &dest)
};
let report = restore(&opts).expect("dry run");
assert!(report.dry_run);
assert!(!dest.exists(), "dry run must not create the destination");
assert!(
report.entries_written > 0,
"it still counts what would land"
);
assert!(!report.destination_exists);
assert!(report.would_overwrite.is_empty());
assert!(report.would_remain.is_empty());
}
#[test]
fn test_dry_run_splits_existing_destination() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path().join("proj");
touch(&root.join("shared.txt"), "from pack");
touch(&root.join("only-in-pack.txt"), "new");
let out = dir.path().join("proj.pack");
create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
let dest = dir.path().join("existing");
touch(&dest.join("shared.txt"), "old content");
touch(&dest.join("only-in-dest.txt"), "leftover");
let opts = RestoreOptions {
dry_run: true,
..RestoreOptions::new(&out, &dest)
};
let report = restore(&opts).expect("dry run over existing dest");
assert!(report.destination_exists);
assert_eq!(report.would_overwrite, vec!["shared.txt".to_string()]);
assert_eq!(report.would_remain, vec!["only-in-dest.txt".to_string()]);
assert!(report.needs_attention());
assert_eq!(
fs::read_to_string(dest.join("shared.txt")).expect("read"),
"old content"
);
}
#[test]
fn test_dry_run_agrees_with_real_restore() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path().join("proj");
touch(&root.join("a.txt"), "a");
touch(&root.join("sub/b.txt"), "b");
let out = dir.path().join("proj.pack");
create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
let dest = dir.path().join("dest");
let predicted = restore(&RestoreOptions {
dry_run: true,
..RestoreOptions::new(&out, &dest)
})
.expect("dry run");
let actual = restore(&RestoreOptions::new(&out, &dest)).expect("real restore");
assert_eq!(
predicted.entries_written, actual.entries_written,
"a dry run that miscounts is worse than none"
);
assert_eq!(predicted.rewritten_worktrees, actual.rewritten_worktrees);
assert_eq!(
predicted.dangling_symlinks.len(),
actual.dangling_symlinks.len()
);
}
#[cfg(unix)]
#[test]
fn test_dry_run_predicts_dangling_symlink() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path().join("proj");
fs::create_dir_all(&root).expect("mkdir");
let vanishing = dir.path().join("vanishing");
fs::create_dir_all(&vanishing).expect("mkdir");
touch(&vanishing.join("t.md"), "t");
std::os::unix::fs::symlink(vanishing.join("t.md"), root.join("link.md")).expect("symlink");
let out = dir.path().join("proj.pack");
create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
fs::remove_dir_all(&vanishing).expect("rm");
let dest = dir.path().join("dest");
let predicted = restore(&RestoreOptions {
dry_run: true,
..RestoreOptions::new(&out, &dest)
})
.expect("dry run");
assert_eq!(predicted.dangling_symlinks.len(), 1);
assert_eq!(predicted.dangling_symlinks[0].path, "link.md");
assert!(!dest.exists(), "still nothing written");
let actual = restore(&RestoreOptions::new(&out, &dest)).expect("restore");
assert_eq!(actual.dangling_symlinks.len(), 1);
}
#[cfg(unix)]
#[test]
fn test_dry_run_does_not_predict_live_relative_link() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path().join("proj");
fs::create_dir_all(&root).expect("mkdir");
touch(&root.join("real.txt"), "r");
std::os::unix::fs::symlink("real.txt", root.join("rel-link")).expect("symlink");
let out = dir.path().join("proj.pack");
create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
let dest = dir.path().join("dest");
let predicted = restore(&RestoreOptions {
dry_run: true,
..RestoreOptions::new(&out, &dest)
})
.expect("dry run");
assert!(
predicted.dangling_symlinks.is_empty(),
"a link resolving inside the restored tree is fine"
);
}
#[test]
fn test_dry_run_announces_worktree_rewrite() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path().join("proj");
touch(&root.join(".git/HEAD"), "ref: refs/heads/main\n");
let wt = root.join(".worktrees/feature");
touch(&wt.join("f.txt"), "w");
let admin = root.join(".git/worktrees/feature");
fs::create_dir_all(&admin).expect("mkdir");
fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).expect("write");
fs::write(
admin.join("gitdir"),
format!("{}\n", wt.join(".git").display()),
)
.expect("write");
let out = dir.path().join("proj.pack");
create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
let dest = dir.path().join("dest");
let predicted = restore(&RestoreOptions {
dry_run: true,
..RestoreOptions::new(&out, &dest)
})
.expect("dry run");
assert_eq!(predicted.rewritten_worktrees, vec!["feature".to_string()]);
assert!(!dest.exists());
}
#[test]
fn test_restore_report_carries_skips() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path().join("proj");
touch(&root.join("a.txt"), "a");
touch(&root.join(".env"), "S=1");
touch(&root.join("target/x"), "bin");
let out = dir.path().join("proj.pack");
create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
let dest = dir.path().join("restored");
let report = restore(&RestoreOptions::new(&out, &dest)).expect("restore");
assert!(report.secrets_not_carried.iter().any(|s| s.path == ".env"));
assert!(report.regenerable_caches.iter().any(|s| s.path == "target"));
assert!(!dest.join(".env").exists());
assert!(report.needs_attention());
}
}