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>) -> Result<()> {
181 let backups_dir = get_backups_dir();
182
183 if !backups_dir.exists() {
184 println!("\n{}\n", style("No backups found").yellow());
185 return Ok(());
186 }
187
188 println!("\n{}\n", style("Available Backups:").cyan().bold());
189
190 let mut found = false;
191
192 let mut entries: Vec<_> = std::fs::read_dir(&backups_dir)?
193 .flatten()
194 .filter(|e| e.path().is_dir())
195 .collect();
196 entries.sort_by_key(|e| e.file_name());
197
198 for branch_dir in entries {
199 let branch_name = branch_dir.file_name().to_string_lossy().to_string();
200
201 if let Some(filter) = branch {
202 if branch_name != filter {
203 continue;
204 }
205 }
206
207 let mut timestamps: Vec<_> = std::fs::read_dir(branch_dir.path())
208 .ok()
209 .into_iter()
210 .flatten()
211 .flatten()
212 .filter(|e| e.path().is_dir())
213 .collect();
214 timestamps.sort_by_key(|e| std::cmp::Reverse(e.file_name()));
215
216 if timestamps.is_empty() {
217 continue;
218 }
219
220 found = true;
221 println!("{}:", style(&branch_name).green().bold());
222
223 for ts_dir in ×tamps {
224 let metadata_file = ts_dir.path().join("metadata.json");
225 if let Ok(content) = std::fs::read_to_string(&metadata_file) {
226 if let Ok(meta) = serde_json::from_str::<BackupMetadata>(&content) {
227 let changes = if meta.has_uncommitted_changes {
228 format!(" {}", style("(with uncommitted changes)").yellow())
229 } else {
230 String::new()
231 };
232 println!(
233 " - {} - {}{}",
234 ts_dir.file_name().to_string_lossy(),
235 meta.backed_up_at,
236 changes
237 );
238 }
239 }
240 }
241 println!();
242 }
243
244 if !found {
245 println!("{}\n", style("No backups found").yellow());
246 }
247
248 Ok(())
249}
250
251pub fn restore_worktree(branch: &str, path: Option<&str>, id: Option<&str>) -> Result<()> {
253 let backups_dir = get_backups_dir();
254 let branch_backup_dir = backups_dir.join(branch);
255
256 if !branch_backup_dir.exists() {
257 return Err(CwError::Git(messages::backup_not_found(
258 id.unwrap_or("latest"),
259 branch,
260 )));
261 }
262
263 let backup_dir = if let Some(backup_id) = id {
265 let specific_dir = branch_backup_dir.join(backup_id);
266 if !specific_dir.exists() {
267 return Err(CwError::Git(messages::backup_not_found(backup_id, branch)));
268 }
269 specific_dir
270 } else {
271 let mut backups: Vec<_> = std::fs::read_dir(&branch_backup_dir)?
272 .flatten()
273 .filter(|e| e.path().is_dir())
274 .collect();
275 backups.sort_by_key(|e| std::cmp::Reverse(e.file_name()));
276
277 backups
278 .first()
279 .ok_or_else(|| CwError::Git(messages::backup_not_found("latest", branch)))?
280 .path()
281 };
282
283 let backup_id = backup_dir
284 .file_name()
285 .map(|n| n.to_string_lossy().to_string())
286 .unwrap_or_default();
287
288 let metadata_file = backup_dir.join("metadata.json");
289 let bundle_file = backup_dir.join("bundle.git");
290
291 if !metadata_file.exists() || !bundle_file.exists() {
292 return Err(CwError::Git(
293 "Invalid backup: missing metadata or bundle file".to_string(),
294 ));
295 }
296
297 let content = std::fs::read_to_string(&metadata_file)?;
298 let metadata: BackupMetadata = serde_json::from_str(&content)?;
299
300 println!("\n{}", style("Restoring from backup:").cyan().bold());
301 println!(" Branch: {}", style(branch).green());
302 println!(" Backup ID: {}", style(&backup_id).yellow());
303 println!(" Backed up at: {}\n", metadata.backed_up_at);
304
305 let repo = git::get_repo_root(None)?;
306
307 let worktree_path = if let Some(p) = path {
308 PathBuf::from(p)
309 } else {
310 default_worktree_path(&repo, branch)
311 };
312
313 if worktree_path.exists() {
314 return Err(CwError::Git(format!(
315 "Worktree path already exists: {}\nRemove it first or specify --path",
316 worktree_path.display()
317 )));
318 }
319
320 if let Some(parent) = worktree_path.parent() {
322 let _ = std::fs::create_dir_all(parent);
323 }
324
325 println!(
326 "{} {}",
327 style("Restoring worktree to:").yellow(),
328 worktree_path.display()
329 );
330
331 let bundle_str = bundle_file.to_string_lossy().to_string();
332 let wt_str = worktree_path.to_string_lossy().to_string();
333
334 git::git_command(
335 &["clone", &bundle_str, &wt_str],
336 worktree_path.parent(),
337 true,
338 false,
339 )?;
340
341 let _ = git::git_command(&["checkout", branch], Some(&worktree_path), false, false);
342
343 if let Some(ref base_branch) = metadata.base_branch {
345 let bb_key = format_config_key(CONFIG_KEY_BASE_BRANCH, branch);
346 let bp_key = format_config_key(CONFIG_KEY_BASE_PATH, branch);
347 let _ = git::set_config(&bb_key, base_branch, Some(&repo));
348 let _ = git::set_config(&bp_key, &repo.to_string_lossy(), Some(&repo));
349 }
350
351 let stash_file = backup_dir.join("stash.patch");
353 if stash_file.exists() {
354 println!(" {}", style("Restoring uncommitted changes...").dim());
355 if let Ok(patch) = std::fs::read_to_string(&stash_file) {
356 let mut child = std::process::Command::new("git")
357 .args(["apply", "--whitespace=fix"])
358 .current_dir(&worktree_path)
359 .stdin(std::process::Stdio::piped())
360 .spawn()
361 .ok();
362
363 if let Some(ref mut c) = child {
364 if let Some(ref mut stdin) = c.stdin {
365 use std::io::Write;
366 let _ = stdin.write_all(patch.as_bytes());
367 }
368 let _ = c.wait();
369 }
370 }
371 }
372
373 println!("{} Restore complete!", style("*").green().bold());
374 println!(" Worktree path: {}\n", worktree_path.display());
375
376 Ok(())
377}