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