git_worktree_manager/operations/
backup.rs1use 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
25fn 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
35pub 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(×tamp);
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 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 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 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 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 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
157pub 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 ×tamps {
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
229pub 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 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 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 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 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}