use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use std::collections::HashMap;
use std::fs;
use std::io::BufRead;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::config::Config;
#[derive(Debug, Clone)]
pub struct BackupInfo {
pub path: PathBuf,
#[allow(dead_code)]
repo_name: String,
pub timestamp: DateTime<Utc>,
pub branch_count: usize,
}
impl BackupInfo {
fn from_path(path: PathBuf, repo_name: &str) -> Result<Self> {
let file = fs::File::open(&path)
.with_context(|| format!("Failed to open backup file: {}", path.display()))?;
let reader = std::io::BufReader::new(file);
let mut timestamp: Option<DateTime<Utc>> = None;
let mut branch_count = 0;
for line in reader.lines() {
let line = line?;
if line.starts_with("# Created:") {
if let Some(date_str) = line.strip_prefix("# Created:") {
let date_str = date_str.trim();
if let Ok(dt) = DateTime::parse_from_rfc3339(date_str) {
timestamp = Some(dt.with_timezone(&Utc));
}
}
}
if line.starts_with("git branch") {
branch_count += 1;
}
}
let timestamp = timestamp
.unwrap_or_else(|| parse_timestamp_from_filename(&path).unwrap_or_else(Utc::now));
Ok(BackupInfo {
path,
repo_name: repo_name.to_string(),
timestamp,
branch_count,
})
}
pub fn format_age(&self) -> String {
let now = Utc::now();
let duration = now.signed_duration_since(self.timestamp);
let days = duration.num_days();
let hours = duration.num_hours();
let minutes = duration.num_minutes();
if days > 0 {
format!("{} {} ago", days, if days == 1 { "day" } else { "days" })
} else if hours > 0 {
format!(
"{} {} ago",
hours,
if hours == 1 { "hour" } else { "hours" }
)
} else if minutes > 0 {
format!(
"{} {} ago",
minutes,
if minutes == 1 { "minute" } else { "minutes" }
)
} else {
"just now".to_string()
}
}
pub fn filename(&self) -> String {
self.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string()
}
}
fn parse_timestamp_from_filename(path: &Path) -> Option<DateTime<Utc>> {
let filename = path.file_stem()?.to_str()?;
let timestamp_part = filename.strip_prefix("backup-")?;
let parts: Vec<&str> = timestamp_part.split('-').collect();
if parts.len() != 2 {
return None;
}
let date_str = parts[0]; let time_str = parts[1];
if date_str.len() != 8 || time_str.len() != 6 {
return None;
}
let year: i32 = date_str[0..4].parse().ok()?;
let month: u32 = date_str[4..6].parse().ok()?;
let day: u32 = date_str[6..8].parse().ok()?;
let hour: u32 = time_str[0..2].parse().ok()?;
let min: u32 = time_str[2..4].parse().ok()?;
let sec: u32 = time_str[4..6].parse().ok()?;
chrono::NaiveDate::from_ymd_opt(year, month, day)
.and_then(|date| date.and_hms_opt(hour, min, sec))
.map(|naive| DateTime::from_naive_utc_and_offset(naive, Utc))
}
pub fn list_all_backups() -> Result<HashMap<String, Vec<BackupInfo>>> {
let backups_dir = Config::backups_dir()?;
let mut result: HashMap<String, Vec<BackupInfo>> = HashMap::new();
if !backups_dir.exists() {
return Ok(result);
}
let entries = fs::read_dir(&backups_dir).with_context(|| {
format!(
"Failed to read backups directory: {}",
backups_dir.display()
)
})?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let repo_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
let backups = list_repo_backups(&repo_name)?;
if !backups.is_empty() {
result.insert(repo_name, backups);
}
}
Ok(result)
}
pub fn list_repo_backups(repo_name: &str) -> Result<Vec<BackupInfo>> {
let repo_backup_dir = Config::repo_backup_dir(repo_name)?;
let mut backups = Vec::new();
if !repo_backup_dir.exists() {
return Ok(backups);
}
let entries = fs::read_dir(&repo_backup_dir).with_context(|| {
format!(
"Failed to read backup directory: {}",
repo_backup_dir.display()
)
})?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if !filename.starts_with("backup-") || !filename.ends_with(".txt") {
continue;
}
match BackupInfo::from_path(path, repo_name) {
Ok(info) => backups.push(info),
Err(e) => {
eprintln!("Warning: Could not parse backup file: {}", e);
}
}
}
backups.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
Ok(backups)
}
#[derive(Debug, Clone)]
pub struct BackupBranchEntry {
pub name: String,
pub commit_sha: String,
}
#[derive(Debug, Clone)]
pub struct SkippedLine {
pub line_number: usize,
pub content: String,
}
#[derive(Debug)]
pub struct ParsedBackup {
pub entries: Vec<BackupBranchEntry>,
pub skipped_lines: Vec<SkippedLine>,
}
#[derive(Debug)]
pub struct RestoreResult {
pub original_name: String,
pub restored_name: String,
pub commit_sha: String,
pub overwrote_existing: bool,
}
#[derive(Debug)]
pub enum RestoreError {
BranchExists { branch_name: String },
CommitNotFound {
branch_name: String,
commit_sha: String,
},
BranchNotInBackup {
branch_name: String,
available_branches: Vec<BackupBranchEntry>,
skipped_lines: Vec<SkippedLine>,
},
NoBackupsFound { repo_name: String },
BackupCorrupted { message: String },
Other(anyhow::Error),
}
impl std::fmt::Display for RestoreError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RestoreError::BranchExists { branch_name } => {
write!(f, "Branch '{}' already exists", branch_name)
}
RestoreError::CommitNotFound {
branch_name,
commit_sha,
} => {
write!(
f,
"Cannot restore '{}': commit {} no longer exists",
branch_name, commit_sha
)
}
RestoreError::BranchNotInBackup { branch_name, .. } => {
write!(f, "Branch '{}' not found in backup", branch_name)
}
RestoreError::NoBackupsFound { repo_name } => {
write!(f, "No backups found for repository '{}'", repo_name)
}
RestoreError::BackupCorrupted { message } => {
write!(f, "Backup file is corrupted: {}", message)
}
RestoreError::Other(e) => write!(f, "{}", e),
}
}
}
impl std::error::Error for RestoreError {}
#[derive(Debug)]
pub struct CleanResult {
pub deleted_count: usize,
pub bytes_freed: u64,
}
#[derive(Debug, Clone)]
pub struct BackupToDelete {
pub info: BackupInfo,
pub size_bytes: u64,
}
impl BackupToDelete {
pub fn format_size(&self) -> String {
format_bytes(self.size_bytes)
}
}
pub fn format_bytes(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = 1024 * KB;
if bytes >= MB {
format!("{:.1} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.1} KB", bytes as f64 / KB as f64)
} else {
format!("{} B", bytes)
}
}
#[derive(Debug)]
pub struct RepoStats {
pub repo_name: String,
pub backup_count: usize,
pub total_bytes: u64,
}
#[derive(Debug)]
pub struct BackupStats {
pub repos: Vec<RepoStats>,
pub backups_dir: PathBuf,
}
impl BackupStats {
pub fn total_backups(&self) -> usize {
self.repos.iter().map(|r| r.backup_count).sum()
}
pub fn total_bytes(&self) -> u64 {
self.repos.iter().map(|r| r.total_bytes).sum()
}
}
pub fn get_backup_stats() -> Result<BackupStats> {
let backups_dir = Config::backups_dir()?;
let all_backups = list_all_backups()?;
let mut repos: Vec<RepoStats> = all_backups
.into_iter()
.map(|(repo_name, backups)| {
let total_bytes: u64 = backups
.iter()
.map(|b| fs::metadata(&b.path).map(|m| m.len()).unwrap_or(0))
.sum();
RepoStats {
repo_name,
backup_count: backups.len(),
total_bytes,
}
})
.collect();
repos.sort_by(|a, b| a.repo_name.cmp(&b.repo_name));
Ok(BackupStats { repos, backups_dir })
}
pub fn get_backups_to_clean(repo_name: &str, keep: usize) -> Result<Vec<BackupToDelete>> {
let backups = list_repo_backups(repo_name)?;
if backups.len() <= keep {
return Ok(Vec::new());
}
let to_delete: Vec<BackupToDelete> = backups
.into_iter()
.skip(keep)
.map(|info| {
let size_bytes = fs::metadata(&info.path).map(|m| m.len()).unwrap_or(0);
BackupToDelete { info, size_bytes }
})
.collect();
Ok(to_delete)
}
pub fn delete_backups(backups: &[BackupToDelete]) -> Result<CleanResult> {
let mut deleted_count = 0;
let mut bytes_freed = 0;
for backup in backups {
fs::remove_file(&backup.info.path).with_context(|| {
format!(
"Failed to delete backup file: {}",
backup.info.path.display()
)
})?;
deleted_count += 1;
bytes_freed += backup.size_bytes;
}
Ok(CleanResult {
deleted_count,
bytes_freed,
})
}
pub fn parse_backup_file(path: &Path) -> Result<ParsedBackup, RestoreError> {
let file = fs::File::open(path).map_err(|e| RestoreError::Other(e.into()))?;
let reader = std::io::BufReader::new(file);
let mut entries = Vec::new();
let mut skipped_lines = Vec::new();
let mut found_header = false;
for (line_num, line) in reader.lines().enumerate() {
let line = line.map_err(|e| RestoreError::Other(e.into()))?;
if line_num == 0 {
if !line.starts_with("# deadbranch backup") {
return Err(RestoreError::BackupCorrupted {
message: format!(
"Invalid header at line 1. Expected '# deadbranch backup', found: '{}'",
line
),
});
}
found_header = true;
continue;
}
if line.starts_with('#') || line.trim().is_empty() {
continue;
}
if line.starts_with("git branch ") {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 4 {
entries.push(BackupBranchEntry {
name: parts[2].to_string(),
commit_sha: parts[3].to_string(),
});
} else {
skipped_lines.push(SkippedLine {
line_number: line_num + 1,
content: line,
});
}
} else {
skipped_lines.push(SkippedLine {
line_number: line_num + 1,
content: line,
});
}
}
if !found_header {
return Err(RestoreError::BackupCorrupted {
message: "Empty or invalid backup file".to_string(),
});
}
Ok(ParsedBackup {
entries,
skipped_lines,
})
}
pub fn restore_branch(
branch_name: &str,
backup_file: Option<&str>,
target_name: Option<&str>,
force: bool,
) -> Result<RestoreResult, RestoreError> {
let repo_name = Config::get_repo_name();
let final_branch_name = target_name.unwrap_or(branch_name);
let branch_exists = check_branch_exists(final_branch_name);
if branch_exists && !force {
return Err(RestoreError::BranchExists {
branch_name: final_branch_name.to_string(),
});
}
let backup_path = if let Some(filename) = backup_file {
let path = PathBuf::from(filename);
if path.is_absolute() || path.exists() {
path
} else {
let backup_dir = Config::repo_backup_dir(&repo_name).map_err(RestoreError::Other)?;
backup_dir.join(filename)
}
} else {
let backups = list_repo_backups(&repo_name).map_err(RestoreError::Other)?;
backups
.into_iter()
.next()
.map(|info| info.path)
.ok_or_else(|| RestoreError::NoBackupsFound {
repo_name: repo_name.clone(),
})?
};
let parsed = parse_backup_file(&backup_path)?;
let entry = parsed
.entries
.iter()
.find(|e| e.name == branch_name)
.ok_or_else(|| RestoreError::BranchNotInBackup {
branch_name: branch_name.to_string(),
available_branches: parsed.entries.clone(),
skipped_lines: parsed.skipped_lines.clone(),
})?;
if !commit_exists(&entry.commit_sha) {
return Err(RestoreError::CommitNotFound {
branch_name: branch_name.to_string(),
commit_sha: entry.commit_sha.clone(),
});
}
create_branch(final_branch_name, &entry.commit_sha, force).map_err(RestoreError::Other)?;
Ok(RestoreResult {
original_name: branch_name.to_string(),
restored_name: final_branch_name.to_string(),
commit_sha: entry.commit_sha.clone(),
overwrote_existing: branch_exists && force,
})
}
fn check_branch_exists(branch_name: &str) -> bool {
Command::new("git")
.args([
"rev-parse",
"--verify",
&format!("refs/heads/{}", branch_name),
])
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
fn commit_exists(sha: &str) -> bool {
Command::new("git")
.args(["cat-file", "-t", sha])
.output()
.map(|output| {
output.status.success() && String::from_utf8_lossy(&output.stdout).trim() == "commit"
})
.unwrap_or(false)
}
fn create_branch(branch_name: &str, commit_sha: &str, force: bool) -> Result<()> {
let mut args = vec!["branch"];
if force {
args.push("-f");
}
args.push(branch_name);
args.push(commit_sha);
let output = Command::new("git")
.args(&args)
.output()
.context("Failed to run git branch command")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!(
"Failed to create branch '{}': {}",
branch_name,
stderr.trim()
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
fn create_test_backup(dir: &std::path::Path, filename: &str, content: &str) -> PathBuf {
let path = dir.join(filename);
let mut file = fs::File::create(&path).unwrap();
file.write_all(content.as_bytes()).unwrap();
path
}
#[test]
fn test_parse_timestamp_from_filename() {
let path = PathBuf::from("/some/path/backup-20260201-143022.txt");
let ts = parse_timestamp_from_filename(&path).unwrap();
assert_eq!(
ts.format("%Y-%m-%d %H:%M:%S").to_string(),
"2026-02-01 14:30:22"
);
}
#[test]
fn test_parse_timestamp_invalid_filename() {
let path = PathBuf::from("/some/path/not-a-backup.txt");
assert!(parse_timestamp_from_filename(&path).is_none());
let path = PathBuf::from("/some/path/backup-invalid.txt");
assert!(parse_timestamp_from_filename(&path).is_none());
}
#[test]
fn test_backup_info_from_path() {
let temp_dir = TempDir::new().unwrap();
let content = r#"# deadbranch backup
# Created: 2026-02-01T14:30:22Z
# Repository: test-repo
# feature/old-api
git branch feature/old-api a1b2c3d4
# bugfix/login
git branch bugfix/login e5f6g7h8
"#;
let path = create_test_backup(temp_dir.path(), "backup-20260201-143022.txt", content);
let info = BackupInfo::from_path(path, "test-repo").unwrap();
assert_eq!(info.repo_name, "test-repo");
assert_eq!(info.branch_count, 2);
assert_eq!(info.timestamp.format("%Y-%m-%d").to_string(), "2026-02-01");
}
#[test]
fn test_backup_info_format_age() {
let info = BackupInfo {
path: PathBuf::from("/test"),
repo_name: "test".to_string(),
timestamp: Utc::now() - chrono::Duration::hours(2),
branch_count: 5,
};
let age = info.format_age();
assert!(age.contains("hour"));
}
#[test]
fn test_backup_info_filename() {
let info = BackupInfo {
path: PathBuf::from("/some/long/path/backup-20260201-143022.txt"),
repo_name: "test".to_string(),
timestamp: Utc::now(),
branch_count: 5,
};
assert_eq!(info.filename(), "backup-20260201-143022.txt");
}
}