Skip to main content

git_worktree_manager/operations/
backup.rs

1use std::path::{Path, PathBuf};
2
3use console::style;
4use serde::{Deserialize, Serialize};
5
6use crate::config;
7use crate::constants::{
8    default_worktree_path, format_config_key, sanitize_branch_name, CONFIG_KEY_BASE_BRANCH,
9    CONFIG_KEY_BASE_PATH,
10};
11use crate::error::{CwError, Result};
12use crate::git;
13use crate::messages;
14
15#[derive(Debug, Serialize, Deserialize)]
16struct BackupMetadata {
17    branch: String,
18    base_branch: Option<String>,
19    base_path: Option<String>,
20    worktree_path: String,
21    backed_up_at: String,
22    has_uncommitted_changes: bool,
23    bundle_file: String,
24    stash_file: Option<String>,
25}
26
27/// Get the backups directory.
28fn get_backups_dir() -> PathBuf {
29    let dir = config::get_config_path()
30        .parent()
31        .unwrap_or(Path::new("."))
32        .join("backups");
33    let _ = std::fs::create_dir_all(&dir);
34    dir
35}
36
37/// Create backup of worktree(s) using git bundle.
38pub fn backup_worktree(branch: Option<&str>, all: bool) -> Result<()> {
39    let repo = git::get_repo_root(None)?;
40
41    let branches_to_backup: Vec<(String, PathBuf)> = if all {
42        git::get_feature_worktrees(Some(&repo))?
43    } else {
44        let resolved = super::helpers::resolve_worktree_target(branch, None)?;
45        vec![(resolved.branch, resolved.path)]
46    };
47
48    let backups_root = get_backups_dir();
49    let timestamp = crate::session::chrono_now_iso_pub()
50        .replace([':', '-'], "")
51        .split('T')
52        .collect::<Vec<_>>()
53        .join("-")
54        .trim_end_matches('Z')
55        .to_string();
56
57    println!("\n{}\n", style("Creating backup(s)...").cyan().bold());
58
59    let mut backup_count = 0;
60
61    for (branch_name, worktree_path) in &branches_to_backup {
62        // Flatten slashes and other special chars so branches like "feat/A"
63        // don't create nested dirs that break read_dir iteration in list_backups.
64        let branch_backup_dir = backups_root
65            .join(sanitize_branch_name(branch_name))
66            .join(&timestamp);
67        let _ = std::fs::create_dir_all(&branch_backup_dir);
68
69        let bundle_file = branch_backup_dir.join("bundle.git");
70        let metadata_file = branch_backup_dir.join("metadata.json");
71
72        println!(
73            "{} {}",
74            style("Backing up:").yellow(),
75            style(branch_name).bold()
76        );
77
78        // Create git bundle
79        let bundle_str = bundle_file.to_string_lossy().to_string();
80        match git::git_command(
81            &["bundle", "create", &bundle_str, "--all"],
82            Some(worktree_path),
83            false,
84            true,
85        ) {
86            Ok(r) if r.returncode == 0 => {}
87            _ => {
88                println!("  {} Backup failed for {}", style("x").red(), branch_name);
89                continue;
90            }
91        }
92
93        // Get metadata. Fall back to the repo root when per-branch config is
94        // absent (e.g. backing up the main worktree) so list_backups can still
95        // attribute the backup to the current repo.
96        let base_branch_key = format_config_key(CONFIG_KEY_BASE_BRANCH, branch_name);
97        let base_path_key = format_config_key(CONFIG_KEY_BASE_PATH, branch_name);
98        let base_branch = git::get_config(&base_branch_key, Some(&repo));
99        let base_path = git::get_config(&base_path_key, Some(&repo))
100            .or_else(|| Some(repo.to_string_lossy().to_string()));
101
102        // Check for uncommitted changes
103        let has_changes =
104            git::git_command(&["status", "--porcelain"], Some(worktree_path), false, true)
105                .map(|r| r.returncode == 0 && !r.stdout.trim().is_empty())
106                .unwrap_or(false);
107
108        // Create stash patch for uncommitted changes
109        let stash_file = if has_changes {
110            println!(
111                "  {}",
112                style("Found uncommitted changes, creating stash...").dim()
113            );
114            let patch_file = branch_backup_dir.join("stash.patch");
115            if let Ok(r) = git::git_command(&["diff", "HEAD"], Some(worktree_path), false, true) {
116                if let Err(e) = std::fs::write(&patch_file, &r.stdout) {
117                    println!(
118                        "  {} Failed to write stash patch: {}",
119                        style("!").yellow(),
120                        e
121                    );
122                }
123                Some(patch_file.to_string_lossy().to_string())
124            } else {
125                None
126            }
127        } else {
128            None
129        };
130
131        // Save metadata
132        let metadata = BackupMetadata {
133            branch: branch_name.clone(),
134            base_branch,
135            base_path,
136            worktree_path: worktree_path.to_string_lossy().to_string(),
137            backed_up_at: crate::session::chrono_now_iso_pub(),
138            has_uncommitted_changes: has_changes,
139            bundle_file: bundle_file.to_string_lossy().to_string(),
140            stash_file,
141        };
142
143        match serde_json::to_string_pretty(&metadata) {
144            Ok(content) => {
145                if let Err(e) = std::fs::write(&metadata_file, content) {
146                    println!(
147                        "  {} Failed to write backup metadata: {}",
148                        style("!").yellow(),
149                        e
150                    );
151                }
152            }
153            Err(e) => {
154                println!(
155                    "  {} Failed to serialize backup metadata: {}",
156                    style("!").yellow(),
157                    e
158                );
159            }
160        }
161
162        println!(
163            "  {} Backup saved to: {}",
164            style("*").green(),
165            branch_backup_dir.display()
166        );
167        backup_count += 1;
168    }
169
170    println!(
171        "\n{}\n",
172        style(format!(
173            "* Backup complete! Created {} backup(s)",
174            backup_count
175        ))
176        .green()
177        .bold()
178    );
179    println!(
180        "{}\n",
181        style(format!("Backups saved in: {}", backups_root.display())).dim()
182    );
183
184    Ok(())
185}
186
187/// List available backups.
188///
189/// By default, only shows backups whose metadata matches the current repository.
190/// Use `--all` to show backups from all repositories.
191pub fn list_backups(branch: Option<&str>, all: bool) -> Result<()> {
192    let backups_dir = get_backups_dir();
193
194    if !backups_dir.exists() {
195        println!("\n{}\n", style("No backups found").yellow());
196        return Ok(());
197    }
198
199    // Get current repo path for filtering
200    let current_repo = if !all {
201        crate::git::get_repo_root(None).ok()
202    } else {
203        None
204    };
205
206    println!("\n{}\n", style("Available Backups:").cyan().bold());
207
208    let mut found = false;
209
210    let mut entries: Vec<_> = std::fs::read_dir(&backups_dir)?
211        .flatten()
212        .filter(|e| e.path().is_dir())
213        .collect();
214    entries.sort_by_key(|e| e.file_name());
215
216    for branch_dir in entries {
217        let mut timestamps: Vec<_> = std::fs::read_dir(branch_dir.path())
218            .ok()
219            .into_iter()
220            .flatten()
221            .flatten()
222            .filter(|e| e.path().is_dir())
223            .collect();
224        timestamps.sort_by_key(|e| std::cmp::Reverse(e.file_name()));
225
226        if timestamps.is_empty() {
227            continue;
228        }
229
230        // Prefer the original branch name from metadata over the sanitized
231        // directory name so `feat/A` shows up as "feat/A" rather than "feat-A".
232        let display_branch = timestamps
233            .iter()
234            .find_map(|ts_dir| {
235                std::fs::read_to_string(ts_dir.path().join("metadata.json"))
236                    .ok()
237                    .and_then(|c| serde_json::from_str::<BackupMetadata>(&c).ok())
238                    .map(|m| m.branch)
239            })
240            .unwrap_or_else(|| branch_dir.file_name().to_string_lossy().to_string());
241
242        if let Some(filter) = branch {
243            if display_branch != filter {
244                continue;
245            }
246        }
247
248        if let Some(ref repo_root) = current_repo {
249            let matches_repo = timestamps.iter().any(|ts_dir| {
250                std::fs::read_to_string(ts_dir.path().join("metadata.json"))
251                    .ok()
252                    .and_then(|c| serde_json::from_str::<BackupMetadata>(&c).ok())
253                    .and_then(|m| m.base_path)
254                    .map(|p| std::path::Path::new(&p) == repo_root)
255                    .unwrap_or(false)
256            });
257            if !matches_repo {
258                continue;
259            }
260        }
261
262        found = true;
263        println!("{}:", style(&display_branch).green().bold());
264
265        for ts_dir in &timestamps {
266            let metadata_file = ts_dir.path().join("metadata.json");
267            if let Ok(content) = std::fs::read_to_string(&metadata_file) {
268                if let Ok(meta) = serde_json::from_str::<BackupMetadata>(&content) {
269                    let changes = if meta.has_uncommitted_changes {
270                        format!(" {}", style("(with uncommitted changes)").yellow())
271                    } else {
272                        String::new()
273                    };
274                    println!(
275                        "  - {} - {}{}",
276                        ts_dir.file_name().to_string_lossy(),
277                        meta.backed_up_at,
278                        changes
279                    );
280                }
281            }
282        }
283        println!();
284    }
285
286    if !found {
287        println!("{}\n", style("No backups found").yellow());
288    }
289
290    Ok(())
291}
292
293/// Restore worktree from backup.
294pub fn restore_worktree(branch: &str, path: Option<&str>, id: Option<&str>) -> Result<()> {
295    let backups_dir = get_backups_dir();
296    let branch_backup_dir = backups_dir.join(sanitize_branch_name(branch));
297
298    if !branch_backup_dir.exists() {
299        return Err(CwError::Git(messages::backup_not_found(
300            id.unwrap_or("latest"),
301            branch,
302        )));
303    }
304
305    // Find backup by ID or use latest
306    let backup_dir = if let Some(backup_id) = id {
307        let specific_dir = branch_backup_dir.join(backup_id);
308        if !specific_dir.exists() {
309            return Err(CwError::Git(messages::backup_not_found(backup_id, branch)));
310        }
311        specific_dir
312    } else {
313        let mut backups: Vec<_> = std::fs::read_dir(&branch_backup_dir)?
314            .flatten()
315            .filter(|e| e.path().is_dir())
316            .collect();
317        backups.sort_by_key(|e| std::cmp::Reverse(e.file_name()));
318
319        backups
320            .first()
321            .ok_or_else(|| CwError::Git(messages::backup_not_found("latest", branch)))?
322            .path()
323    };
324
325    let backup_id = backup_dir
326        .file_name()
327        .map(|n| n.to_string_lossy().to_string())
328        .unwrap_or_default();
329
330    let metadata_file = backup_dir.join("metadata.json");
331    let bundle_file = backup_dir.join("bundle.git");
332
333    if !metadata_file.exists() || !bundle_file.exists() {
334        return Err(CwError::Git(
335            "Invalid backup: missing metadata or bundle file".to_string(),
336        ));
337    }
338
339    let content = std::fs::read_to_string(&metadata_file)?;
340    let metadata: BackupMetadata = serde_json::from_str(&content)?;
341
342    println!("\n{}", style("Restoring from backup:").cyan().bold());
343    println!("  Branch: {}", style(branch).green());
344    println!("  Backup ID: {}", style(&backup_id).yellow());
345    println!("  Backed up at: {}\n", metadata.backed_up_at);
346
347    let repo = git::get_repo_root(None)?;
348
349    let worktree_path = if let Some(p) = path {
350        PathBuf::from(p)
351    } else {
352        default_worktree_path(&repo, branch)
353    };
354
355    if worktree_path.exists() {
356        return Err(CwError::Git(format!(
357            "Worktree path already exists: {}\nRemove it first or specify --path",
358            worktree_path.display()
359        )));
360    }
361
362    // Clone from bundle
363    if let Some(parent) = worktree_path.parent() {
364        let _ = std::fs::create_dir_all(parent);
365    }
366
367    println!(
368        "{} {}",
369        style("Restoring worktree to:").yellow(),
370        worktree_path.display()
371    );
372
373    let bundle_str = bundle_file.to_string_lossy().to_string();
374    let wt_str = worktree_path.to_string_lossy().to_string();
375
376    git::git_command(
377        &["clone", &bundle_str, &wt_str],
378        worktree_path.parent(),
379        true,
380        false,
381    )?;
382
383    let _ = git::git_command(&["checkout", branch], Some(&worktree_path), false, false);
384
385    // Restore metadata
386    if let Some(ref base_branch) = metadata.base_branch {
387        let bb_key = format_config_key(CONFIG_KEY_BASE_BRANCH, branch);
388        let bp_key = format_config_key(CONFIG_KEY_BASE_PATH, branch);
389        let _ = git::set_config(&bb_key, base_branch, Some(&repo));
390        let _ = git::set_config(&bp_key, &repo.to_string_lossy(), Some(&repo));
391    }
392
393    // Restore uncommitted changes
394    let stash_file = backup_dir.join("stash.patch");
395    if stash_file.exists() {
396        println!("  {}", style("Restoring uncommitted changes...").dim());
397        if let Ok(patch) = std::fs::read_to_string(&stash_file) {
398            let mut child = std::process::Command::new("git")
399                .args(["apply", "--whitespace=fix"])
400                .current_dir(&worktree_path)
401                .stdin(std::process::Stdio::piped())
402                .spawn()
403                .ok();
404
405            if let Some(ref mut c) = child {
406                if let Some(ref mut stdin) = c.stdin {
407                    use std::io::Write;
408                    let _ = stdin.write_all(patch.as_bytes());
409                }
410                let _ = c.wait();
411            }
412        }
413    }
414
415    println!("{} Restore complete!", style("*").green().bold());
416    println!("  Worktree path: {}\n", worktree_path.display());
417
418    Ok(())
419}