use std::path::{Path, PathBuf};
use console::style;
use serde::{Deserialize, Serialize};
use crate::config;
use crate::constants::{
default_worktree_path, format_config_key, CONFIG_KEY_BASE_BRANCH, CONFIG_KEY_BASE_PATH,
};
use crate::error::{CwError, Result};
use crate::git;
use crate::messages;
#[derive(Debug, Serialize, Deserialize)]
struct BackupMetadata {
branch: String,
base_branch: Option<String>,
base_path: Option<String>,
worktree_path: String,
backed_up_at: String,
has_uncommitted_changes: bool,
bundle_file: String,
stash_file: Option<String>,
}
fn get_backups_dir() -> PathBuf {
let dir = config::get_config_path()
.parent()
.unwrap_or(Path::new("."))
.join("backups");
let _ = std::fs::create_dir_all(&dir);
dir
}
pub fn backup_worktree(branch: Option<&str>, all: bool) -> Result<()> {
let repo = git::get_repo_root(None)?;
let branches_to_backup: Vec<(String, PathBuf)> = if all {
git::get_feature_worktrees(Some(&repo))?
} else {
let resolved = super::helpers::resolve_worktree_target(branch, None)?;
vec![(resolved.branch, resolved.path)]
};
let backups_root = get_backups_dir();
let timestamp = crate::session::chrono_now_iso_pub()
.replace([':', '-'], "")
.split('T')
.collect::<Vec<_>>()
.join("-")
.trim_end_matches('Z')
.to_string();
println!("\n{}\n", style("Creating backup(s)...").cyan().bold());
let mut backup_count = 0;
for (branch_name, worktree_path) in &branches_to_backup {
let branch_backup_dir = backups_root.join(branch_name).join(×tamp);
let _ = std::fs::create_dir_all(&branch_backup_dir);
let bundle_file = branch_backup_dir.join("bundle.git");
let metadata_file = branch_backup_dir.join("metadata.json");
println!(
"{} {}",
style("Backing up:").yellow(),
style(branch_name).bold()
);
let bundle_str = bundle_file.to_string_lossy().to_string();
match git::git_command(
&["bundle", "create", &bundle_str, "--all"],
Some(worktree_path),
false,
true,
) {
Ok(r) if r.returncode == 0 => {}
_ => {
println!(" {} Backup failed for {}", style("x").red(), branch_name);
continue;
}
}
let base_branch_key = format_config_key(CONFIG_KEY_BASE_BRANCH, branch_name);
let base_path_key = format_config_key(CONFIG_KEY_BASE_PATH, branch_name);
let base_branch = git::get_config(&base_branch_key, Some(&repo));
let base_path = git::get_config(&base_path_key, Some(&repo));
let has_changes =
git::git_command(&["status", "--porcelain"], Some(worktree_path), false, true)
.map(|r| r.returncode == 0 && !r.stdout.trim().is_empty())
.unwrap_or(false);
let stash_file = if has_changes {
println!(
" {}",
style("Found uncommitted changes, creating stash...").dim()
);
let patch_file = branch_backup_dir.join("stash.patch");
if let Ok(r) = git::git_command(&["diff", "HEAD"], Some(worktree_path), false, true) {
if let Err(e) = std::fs::write(&patch_file, &r.stdout) {
println!(
" {} Failed to write stash patch: {}",
style("!").yellow(),
e
);
}
Some(patch_file.to_string_lossy().to_string())
} else {
None
}
} else {
None
};
let metadata = BackupMetadata {
branch: branch_name.clone(),
base_branch,
base_path,
worktree_path: worktree_path.to_string_lossy().to_string(),
backed_up_at: crate::session::chrono_now_iso_pub(),
has_uncommitted_changes: has_changes,
bundle_file: bundle_file.to_string_lossy().to_string(),
stash_file,
};
match serde_json::to_string_pretty(&metadata) {
Ok(content) => {
if let Err(e) = std::fs::write(&metadata_file, content) {
println!(
" {} Failed to write backup metadata: {}",
style("!").yellow(),
e
);
}
}
Err(e) => {
println!(
" {} Failed to serialize backup metadata: {}",
style("!").yellow(),
e
);
}
}
println!(
" {} Backup saved to: {}",
style("*").green(),
branch_backup_dir.display()
);
backup_count += 1;
}
println!(
"\n{}\n",
style(format!(
"* Backup complete! Created {} backup(s)",
backup_count
))
.green()
.bold()
);
println!(
"{}\n",
style(format!("Backups saved in: {}", backups_root.display())).dim()
);
Ok(())
}
pub fn list_backups(branch: Option<&str>, all: bool) -> Result<()> {
let backups_dir = get_backups_dir();
if !backups_dir.exists() {
println!("\n{}\n", style("No backups found").yellow());
return Ok(());
}
let current_repo = if !all {
crate::git::get_repo_root(None).ok()
} else {
None
};
println!("\n{}\n", style("Available Backups:").cyan().bold());
let mut found = false;
let mut entries: Vec<_> = std::fs::read_dir(&backups_dir)?
.flatten()
.filter(|e| e.path().is_dir())
.collect();
entries.sort_by_key(|e| e.file_name());
for branch_dir in entries {
let branch_name = branch_dir.file_name().to_string_lossy().to_string();
if let Some(filter) = branch {
if branch_name != filter {
continue;
}
}
if let Some(ref repo_root) = current_repo {
let matches_repo = std::fs::read_dir(branch_dir.path())
.ok()
.into_iter()
.flatten()
.flatten()
.any(|ts_dir| {
let metadata_file = ts_dir.path().join("metadata.json");
std::fs::read_to_string(&metadata_file)
.ok()
.and_then(|c| serde_json::from_str::<BackupMetadata>(&c).ok())
.map(|m| {
m.base_path
.as_deref()
.map(std::path::Path::new)
.map(|p| p == repo_root)
.unwrap_or(false)
})
.unwrap_or(false)
});
if !matches_repo {
continue;
}
}
let mut timestamps: Vec<_> = std::fs::read_dir(branch_dir.path())
.ok()
.into_iter()
.flatten()
.flatten()
.filter(|e| e.path().is_dir())
.collect();
timestamps.sort_by_key(|e| std::cmp::Reverse(e.file_name()));
if timestamps.is_empty() {
continue;
}
found = true;
println!("{}:", style(&branch_name).green().bold());
for ts_dir in ×tamps {
let metadata_file = ts_dir.path().join("metadata.json");
if let Ok(content) = std::fs::read_to_string(&metadata_file) {
if let Ok(meta) = serde_json::from_str::<BackupMetadata>(&content) {
let changes = if meta.has_uncommitted_changes {
format!(" {}", style("(with uncommitted changes)").yellow())
} else {
String::new()
};
println!(
" - {} - {}{}",
ts_dir.file_name().to_string_lossy(),
meta.backed_up_at,
changes
);
}
}
}
println!();
}
if !found {
println!("{}\n", style("No backups found").yellow());
}
Ok(())
}
pub fn restore_worktree(branch: &str, path: Option<&str>, id: Option<&str>) -> Result<()> {
let backups_dir = get_backups_dir();
let branch_backup_dir = backups_dir.join(branch);
if !branch_backup_dir.exists() {
return Err(CwError::Git(messages::backup_not_found(
id.unwrap_or("latest"),
branch,
)));
}
let backup_dir = if let Some(backup_id) = id {
let specific_dir = branch_backup_dir.join(backup_id);
if !specific_dir.exists() {
return Err(CwError::Git(messages::backup_not_found(backup_id, branch)));
}
specific_dir
} else {
let mut backups: Vec<_> = std::fs::read_dir(&branch_backup_dir)?
.flatten()
.filter(|e| e.path().is_dir())
.collect();
backups.sort_by_key(|e| std::cmp::Reverse(e.file_name()));
backups
.first()
.ok_or_else(|| CwError::Git(messages::backup_not_found("latest", branch)))?
.path()
};
let backup_id = backup_dir
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let metadata_file = backup_dir.join("metadata.json");
let bundle_file = backup_dir.join("bundle.git");
if !metadata_file.exists() || !bundle_file.exists() {
return Err(CwError::Git(
"Invalid backup: missing metadata or bundle file".to_string(),
));
}
let content = std::fs::read_to_string(&metadata_file)?;
let metadata: BackupMetadata = serde_json::from_str(&content)?;
println!("\n{}", style("Restoring from backup:").cyan().bold());
println!(" Branch: {}", style(branch).green());
println!(" Backup ID: {}", style(&backup_id).yellow());
println!(" Backed up at: {}\n", metadata.backed_up_at);
let repo = git::get_repo_root(None)?;
let worktree_path = if let Some(p) = path {
PathBuf::from(p)
} else {
default_worktree_path(&repo, branch)
};
if worktree_path.exists() {
return Err(CwError::Git(format!(
"Worktree path already exists: {}\nRemove it first or specify --path",
worktree_path.display()
)));
}
if let Some(parent) = worktree_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
println!(
"{} {}",
style("Restoring worktree to:").yellow(),
worktree_path.display()
);
let bundle_str = bundle_file.to_string_lossy().to_string();
let wt_str = worktree_path.to_string_lossy().to_string();
git::git_command(
&["clone", &bundle_str, &wt_str],
worktree_path.parent(),
true,
false,
)?;
let _ = git::git_command(&["checkout", branch], Some(&worktree_path), false, false);
if let Some(ref base_branch) = metadata.base_branch {
let bb_key = format_config_key(CONFIG_KEY_BASE_BRANCH, branch);
let bp_key = format_config_key(CONFIG_KEY_BASE_PATH, branch);
let _ = git::set_config(&bb_key, base_branch, Some(&repo));
let _ = git::set_config(&bp_key, &repo.to_string_lossy(), Some(&repo));
}
let stash_file = backup_dir.join("stash.patch");
if stash_file.exists() {
println!(" {}", style("Restoring uncommitted changes...").dim());
if let Ok(patch) = std::fs::read_to_string(&stash_file) {
let mut child = std::process::Command::new("git")
.args(["apply", "--whitespace=fix"])
.current_dir(&worktree_path)
.stdin(std::process::Stdio::piped())
.spawn()
.ok();
if let Some(ref mut c) = child {
if let Some(ref mut stdin) = c.stdin {
use std::io::Write;
let _ = stdin.write_all(patch.as_bytes());
}
let _ = c.wait();
}
}
}
println!("{} Restore complete!", style("*").green().bold());
println!(" Worktree path: {}\n", worktree_path.display());
Ok(())
}