1use crate::core::git::*;
2use crate::core::safety::Safety;
3use crate::core::traits::*;
4use crate::{GitXError, Result};
5
6pub struct StashCommands;
8
9impl StashCommands {
10 pub fn create_branch(branch_name: String, stash_ref: Option<String>) -> Result<String> {
12 StashCommand::new(StashBranchAction::Create {
13 branch_name,
14 stash_ref,
15 })
16 .execute()
17 }
18
19 pub fn clean(older_than: Option<String>, dry_run: bool) -> Result<String> {
21 StashCommand::new(StashBranchAction::Clean {
22 older_than,
23 dry_run,
24 })
25 .execute()
26 }
27
28 pub fn apply_by_branch(branch_name: String, list_only: bool) -> Result<String> {
30 StashCommand::new(StashBranchAction::ApplyByBranch {
31 branch_name,
32 list_only,
33 })
34 .execute()
35 }
36
37 pub fn interactive() -> Result<String> {
39 StashCommand::new(StashBranchAction::Interactive).execute()
40 }
41
42 pub fn export(output_dir: String, stash_ref: Option<String>) -> Result<String> {
44 StashCommand::new(StashBranchAction::Export {
45 output_dir,
46 stash_ref,
47 })
48 .execute()
49 }
50}
51
52#[derive(Debug, Clone)]
54pub enum StashBranchAction {
55 Create {
56 branch_name: String,
57 stash_ref: Option<String>,
58 },
59 Clean {
60 older_than: Option<String>,
61 dry_run: bool,
62 },
63 ApplyByBranch {
64 branch_name: String,
65 list_only: bool,
66 },
67 Interactive,
68 Export {
69 output_dir: String,
70 stash_ref: Option<String>,
71 },
72}
73
74#[derive(Debug, Clone)]
76pub struct StashInfo {
77 pub name: String,
78 pub message: String,
79 pub branch: String,
80 pub timestamp: String,
81}
82
83pub struct StashCommand {
85 action: StashBranchAction,
86}
87
88impl StashCommand {
89 pub fn new(action: StashBranchAction) -> Self {
90 Self { action }
91 }
92
93 fn execute_action(&self) -> Result<String> {
94 match &self.action {
95 StashBranchAction::Create {
96 branch_name,
97 stash_ref,
98 } => self.create_branch_from_stash(branch_name, stash_ref),
99 StashBranchAction::Clean {
100 older_than,
101 dry_run,
102 } => self.clean_old_stashes(older_than, *dry_run),
103 StashBranchAction::ApplyByBranch {
104 branch_name,
105 list_only,
106 } => self.apply_stashes_by_branch(branch_name, *list_only),
107 StashBranchAction::Interactive => self.interactive_stash_management(),
108 StashBranchAction::Export {
109 output_dir,
110 stash_ref,
111 } => self.export_stashes_to_patches(output_dir, stash_ref),
112 }
113 }
114
115 fn create_branch_from_stash(
116 &self,
117 branch_name: &str,
118 stash_ref: &Option<String>,
119 ) -> Result<String> {
120 self.validate_branch_name(branch_name)?;
122
123 if BranchOperations::exists(branch_name).unwrap_or(false) {
125 return Err(GitXError::GitCommand(format!(
126 "Branch '{branch_name}' already exists"
127 )));
128 }
129
130 let stash = stash_ref.clone().unwrap_or_else(|| "stash@{0}".to_string());
132
133 self.validate_stash_exists(&stash)?;
135
136 GitOperations::run_status(&["stash", "branch", branch_name, &stash])?;
138
139 Ok(format!(
140 "โ
Created branch '{branch_name}' from stash '{stash}'"
141 ))
142 }
143
144 fn clean_old_stashes(&self, older_than: &Option<String>, dry_run: bool) -> Result<String> {
145 let stashes = self.get_stash_list_with_dates()?;
147
148 if stashes.is_empty() {
149 return Ok("โน๏ธ No stashes found".to_string());
150 }
151
152 let stashes_to_clean = if let Some(age) = older_than {
154 self.filter_stashes_by_age(&stashes, age)?
155 } else {
156 stashes
157 };
158
159 if stashes_to_clean.is_empty() {
160 return Ok("โ
No old stashes to clean".to_string());
161 }
162
163 let count = stashes_to_clean.len();
164 let mut result = if dry_run {
165 format!("๐งช (dry run) Would clean {count} stash(es):\n")
166 } else {
167 format!("๐งน Cleaning {count} stash(es):\n")
168 };
169
170 for stash in &stashes_to_clean {
171 result.push_str(&format!(" {}: {}\n", stash.name, stash.message));
172 }
173
174 if !dry_run {
175 let stash_names: Vec<_> = stashes_to_clean.iter().map(|s| s.name.as_str()).collect();
177 let details = format!(
178 "This will delete {} stashes: {}",
179 stashes_to_clean.len(),
180 stash_names.join(", ")
181 );
182
183 let confirmed = Safety::confirm_destructive_operation("Clean old stashes", &details)?;
184 if !confirmed {
185 return Ok("Operation cancelled by user.".to_string());
186 }
187
188 let mut deleted_count = 0;
189 for stash in &stashes_to_clean {
190 match self.delete_stash(&stash.name) {
191 Ok(()) => deleted_count += 1,
192 Err(e) => {
193 result.push_str(&format!("โ Failed to delete {}: {}\n", stash.name, e));
194 }
195 }
196 }
197 result.push_str(&format!("โ
Successfully deleted {deleted_count} stashes"));
198 }
199
200 Ok(result)
201 }
202
203 fn apply_stashes_by_branch(&self, branch_name: &str, list_only: bool) -> Result<String> {
204 let stashes = self.get_stash_list_with_branches()?;
206
207 let branch_stashes: Vec<_> = stashes
209 .into_iter()
210 .filter(|s| s.branch == branch_name)
211 .collect();
212
213 if branch_stashes.is_empty() {
214 return Ok(format!("No stashes found for branch '{branch_name}'"));
215 }
216
217 let count = branch_stashes.len();
218 let mut result = if list_only {
219 format!("๐ Found {count} stash(es) for branch '{branch_name}':\n")
220 } else {
221 format!("๐ Applying {count} stash(es) from branch '{branch_name}':\n")
222 };
223
224 for stash in &branch_stashes {
225 if list_only {
226 result.push_str(&format!(" {}: {}\n", stash.name, stash.message));
227 } else {
228 match self.apply_stash(&stash.name) {
229 Ok(()) => result.push_str(&format!(" โ
Applied {}\n", stash.name)),
230 Err(e) => {
231 result.push_str(&format!(" โ Failed to apply {}: {}\n", stash.name, e))
232 }
233 }
234 }
235 }
236
237 Ok(result)
238 }
239
240 fn interactive_stash_management(&self) -> Result<String> {
241 use dialoguer::{MultiSelect, Select, theme::ColorfulTheme};
242
243 let stashes = self.get_stash_list_with_branches()?;
245
246 if stashes.is_empty() {
247 return Ok("๐ No stashes found".to_string());
248 }
249
250 let stash_display: Vec<String> = stashes
252 .iter()
253 .map(|s| format!("{}: {} (from {})", s.name, s.message, s.branch))
254 .collect();
255
256 let actions = vec![
258 "Apply selected stash",
259 "Delete selected stashes",
260 "Create branch from stash",
261 "Show stash diff",
262 "List all stashes",
263 "Exit",
264 ];
265
266 let theme = ColorfulTheme::default();
267 let action_selection = Select::with_theme(&theme)
268 .with_prompt("๐ What would you like to do?")
269 .items(&actions)
270 .default(0)
271 .interact();
272
273 match action_selection {
274 Ok(0) => {
275 let selection = Select::with_theme(&theme)
277 .with_prompt("๐ฏ Select stash to apply")
278 .items(&stash_display)
279 .interact()?;
280
281 self.apply_stash(&stashes[selection].name)?;
282 Ok(format!("โ
Applied stash: {}", stashes[selection].name))
283 }
284 Ok(1) => {
285 let selections = MultiSelect::with_theme(&theme)
287 .with_prompt(
288 "๐๏ธ Select stashes to delete (use Space to select, Enter to confirm)",
289 )
290 .items(&stash_display)
291 .interact()?;
292
293 if selections.is_empty() {
294 return Ok("No stashes selected for deletion".to_string());
295 }
296
297 let mut deleted_count = 0;
298 for &idx in selections.iter().rev() {
299 if self.delete_stash(&stashes[idx].name).is_ok() {
301 deleted_count += 1;
302 }
303 }
304
305 Ok(format!("โ
Deleted {deleted_count} stash(es)"))
306 }
307 Ok(2) => {
308 let selection = Select::with_theme(&theme)
310 .with_prompt("๐ฑ Select stash to create branch from")
311 .items(&stash_display)
312 .interact()?;
313
314 let branch_name = dialoguer::Input::<String>::with_theme(&theme)
315 .with_prompt("๐ฟ Enter new branch name")
316 .interact()?;
317
318 self.validate_branch_name(&branch_name)?;
319
320 GitOperations::run_status(&[
321 "stash",
322 "branch",
323 &branch_name,
324 &stashes[selection].name,
325 ])?;
326
327 Ok(format!(
328 "โ
Created branch '{}' from stash '{}'",
329 branch_name, stashes[selection].name
330 ))
331 }
332 Ok(3) => {
333 let selection = Select::with_theme(&theme)
335 .with_prompt("๐ Select stash to view diff")
336 .items(&stash_display)
337 .interact()?;
338
339 let diff = GitOperations::run(&["stash", "show", "-p", &stashes[selection].name])?;
340 Ok(format!(
341 "๐ Diff for {}:\n{}",
342 stashes[selection].name, diff
343 ))
344 }
345 Ok(4) => {
346 let mut result = "๐ All stashes:\n".to_string();
348 for stash in &stashes {
349 result.push_str(&format!(
350 " {}: {} (from {})\n",
351 stash.name, stash.message, stash.branch
352 ));
353 }
354 Ok(result)
355 }
356 Ok(_) | Err(_) => Ok("๐ Goodbye!".to_string()),
357 }
358 }
359
360 fn export_stashes_to_patches(
361 &self,
362 output_dir: &str,
363 stash_ref: &Option<String>,
364 ) -> Result<String> {
365 use std::fs;
366 use std::path::Path;
367
368 let output_path = Path::new(output_dir);
370 if !output_path.exists() {
371 fs::create_dir_all(output_path)
372 .map_err(|e| GitXError::GitCommand(format!("Failed to create directory: {e}")))?;
373 }
374
375 let stashes = if let Some(specific_stash) = stash_ref {
376 self.validate_stash_exists(specific_stash)?;
378 vec![self.get_stash_info(specific_stash)?]
379 } else {
380 self.get_stash_list_with_branches()?
382 };
383
384 if stashes.is_empty() {
385 return Ok("๐ No stashes to export".to_string());
386 }
387
388 let mut exported_count = 0;
389 for stash in &stashes {
390 let patch_content = GitOperations::run(&["stash", "show", "-p", &stash.name])?;
392
393 let safe_name = stash.name.replace(['@', '{', '}'], "");
395 let filename = format!("{safe_name}.patch");
396 let file_path = output_path.join(filename);
397
398 fs::write(&file_path, patch_content)
400 .map_err(|e| GitXError::GitCommand(format!("Failed to write patch file: {e}")))?;
401
402 exported_count += 1;
403 }
404
405 Ok(format!(
406 "โ
Exported {exported_count} stash(es) to patch files in '{output_dir}'"
407 ))
408 }
409
410 fn get_stash_info(&self, stash_ref: &str) -> Result<StashInfo> {
411 let output = GitOperations::run(&["stash", "list", "--pretty=format:%gd|%s", stash_ref])?;
412
413 if let Some(line) = output.lines().next() {
414 if let Some(stash) = self.parse_stash_line_with_branch(line) {
415 return Ok(stash);
416 }
417 }
418
419 Err(GitXError::GitCommand(
420 "Could not get stash information".to_string(),
421 ))
422 }
423
424 fn validate_branch_name(&self, name: &str) -> Result<()> {
426 if name.is_empty() {
427 return Err(GitXError::GitCommand(
428 "Branch name cannot be empty".to_string(),
429 ));
430 }
431
432 if name.starts_with('-') {
433 return Err(GitXError::GitCommand(
434 "Branch name cannot start with a dash".to_string(),
435 ));
436 }
437
438 if name.contains("..") {
439 return Err(GitXError::GitCommand(
440 "Branch name cannot contain '..'".to_string(),
441 ));
442 }
443
444 if name.contains(' ') {
445 return Err(GitXError::GitCommand(
446 "Branch name cannot contain spaces".to_string(),
447 ));
448 }
449
450 Ok(())
451 }
452
453 fn validate_stash_exists(&self, stash_ref: &str) -> Result<()> {
454 match GitOperations::run(&["rev-parse", "--verify", stash_ref]) {
455 Ok(_) => Ok(()),
456 Err(_) => Err(GitXError::GitCommand(
457 "Stash reference does not exist".to_string(),
458 )),
459 }
460 }
461
462 fn get_stash_list_with_dates(&self) -> Result<Vec<StashInfo>> {
463 let output = GitOperations::run(&["stash", "list", "--pretty=format:%gd|%s|%gD"])?;
464
465 let mut stashes = Vec::new();
466 for line in output.lines() {
467 if let Some(stash) = self.parse_stash_line_with_date(line) {
468 stashes.push(stash);
469 }
470 }
471
472 Ok(stashes)
473 }
474
475 fn get_stash_list_with_branches(&self) -> Result<Vec<StashInfo>> {
476 let output = GitOperations::run(&["stash", "list", "--pretty=format:%gd|%s"])?;
477
478 let mut stashes = Vec::new();
479 for line in output.lines() {
480 if let Some(stash) = self.parse_stash_line_with_branch(line) {
481 stashes.push(stash);
482 }
483 }
484
485 Ok(stashes)
486 }
487
488 fn parse_stash_line_with_date(&self, line: &str) -> Option<StashInfo> {
489 let parts: Vec<&str> = line.splitn(3, '|').collect();
490 if parts.len() != 3 {
491 return None;
492 }
493
494 Some(StashInfo {
495 name: parts[0].to_string(),
496 message: parts[1].to_string(),
497 branch: self.extract_branch_from_message(parts[1]),
498 timestamp: parts[2].to_string(),
499 })
500 }
501
502 fn parse_stash_line_with_branch(&self, line: &str) -> Option<StashInfo> {
503 let parts: Vec<&str> = line.splitn(2, '|').collect();
504 if parts.len() != 2 {
505 return None;
506 }
507
508 Some(StashInfo {
509 name: parts[0].to_string(),
510 message: parts[1].to_string(),
511 branch: self.extract_branch_from_message(parts[1]),
512 timestamp: String::new(),
513 })
514 }
515
516 fn extract_branch_from_message(&self, message: &str) -> String {
517 if let Some(start) = message.find("On ") {
519 let rest = &message[start + 3..];
520 if let Some(end) = rest.find(':') {
521 return rest[..end].to_string();
522 }
523 }
524
525 if let Some(start) = message.find("WIP on ") {
526 let rest = &message[start + 7..];
527 if let Some(end) = rest.find(':') {
528 return rest[..end].to_string();
529 }
530 }
531
532 "unknown".to_string()
533 }
534
535 fn filter_stashes_by_age(&self, stashes: &[StashInfo], age: &str) -> Result<Vec<StashInfo>> {
536 if age.ends_with('d') || age.ends_with('w') || age.ends_with('m') {
539 Ok(stashes.to_vec())
541 } else {
542 Err(GitXError::GitCommand(
543 "Invalid age format. Use format like '7d', '2w', '1m'".to_string(),
544 ))
545 }
546 }
547
548 fn delete_stash(&self, stash_name: &str) -> Result<()> {
549 GitOperations::run_status(&["stash", "drop", stash_name])
550 }
551
552 fn apply_stash(&self, stash_name: &str) -> Result<()> {
553 GitOperations::run_status(&["stash", "apply", stash_name])
554 }
555}
556
557impl Command for StashCommand {
558 fn execute(&self) -> Result<String> {
559 self.execute_action()
560 }
561
562 fn name(&self) -> &'static str {
563 "stash-branch"
564 }
565
566 fn description(&self) -> &'static str {
567 "Create branches from stashes or manage stash cleanup"
568 }
569}
570
571impl GitCommand for StashCommand {}
572
573impl Destructive for StashCommand {
574 fn destruction_description(&self) -> String {
575 match &self.action {
576 StashBranchAction::Create { branch_name, .. } => {
577 format!("This will create a new branch '{branch_name}' and remove the stash")
578 }
579 StashBranchAction::Clean { dry_run: true, .. } => {
580 "This is a dry run - no stashes will be deleted".to_string()
581 }
582 StashBranchAction::Clean { dry_run: false, .. } => {
583 "This will permanently delete the selected stashes".to_string()
584 }
585 StashBranchAction::ApplyByBranch {
586 list_only: true, ..
587 } => "This will only list stashes without applying them".to_string(),
588 StashBranchAction::ApplyByBranch {
589 list_only: false, ..
590 } => "This will apply stashes to your working directory".to_string(),
591 StashBranchAction::Interactive => {
592 "Interactive stash management - actions will be confirmed individually".to_string()
593 }
594 StashBranchAction::Export { .. } => {
595 "This will export stashes as patch files to the specified directory".to_string()
596 }
597 }
598 }
599}
600
601pub mod utils {
603 use super::StashInfo;
604 use crate::core::git::GitOperations;
605 use crate::{GitXError, Result};
606
607 pub fn validate_branch_name(name: &str) -> Result<()> {
608 if name.is_empty() {
609 return Err(GitXError::GitCommand(
610 "Branch name cannot be empty".to_string(),
611 ));
612 }
613
614 if name.starts_with('-') {
615 return Err(GitXError::GitCommand(
616 "Branch name cannot start with a dash".to_string(),
617 ));
618 }
619
620 if name.contains("..") {
621 return Err(GitXError::GitCommand(
622 "Branch name cannot contain '..'".to_string(),
623 ));
624 }
625
626 if name.contains(' ') {
627 return Err(GitXError::GitCommand(
628 "Branch name cannot contain spaces".to_string(),
629 ));
630 }
631
632 Ok(())
633 }
634
635 pub fn validate_stash_exists(stash_ref: &str) -> Result<()> {
636 match GitOperations::run(&["rev-parse", "--verify", stash_ref]) {
637 Ok(_) => Ok(()),
638 Err(_) => Err(GitXError::GitCommand(
639 "Stash reference does not exist".to_string(),
640 )),
641 }
642 }
643
644 pub fn parse_stash_line_with_date(line: &str) -> Option<StashInfo> {
645 let parts: Vec<&str> = line.splitn(3, '|').collect();
646 if parts.len() != 3 {
647 return None;
648 }
649
650 Some(StashInfo {
651 name: parts[0].to_string(),
652 message: parts[1].to_string(),
653 branch: extract_branch_from_message(parts[1]),
654 timestamp: parts[2].to_string(),
655 })
656 }
657
658 pub fn parse_stash_line_with_branch(line: &str) -> Option<StashInfo> {
659 let parts: Vec<&str> = line.splitn(2, '|').collect();
660 if parts.len() != 2 {
661 return None;
662 }
663
664 Some(StashInfo {
665 name: parts[0].to_string(),
666 message: parts[1].to_string(),
667 branch: extract_branch_from_message(parts[1]),
668 timestamp: String::new(),
669 })
670 }
671
672 pub fn extract_branch_from_message(message: &str) -> String {
673 if let Some(start) = message.find("On ") {
675 let rest = &message[start + 3..];
676 if let Some(end) = rest.find(':') {
677 return rest[..end].to_string();
678 }
679 }
680
681 if let Some(start) = message.find("WIP on ") {
682 let rest = &message[start + 7..];
683 if let Some(end) = rest.find(':') {
684 return rest[..end].to_string();
685 }
686 }
687
688 "unknown".to_string()
689 }
690
691 pub fn filter_stashes_by_age(stashes: &[StashInfo], age: &str) -> Result<Vec<StashInfo>> {
692 if age.ends_with('d') || age.ends_with('w') || age.ends_with('m') {
695 Ok(stashes.to_vec())
697 } else {
698 Err(GitXError::GitCommand(
699 "Invalid age format. Use format like '7d', '2w', '1m'".to_string(),
700 ))
701 }
702 }
703
704 pub fn format_applying_stashes_message(branch_name: &str, count: usize) -> String {
705 format!("๐ Applying {count} stash(es) from branch '{branch_name}':")
706 }
707}