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