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