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, 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
27fn 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
37pub 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 let branch_backup_dir = backups_root
74 .join(sanitize_branch_name(branch_name))
75 .join(×tamp);
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 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 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 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 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 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
196pub 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 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 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 ×tamps {
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
302pub 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 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 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 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 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}