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;
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
26fn 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
36pub 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(×tamp);
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 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 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 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 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 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
179pub 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 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 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 ×tamps {
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
287pub 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 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 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 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 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}