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, CONFIG_KEY_BASE_BRANCH, CONFIG_KEY_BASE_PATH,
9};
10use crate::error::{CwError, Result};
11use crate::git;
12
13#[derive(Debug, Serialize, Deserialize)]
14struct BackupMetadata {
15    branch: String,
16    base_branch: Option<String>,
17    base_path: Option<String>,
18    worktree_path: String,
19    backed_up_at: String,
20    has_uncommitted_changes: bool,
21    bundle_file: String,
22    stash_file: Option<String>,
23}
24
25/// Get the backups directory.
26fn get_backups_dir() -> PathBuf {
27    let dir = config::get_config_path()
28        .parent()
29        .unwrap_or(Path::new("."))
30        .join("backups");
31    let _ = std::fs::create_dir_all(&dir);
32    dir
33}
34
35/// Create backup of worktree(s) using git bundle.
36pub fn backup_worktree(branch: Option<&str>, all: bool) -> Result<()> {
37    let repo = git::get_repo_root(None)?;
38
39    let branches_to_backup: Vec<(String, PathBuf)> = if all {
40        git::get_feature_worktrees(Some(&repo))?
41    } else {
42        let (path, branch_name, _) = super::helpers::resolve_worktree_target(branch, None)?;
43        vec![(branch_name, path)]
44    };
45
46    let backups_root = get_backups_dir();
47    let timestamp = crate::session::chrono_now_iso_pub()
48        .replace([':', '-'], "")
49        .split('T')
50        .collect::<Vec<_>>()
51        .join("-")
52        .trim_end_matches('Z')
53        .to_string();
54
55    println!("\n{}\n", style("Creating backup(s)...").cyan().bold());
56
57    let mut backup_count = 0;
58
59    for (branch_name, worktree_path) in &branches_to_backup {
60        let branch_backup_dir = backups_root.join(branch_name).join(&timestamp);
61        let _ = std::fs::create_dir_all(&branch_backup_dir);
62
63        let bundle_file = branch_backup_dir.join("bundle.git");
64        let metadata_file = branch_backup_dir.join("metadata.json");
65
66        println!(
67            "{} {}",
68            style("Backing up:").yellow(),
69            style(branch_name).bold()
70        );
71
72        // Create git bundle
73        let bundle_str = bundle_file.to_string_lossy().to_string();
74        match git::git_command(
75            &["bundle", "create", &bundle_str, "--all"],
76            Some(worktree_path),
77            false,
78            true,
79        ) {
80            Ok(r) if r.returncode == 0 => {}
81            _ => {
82                println!("  {} Backup failed for {}", style("x").red(), branch_name);
83                continue;
84            }
85        }
86
87        // Get metadata
88        let base_branch_key = format_config_key(CONFIG_KEY_BASE_BRANCH, branch_name);
89        let base_path_key = format_config_key(CONFIG_KEY_BASE_PATH, branch_name);
90        let base_branch = git::get_config(&base_branch_key, Some(&repo));
91        let base_path = git::get_config(&base_path_key, Some(&repo));
92
93        // Check for uncommitted changes
94        let has_changes =
95            git::git_command(&["status", "--porcelain"], Some(worktree_path), false, true)
96                .map(|r| r.returncode == 0 && !r.stdout.trim().is_empty())
97                .unwrap_or(false);
98
99        // Create stash patch for uncommitted changes
100        let stash_file = if has_changes {
101            println!(
102                "  {}",
103                style("Found uncommitted changes, creating stash...").dim()
104            );
105            let patch_file = branch_backup_dir.join("stash.patch");
106            if let Ok(r) = git::git_command(&["diff", "HEAD"], Some(worktree_path), false, true) {
107                let _ = std::fs::write(&patch_file, &r.stdout);
108                Some(patch_file.to_string_lossy().to_string())
109            } else {
110                None
111            }
112        } else {
113            None
114        };
115
116        // Save metadata
117        let metadata = BackupMetadata {
118            branch: branch_name.clone(),
119            base_branch,
120            base_path,
121            worktree_path: worktree_path.to_string_lossy().to_string(),
122            backed_up_at: crate::session::chrono_now_iso_pub(),
123            has_uncommitted_changes: has_changes,
124            bundle_file: bundle_file.to_string_lossy().to_string(),
125            stash_file,
126        };
127
128        if let Ok(content) = serde_json::to_string_pretty(&metadata) {
129            let _ = std::fs::write(&metadata_file, content);
130        }
131
132        println!(
133            "  {} Backup saved to: {}",
134            style("*").green(),
135            branch_backup_dir.display()
136        );
137        backup_count += 1;
138    }
139
140    println!(
141        "\n{}\n",
142        style(format!(
143            "* Backup complete! Created {} backup(s)",
144            backup_count
145        ))
146        .green()
147        .bold()
148    );
149    println!(
150        "{}\n",
151        style(format!("Backups saved in: {}", backups_root.display())).dim()
152    );
153
154    Ok(())
155}
156
157/// List available backups.
158pub fn list_backups(branch: Option<&str>) -> Result<()> {
159    let backups_dir = get_backups_dir();
160
161    if !backups_dir.exists() {
162        println!("\n{}\n", style("No backups found").yellow());
163        return Ok(());
164    }
165
166    println!("\n{}\n", style("Available Backups:").cyan().bold());
167
168    let mut found = false;
169
170    let mut entries: Vec<_> = std::fs::read_dir(&backups_dir)?
171        .flatten()
172        .filter(|e| e.path().is_dir())
173        .collect();
174    entries.sort_by_key(|e| e.file_name());
175
176    for branch_dir in entries {
177        let branch_name = branch_dir.file_name().to_string_lossy().to_string();
178
179        if let Some(filter) = branch {
180            if branch_name != filter {
181                continue;
182            }
183        }
184
185        let mut timestamps: Vec<_> = std::fs::read_dir(branch_dir.path())
186            .ok()
187            .into_iter()
188            .flatten()
189            .flatten()
190            .filter(|e| e.path().is_dir())
191            .collect();
192        timestamps.sort_by_key(|e| std::cmp::Reverse(e.file_name()));
193
194        if timestamps.is_empty() {
195            continue;
196        }
197
198        found = true;
199        println!("{}:", style(&branch_name).green().bold());
200
201        for ts_dir in &timestamps {
202            let metadata_file = ts_dir.path().join("metadata.json");
203            if let Ok(content) = std::fs::read_to_string(&metadata_file) {
204                if let Ok(meta) = serde_json::from_str::<BackupMetadata>(&content) {
205                    let changes = if meta.has_uncommitted_changes {
206                        format!(" {}", style("(with uncommitted changes)").yellow())
207                    } else {
208                        String::new()
209                    };
210                    println!(
211                        "  - {} - {}{}",
212                        ts_dir.file_name().to_string_lossy(),
213                        meta.backed_up_at,
214                        changes
215                    );
216                }
217            }
218        }
219        println!();
220    }
221
222    if !found {
223        println!("{}\n", style("No backups found").yellow());
224    }
225
226    Ok(())
227}
228
229/// Restore worktree from backup.
230pub fn restore_worktree(branch: &str, path: Option<&str>) -> Result<()> {
231    let backups_dir = get_backups_dir();
232    let branch_backup_dir = backups_dir.join(branch);
233
234    if !branch_backup_dir.exists() {
235        return Err(CwError::Git(format!(
236            "No backups found for branch '{}'",
237            branch
238        )));
239    }
240
241    // Use latest backup
242    let mut backups: Vec<_> = std::fs::read_dir(&branch_backup_dir)?
243        .flatten()
244        .filter(|e| e.path().is_dir())
245        .collect();
246    backups.sort_by_key(|e| std::cmp::Reverse(e.file_name()));
247
248    let backup_dir = backups
249        .first()
250        .ok_or_else(|| CwError::Git(format!("No backups found for branch '{}'", branch)))?
251        .path();
252
253    let backup_id = backup_dir
254        .file_name()
255        .map(|n| n.to_string_lossy().to_string())
256        .unwrap_or_default();
257
258    let metadata_file = backup_dir.join("metadata.json");
259    let bundle_file = backup_dir.join("bundle.git");
260
261    if !metadata_file.exists() || !bundle_file.exists() {
262        return Err(CwError::Git(
263            "Invalid backup: missing metadata or bundle file".to_string(),
264        ));
265    }
266
267    let content = std::fs::read_to_string(&metadata_file)?;
268    let metadata: BackupMetadata = serde_json::from_str(&content)?;
269
270    println!("\n{}", style("Restoring from backup:").cyan().bold());
271    println!("  Branch: {}", style(branch).green());
272    println!("  Backup ID: {}", style(&backup_id).yellow());
273    println!("  Backed up at: {}\n", metadata.backed_up_at);
274
275    let repo = git::get_repo_root(None)?;
276
277    let worktree_path = if let Some(p) = path {
278        PathBuf::from(p)
279    } else {
280        default_worktree_path(&repo, branch)
281    };
282
283    if worktree_path.exists() {
284        return Err(CwError::Git(format!(
285            "Worktree path already exists: {}\nRemove it first or specify --path",
286            worktree_path.display()
287        )));
288    }
289
290    // Clone from bundle
291    if let Some(parent) = worktree_path.parent() {
292        let _ = std::fs::create_dir_all(parent);
293    }
294
295    println!(
296        "{} {}",
297        style("Restoring worktree to:").yellow(),
298        worktree_path.display()
299    );
300
301    let bundle_str = bundle_file.to_string_lossy().to_string();
302    let wt_str = worktree_path.to_string_lossy().to_string();
303
304    git::git_command(
305        &["clone", &bundle_str, &wt_str],
306        worktree_path.parent(),
307        true,
308        false,
309    )?;
310
311    let _ = git::git_command(&["checkout", branch], Some(&worktree_path), false, false);
312
313    // Restore metadata
314    if let Some(ref base_branch) = metadata.base_branch {
315        let bb_key = format_config_key(CONFIG_KEY_BASE_BRANCH, branch);
316        let bp_key = format_config_key(CONFIG_KEY_BASE_PATH, branch);
317        let _ = git::set_config(&bb_key, base_branch, Some(&repo));
318        let _ = git::set_config(&bp_key, &repo.to_string_lossy(), Some(&repo));
319    }
320
321    // Restore uncommitted changes
322    let stash_file = backup_dir.join("stash.patch");
323    if stash_file.exists() {
324        println!("  {}", style("Restoring uncommitted changes...").dim());
325        if let Ok(patch) = std::fs::read_to_string(&stash_file) {
326            let mut child = std::process::Command::new("git")
327                .args(["apply", "--whitespace=fix"])
328                .current_dir(&worktree_path)
329                .stdin(std::process::Stdio::piped())
330                .spawn()
331                .ok();
332
333            if let Some(ref mut c) = child {
334                if let Some(ref mut stdin) = c.stdin {
335                    use std::io::Write;
336                    let _ = stdin.write_all(patch.as_bytes());
337                }
338                let _ = c.wait();
339            }
340        }
341    }
342
343    println!("{} Restore complete!", style("*").green().bold());
344    println!("  Worktree path: {}\n", worktree_path.display());
345
346    Ok(())
347}