git_x/commands/
stash.rs

1use crate::core::git::*;
2use crate::core::safety::Safety;
3use crate::core::traits::*;
4use crate::{GitXError, Result};
5
6/// Stash-related commands grouped together
7pub struct StashCommands;
8
9impl StashCommands {
10    /// Create a branch from a stash
11    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    /// Clean old stashes
20    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    /// Apply stashes by branch
29    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    /// Interactive stash management
38    pub fn interactive() -> Result<String> {
39        StashCommand::new(StashBranchAction::Interactive).execute()
40    }
41
42    /// Export stashes to patch files
43    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/// Stash branch actions
53#[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/// Stash information structure
75#[derive(Debug, Clone)]
76pub struct StashInfo {
77    pub name: String,
78    pub message: String,
79    pub branch: String,
80    pub timestamp: String,
81}
82
83/// Command for managing stash-branch operations
84pub 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        // Validate branch name
121        self.validate_branch_name(branch_name)?;
122
123        // Check if branch already exists
124        if BranchOperations::exists(branch_name).unwrap_or(false) {
125            return Err(GitXError::GitCommand(format!(
126                "Branch '{branch_name}' already exists"
127            )));
128        }
129
130        // Determine stash reference
131        let stash = stash_ref.clone().unwrap_or_else(|| "stash@{0}".to_string());
132
133        // Validate stash exists
134        self.validate_stash_exists(&stash)?;
135
136        // Create branch from stash
137        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        // Get all stashes with timestamps
146        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        // Filter stashes by age if specified
153        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            // Safety confirmation for destructive operation
176            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        // Get all stashes with their branch information
205        let stashes = self.get_stash_list_with_branches()?;
206
207        // Filter stashes by branch
208        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        // Get all stashes
244        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        // Create display items for selection
251        let stash_display: Vec<String> = stashes
252            .iter()
253            .map(|s| format!("{}: {} (from {})", s.name, s.message, s.branch))
254            .collect();
255
256        // Action selection menu
257        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                // Apply selected stash
276                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                // Delete selected stashes
286                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                    // Delete in reverse order to maintain indices
300                    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                // Create branch from stash
309                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                // Show stash diff
334                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                // List all stashes
347                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        // Create output directory if it doesn't exist
369        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            // Export only the specific stash
377            self.validate_stash_exists(specific_stash)?;
378            vec![self.get_stash_info(specific_stash)?]
379        } else {
380            // Export all stashes
381            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            // Generate patch content
391            let patch_content = GitOperations::run(&["stash", "show", "-p", &stash.name])?;
392
393            // Generate filename (sanitize stash name)
394            let safe_name = stash.name.replace(['@', '{', '}'], "");
395            let filename = format!("{safe_name}.patch");
396            let file_path = output_path.join(filename);
397
398            // Write patch file
399            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    // Helper methods
425    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        // Stash messages typically start with "On branch_name:" or "WIP on branch_name:"
518        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        // For simplicity, we'll implement basic age filtering
537        // In a real implementation, you'd parse the age string and compare timestamps
538        if age.ends_with('d') || age.ends_with('w') || age.ends_with('m') {
539            // This is a placeholder - real implementation would parse timestamps
540            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
601// Public utility functions for testing and external use
602pub 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        // Stash messages typically start with "On branch_name:" or "WIP on branch_name:"
674        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        // For simplicity, we'll implement basic age filtering
693        // In a real implementation, you'd parse the age string and compare timestamps
694        if age.ends_with('d') || age.ends_with('w') || age.ends_with('m') {
695            // This is a placeholder - real implementation would parse timestamps
696            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}