use anyhow::{Context, Result, bail};
use colored::Colorize;
use flate2::read::GzDecoder;
use std::fs;
use std::path::{Path, PathBuf};
use crate::bundle;
use crate::git;
use crate::manifest::{self, ArchiveFormat, Manifest};
use crate::util::{archives_dir, format_size};
pub fn run(name: Option<String>, verify_only: bool) -> Result<()> {
let dir = archives_dir();
if !dir.exists() {
println!("{}", "No archives directory found.".yellow());
return Ok(());
}
if let Some(n) = name {
if verify_only {
return verify_archive(&dir, &n);
}
return restore_one(&dir, &n);
}
list(&dir)
}
fn list(dir: &Path) -> Result<()> {
let mut entries: Vec<(String, Manifest)> = Vec::new();
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().is_some_and(|e| e == "json") {
if let Ok(m) = manifest::read(&path) {
let stem = path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
entries.push((stem, m));
}
}
}
if entries.is_empty() {
println!("{}", "No archives found.".yellow());
return Ok(());
}
entries.sort_by(|a, b| a.1.archived_at.cmp(&b.1.archived_at));
println!("{}", "Archives".bold().underline());
for (stem, m) in &entries {
let format_tag = match m.format {
ArchiveFormat::Bundle => "bundle".green(),
ArchiveFormat::Tarball => "tar".yellow(),
};
let verified = if m.verified_at.is_some() {
"verified".green()
} else {
"unverified".red()
};
println!(
" {} [{}] [{}]",
stem.cyan().bold(),
format_tag,
verified
);
println!(
" from {} ({} refs, {} commits)",
m.original_path.dimmed(),
m.refs.len(),
m.commit_count
);
println!(
" archived {} original {} {}",
m.archived_at.dimmed(),
format_size(m.size_bytes),
m.head_sha
.as_deref()
.map(|s| s.chars().take(10).collect::<String>())
.unwrap_or_default()
.dimmed()
);
}
println!();
println!(
"Use {} to restore, {} to verify integrity.",
"ward restore <name>".bold(),
"ward restore <name> --verify".bold()
);
Ok(())
}
fn archive_paths(dir: &Path, name: &str) -> (PathBuf, PathBuf, PathBuf) {
let stem = name
.strip_suffix(".json")
.unwrap_or(name)
.strip_suffix(".bundle")
.unwrap_or(name)
.strip_suffix(".tar.gz")
.unwrap_or(name)
.to_string();
let bundle = dir.join(format!("{stem}.bundle"));
let tar = dir.join(format!("{stem}.tar.gz"));
let manifest = dir.join(format!("{stem}.json"));
let extras = dir.join(format!("{stem}.extras.tar.gz"));
let legacy = dir.join(format!("{stem}.untracked.tar.gz"));
let extras_file = if extras.exists() { extras } else { legacy };
if bundle.exists() {
(bundle, extras_file, manifest)
} else {
(tar, extras_file, manifest)
}
}
fn verify_archive(dir: &Path, name: &str) -> Result<()> {
let (primary, untracked, manifest_path) = archive_paths(dir, name);
if !manifest_path.exists() {
bail!("Manifest not found: {}", manifest_path.display());
}
if !primary.exists() {
bail!("Archive not found: {}", primary.display());
}
let m = manifest::read(&manifest_path)?;
println!("Verifying {} ...", primary.display());
let computed = bundle::sha256_file(&primary)?;
let expected = match m.format {
ArchiveFormat::Bundle => m.bundle_sha256.as_deref(),
ArchiveFormat::Tarball => m.bundle_sha256.as_deref(),
};
match expected {
Some(exp) if exp == computed => {
println!(" {} archive sha256", "ok".green().bold());
}
Some(exp) => {
bail!("sha256 mismatch. expected {exp}, got {computed}");
}
None => {
println!(" {} no expected hash in manifest", "warn".yellow().bold());
}
}
if m.format == ArchiveFormat::Bundle {
bundle::verify(&primary).context("git bundle verify")?;
println!(" {} git bundle verify", "ok".green().bold());
}
if m.has_extras {
if untracked.exists() {
let u_sha = bundle::sha256_file(&untracked)?;
if let Some(exp) = &m.extras_sha256 {
if exp == &u_sha {
println!(" {} extras tar sha256", "ok".green().bold());
} else {
bail!("extras tar sha256 mismatch");
}
}
} else {
println!(" {} extras tar missing", "warn".yellow().bold());
}
}
if m.stash_count > 0 {
println!(
" {} {} stash(es) preserved as refs",
"ok".green().bold(),
m.stash_count
);
}
if m.has_hooks {
println!(" {} hooks preserved in extras", "ok".green().bold());
}
if m.has_config {
println!(" {} per-repo config preserved in extras", "ok".green().bold());
}
if m.has_config_worktree {
println!(" {} worktree config preserved in extras", "ok".green().bold());
}
println!(
"{}",
"Archive refs, history, and local state are restorable.".green().bold()
);
Ok(())
}
fn restore_one(dir: &Path, name: &str) -> Result<()> {
let (primary, untracked, manifest_path) = archive_paths(dir, name);
if !manifest_path.exists() {
bail!("Manifest not found: {}", manifest_path.display());
}
if !primary.exists() {
bail!("Archive not found: {}", primary.display());
}
let m = manifest::read(&manifest_path)?;
let target = PathBuf::from(&m.original_path);
if target.exists() {
bail!(
"Target path already exists: {}. Remove it first.",
target.display()
);
}
let expected_hash = m.bundle_sha256.as_deref();
if let Some(exp) = expected_hash {
let got = bundle::sha256_file(&primary)?;
if exp != got {
bail!("archive sha256 mismatch, refusing to restore");
}
}
println!(
"Restoring {} to {} ...",
primary.display(),
target.display()
);
match m.format {
ArchiveFormat::Bundle => {
bundle::verify(&primary)?;
bundle::restore_clone(&primary, &target)?;
configure_remotes(&target, &m)?;
if m.stash_count > 0 {
bundle::fetch_custom_refs(&primary, &target, "refs/ward-stash/*")?;
let restored = git::restore_stash_refs(&target)?;
if restored > 0 {
println!(" restored {} stash(es)", restored);
}
}
if m.has_extras && untracked.exists() {
extract_extras(&untracked, &target)?;
}
}
ArchiveFormat::Tarball => {
let parent = target
.parent()
.context("could not determine parent dir")?;
fs::create_dir_all(parent)?;
let file = fs::File::open(&primary)?;
let decoder = GzDecoder::new(file);
let mut archive = tar::Archive::new(decoder);
archive.unpack(parent)?;
}
}
println!("{} restored to {}", "ok".green().bold(), target.display());
Ok(())
}
fn configure_remotes(repo: &Path, m: &Manifest) -> Result<()> {
let _ = std::process::Command::new("git")
.args(["remote", "remove", "origin"])
.current_dir(repo)
.output();
for r in &m.remotes {
let _ = std::process::Command::new("git")
.args(["remote", "add", &r.name, &r.url])
.current_dir(repo)
.output();
}
Ok(())
}
fn extract_extras(tar_path: &Path, target: &Path) -> Result<()> {
let file = fs::File::open(tar_path)?;
let decoder = GzDecoder::new(file);
let mut archive = tar::Archive::new(decoder);
archive.unpack(target)?;
let ward_extras = target.join(".ward-extras");
if ward_extras.is_dir() {
let config_src = ward_extras.join("config");
if config_src.is_file() {
let config_dst = target.join(".git/config");
let _ = fs::copy(&config_src, &config_dst);
}
let wt_config_src = ward_extras.join("config.worktree");
if wt_config_src.is_file() {
let wt_config_dst = target.join(".git/config.worktree");
let _ = fs::copy(&wt_config_src, &wt_config_dst);
}
let hooks_src = ward_extras.join("hooks");
if hooks_src.is_dir() {
let hooks_dst = target.join(".git/hooks");
fs::create_dir_all(&hooks_dst)?;
if let Ok(entries) = fs::read_dir(&hooks_src) {
for entry in entries.flatten() {
let src = entry.path();
let dst = hooks_dst.join(entry.file_name());
let _ = fs::copy(&src, &dst);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ =
fs::set_permissions(&dst, fs::Permissions::from_mode(0o755));
}
}
}
}
let _ = fs::remove_dir_all(&ward_extras);
}
Ok(())
}