rustic_git/commands/
stash.rs

1//! Git stash operations
2//!
3//! This module provides functionality for stashing, listing, applying, and managing Git stashes.
4//! It supports comprehensive stash management with type-safe operations.
5//!
6//! # Examples
7//!
8//! ```rust,no_run
9//! use rustic_git::{Repository, StashOptions, StashApplyOptions};
10//!
11//! let repo = Repository::open(".")?;
12//!
13//! // Save current changes to stash
14//! let stash = repo.stash_save("Work in progress")?;
15//! println!("Stashed: {}", stash.message);
16//!
17//! // List all stashes
18//! let stashes = repo.stash_list()?;
19//! for stash in stashes.iter() {
20//!     println!("{}: {}", stash.index, stash.message);
21//! }
22//!
23//! // Apply most recent stash
24//! if let Some(latest) = stashes.latest() {
25//!     repo.stash_apply(latest.index, StashApplyOptions::new())?;
26//! }
27//!
28//! # Ok::<(), rustic_git::GitError>(())
29//! ```
30
31use crate::error::{GitError, Result};
32use crate::repository::Repository;
33use crate::types::Hash;
34use crate::utils::git;
35use chrono::{DateTime, Utc};
36use std::fmt;
37use std::path::PathBuf;
38
39/// Represents a Git stash entry
40#[derive(Debug, Clone, PartialEq)]
41pub struct Stash {
42    /// The stash index (0 is most recent)
43    pub index: usize,
44    /// The stash message
45    pub message: String,
46    /// The commit hash of the stash
47    pub hash: Hash,
48    /// The branch name when stash was created
49    pub branch: String,
50    /// When the stash was created
51    pub timestamp: DateTime<Utc>,
52}
53
54impl fmt::Display for Stash {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        write!(f, "stash@{{{}}}: {}", self.index, self.message)
57    }
58}
59
60/// A collection of stashes with efficient iteration and filtering methods
61#[derive(Debug, Clone)]
62pub struct StashList {
63    stashes: Box<[Stash]>,
64}
65
66impl StashList {
67    /// Create a new StashList from a vector of stashes
68    pub fn new(stashes: Vec<Stash>) -> Self {
69        Self {
70            stashes: stashes.into_boxed_slice(),
71        }
72    }
73
74    /// Get an iterator over all stashes
75    pub fn iter(&self) -> impl Iterator<Item = &Stash> + '_ {
76        self.stashes.iter()
77    }
78
79    /// Get the most recent stash (index 0)
80    pub fn latest(&self) -> Option<&Stash> {
81        self.stashes.first()
82    }
83
84    /// Get stash by index
85    pub fn get(&self, index: usize) -> Option<&Stash> {
86        self.stashes.iter().find(|stash| stash.index == index)
87    }
88
89    /// Find stashes whose messages contain the given substring
90    pub fn find_containing<'a>(
91        &'a self,
92        substring: &'a str,
93    ) -> impl Iterator<Item = &'a Stash> + 'a {
94        self.stashes
95            .iter()
96            .filter(move |stash| stash.message.contains(substring))
97    }
98
99    /// Get stashes created on a specific branch
100    pub fn for_branch<'a>(&'a self, branch: &'a str) -> impl Iterator<Item = &'a Stash> + 'a {
101        self.stashes
102            .iter()
103            .filter(move |stash| stash.branch == branch)
104    }
105
106    /// Get the total number of stashes
107    pub fn len(&self) -> usize {
108        self.stashes.len()
109    }
110
111    /// Check if the stash list is empty
112    pub fn is_empty(&self) -> bool {
113        self.stashes.is_empty()
114    }
115}
116
117/// Options for creating stashes
118#[derive(Debug, Clone, Default)]
119pub struct StashOptions {
120    /// Include untracked files in the stash
121    pub include_untracked: bool,
122    /// Include ignored files in the stash
123    pub include_all: bool,
124    /// Keep staged changes in the index
125    pub keep_index: bool,
126    /// Create a patch-mode stash (interactive)
127    pub patch: bool,
128    /// Only stash staged changes
129    pub staged_only: bool,
130    /// Paths to specifically stash
131    pub paths: Vec<PathBuf>,
132}
133
134impl StashOptions {
135    /// Create new default stash options
136    pub fn new() -> Self {
137        Self::default()
138    }
139
140    /// Include untracked files in the stash
141    pub fn with_untracked(mut self) -> Self {
142        self.include_untracked = true;
143        self
144    }
145
146    /// Include all files (untracked and ignored) in the stash
147    pub fn with_all(mut self) -> Self {
148        self.include_all = true;
149        self.include_untracked = true; // --all implies --include-untracked
150        self
151    }
152
153    /// Keep staged changes in the index after stashing
154    pub fn with_keep_index(mut self) -> Self {
155        self.keep_index = true;
156        self
157    }
158
159    /// Create an interactive patch-mode stash
160    pub fn with_patch(mut self) -> Self {
161        self.patch = true;
162        self
163    }
164
165    /// Only stash staged changes
166    pub fn with_staged_only(mut self) -> Self {
167        self.staged_only = true;
168        self
169    }
170
171    /// Specify paths to stash
172    pub fn with_paths(mut self, paths: Vec<PathBuf>) -> Self {
173        self.paths = paths;
174        self
175    }
176}
177
178/// Options for applying stashes
179#[derive(Debug, Clone, Default)]
180pub struct StashApplyOptions {
181    /// Restore staged changes to the index
182    pub restore_index: bool,
183    /// Suppress output messages
184    pub quiet: bool,
185}
186
187impl StashApplyOptions {
188    /// Create new default apply options
189    pub fn new() -> Self {
190        Self::default()
191    }
192
193    /// Restore staged changes to the index when applying
194    pub fn with_index(mut self) -> Self {
195        self.restore_index = true;
196        self
197    }
198
199    /// Suppress output messages
200    pub fn with_quiet(mut self) -> Self {
201        self.quiet = true;
202        self
203    }
204}
205
206impl Repository {
207    /// List all stashes in the repository
208    ///
209    /// Returns a `StashList` containing all stashes sorted by recency (most recent first).
210    ///
211    /// # Example
212    ///
213    /// ```rust,no_run
214    /// use rustic_git::Repository;
215    ///
216    /// let repo = Repository::open(".")?;
217    /// let stashes = repo.stash_list()?;
218    ///
219    /// println!("Found {} stashes:", stashes.len());
220    /// for stash in stashes.iter() {
221    ///     println!("  {}: {}", stash.index, stash.message);
222    /// }
223    /// # Ok::<(), rustic_git::GitError>(())
224    /// ```
225    pub fn stash_list(&self) -> Result<StashList> {
226        Self::ensure_git()?;
227
228        let output = git(
229            &["stash", "list", "--format=%gd %H %gs"],
230            Some(self.repo_path()),
231        )?;
232
233        if output.trim().is_empty() {
234            return Ok(StashList::new(vec![]));
235        }
236
237        let mut stashes = Vec::new();
238
239        for (index, line) in output.lines().enumerate() {
240            let line = line.trim();
241            if line.is_empty() {
242                continue;
243            }
244
245            if let Ok(stash) = parse_stash_line(index, line) {
246                stashes.push(stash);
247            }
248        }
249
250        Ok(StashList::new(stashes))
251    }
252
253    /// Save current changes to a new stash with a message
254    ///
255    /// This is equivalent to `git stash push -m "message"`.
256    ///
257    /// # Arguments
258    ///
259    /// * `message` - A descriptive message for the stash
260    ///
261    /// # Example
262    ///
263    /// ```rust,no_run
264    /// use rustic_git::Repository;
265    ///
266    /// let repo = Repository::open(".")?;
267    /// let stash = repo.stash_save("Work in progress on feature X")?;
268    /// println!("Created stash: {}", stash.message);
269    /// # Ok::<(), rustic_git::GitError>(())
270    /// ```
271    pub fn stash_save(&self, message: &str) -> Result<Stash> {
272        let options = StashOptions::new();
273        self.stash_push(message, options)
274    }
275
276    /// Create a stash with advanced options
277    ///
278    /// # Arguments
279    ///
280    /// * `message` - A descriptive message for the stash
281    /// * `options` - Stash creation options
282    ///
283    /// # Example
284    ///
285    /// ```rust,no_run
286    /// use rustic_git::{Repository, StashOptions};
287    ///
288    /// let repo = Repository::open(".")?;
289    ///
290    /// // Stash including untracked files
291    /// let options = StashOptions::new()
292    ///     .with_untracked()
293    ///     .with_keep_index();
294    /// let stash = repo.stash_push("WIP with untracked files", options)?;
295    /// # Ok::<(), rustic_git::GitError>(())
296    /// ```
297    pub fn stash_push(&self, message: &str, options: StashOptions) -> Result<Stash> {
298        Self::ensure_git()?;
299
300        let mut args = vec!["stash", "push"];
301
302        if options.include_all {
303            args.push("--all");
304        } else if options.include_untracked {
305            args.push("--include-untracked");
306        }
307
308        if options.keep_index {
309            args.push("--keep-index");
310        }
311
312        if options.patch {
313            args.push("--patch");
314        }
315
316        if options.staged_only {
317            args.push("--staged");
318        }
319
320        args.extend(&["-m", message]);
321
322        // Add paths if specified
323        if !options.paths.is_empty() {
324            args.push("--");
325            for path in &options.paths {
326                if let Some(path_str) = path.to_str() {
327                    args.push(path_str);
328                }
329            }
330        }
331
332        git(&args, Some(self.repo_path()))?;
333
334        // Get the newly created stash (it will be at index 0)
335        let stashes = self.stash_list()?;
336        stashes.latest().cloned().ok_or_else(|| {
337            GitError::CommandFailed(
338                "Failed to create stash or retrieve stash information".to_string(),
339            )
340        })
341    }
342
343    /// Apply a stash without removing it from the stash list
344    ///
345    /// # Arguments
346    ///
347    /// * `index` - The stash index to apply (0 is most recent)
348    /// * `options` - Apply options
349    ///
350    /// # Example
351    ///
352    /// ```rust,no_run
353    /// use rustic_git::{Repository, StashApplyOptions};
354    ///
355    /// let repo = Repository::open(".")?;
356    /// let options = StashApplyOptions::new().with_index();
357    /// repo.stash_apply(0, options)?; // Apply most recent stash
358    /// # Ok::<(), rustic_git::GitError>(())
359    /// ```
360    pub fn stash_apply(&self, index: usize, options: StashApplyOptions) -> Result<()> {
361        Self::ensure_git()?;
362
363        let mut args = vec!["stash", "apply"];
364
365        if options.restore_index {
366            args.push("--index");
367        }
368
369        if options.quiet {
370            args.push("--quiet");
371        }
372
373        let stash_ref = format!("stash@{{{}}}", index);
374        args.push(&stash_ref);
375
376        git(&args, Some(self.repo_path()))?;
377        Ok(())
378    }
379
380    /// Apply a stash and remove it from the stash list
381    ///
382    /// # Arguments
383    ///
384    /// * `index` - The stash index to pop (0 is most recent)
385    /// * `options` - Apply options
386    ///
387    /// # Example
388    ///
389    /// ```rust,no_run
390    /// use rustic_git::{Repository, StashApplyOptions};
391    ///
392    /// let repo = Repository::open(".")?;
393    /// repo.stash_pop(0, StashApplyOptions::new())?; // Pop most recent stash
394    /// # Ok::<(), rustic_git::GitError>(())
395    /// ```
396    pub fn stash_pop(&self, index: usize, options: StashApplyOptions) -> Result<()> {
397        Self::ensure_git()?;
398
399        let mut args = vec!["stash", "pop"];
400
401        if options.restore_index {
402            args.push("--index");
403        }
404
405        if options.quiet {
406            args.push("--quiet");
407        }
408
409        let stash_ref = format!("stash@{{{}}}", index);
410        args.push(&stash_ref);
411
412        git(&args, Some(self.repo_path()))?;
413        Ok(())
414    }
415
416    /// Show the contents of a stash
417    ///
418    /// # Arguments
419    ///
420    /// * `index` - The stash index to show (0 is most recent)
421    ///
422    /// # Example
423    ///
424    /// ```rust,no_run
425    /// use rustic_git::Repository;
426    ///
427    /// let repo = Repository::open(".")?;
428    /// let stash_info = repo.stash_show(0)?;
429    /// println!("Stash contents:\n{}", stash_info);
430    /// # Ok::<(), rustic_git::GitError>(())
431    /// ```
432    pub fn stash_show(&self, index: usize) -> Result<String> {
433        Self::ensure_git()?;
434
435        let output = git(
436            &["stash", "show", &format!("stash@{{{}}}", index)],
437            Some(self.repo_path()),
438        )?;
439
440        Ok(output)
441    }
442
443    /// Delete a specific stash
444    ///
445    /// # Arguments
446    ///
447    /// * `index` - The stash index to delete
448    ///
449    /// # Example
450    ///
451    /// ```rust,no_run
452    /// use rustic_git::Repository;
453    ///
454    /// let repo = Repository::open(".")?;
455    /// repo.stash_drop(1)?; // Delete second most recent stash
456    /// # Ok::<(), rustic_git::GitError>(())
457    /// ```
458    pub fn stash_drop(&self, index: usize) -> Result<()> {
459        Self::ensure_git()?;
460
461        git(
462            &["stash", "drop", &format!("stash@{{{}}}", index)],
463            Some(self.repo_path()),
464        )?;
465
466        Ok(())
467    }
468
469    /// Clear all stashes
470    ///
471    /// # Example
472    ///
473    /// ```rust,no_run
474    /// use rustic_git::Repository;
475    ///
476    /// let repo = Repository::open(".")?;
477    /// repo.stash_clear()?; // Remove all stashes
478    /// # Ok::<(), rustic_git::GitError>(())
479    /// ```
480    pub fn stash_clear(&self) -> Result<()> {
481        Self::ensure_git()?;
482
483        git(&["stash", "clear"], Some(self.repo_path()))?;
484        Ok(())
485    }
486}
487
488/// Parse a stash list line into a Stash struct
489fn parse_stash_line(index: usize, line: &str) -> Result<Stash> {
490    // Format: "stash@{0} hash On branch: message"
491    let parts: Vec<&str> = line.splitn(4, ' ').collect();
492
493    if parts.len() < 4 {
494        return Err(GitError::CommandFailed(
495            "Invalid stash list format".to_string(),
496        ));
497    }
498
499    let hash = Hash::from(parts[1]);
500
501    // Extract branch name and message from parts[3] (should be "On branch: message")
502    let remainder = parts[3];
503    let (branch, message) = if let Some(colon_pos) = remainder.find(':') {
504        let branch_part = &remainder[..colon_pos];
505        let message_part = &remainder[colon_pos + 1..].trim();
506
507        // Extract branch name from "On branch_name" or "WIP on branch_name"
508        let branch = if let Some(stripped) = branch_part.strip_prefix("On ") {
509            stripped.to_string()
510        } else if let Some(stripped) = branch_part.strip_prefix("WIP on ") {
511            stripped.to_string()
512        } else {
513            "unknown".to_string()
514        };
515
516        (branch, message_part.to_string())
517    } else {
518        ("unknown".to_string(), remainder.to_string())
519    };
520
521    Ok(Stash {
522        index,
523        message,
524        hash,
525        branch,
526        timestamp: Utc::now(), // Simplified for now
527    })
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533    use std::env;
534    use std::fs;
535
536    fn create_test_repo() -> (Repository, std::path::PathBuf) {
537        use std::thread;
538        use std::time::{SystemTime, UNIX_EPOCH};
539
540        let timestamp = SystemTime::now()
541            .duration_since(UNIX_EPOCH)
542            .unwrap()
543            .as_nanos();
544        let thread_id = format!("{:?}", thread::current().id());
545        let test_path = env::temp_dir().join(format!(
546            "rustic_git_stash_test_{}_{}_{}",
547            std::process::id(),
548            timestamp,
549            thread_id.replace("ThreadId(", "").replace(")", "")
550        ));
551
552        // Ensure clean state
553        if test_path.exists() {
554            fs::remove_dir_all(&test_path).unwrap();
555        }
556
557        let repo = Repository::init(&test_path, false).unwrap();
558
559        // Configure git user for commits
560        repo.config()
561            .set_user("Test User", "test@example.com")
562            .unwrap();
563
564        (repo, test_path)
565    }
566
567    fn create_test_commit(
568        repo: &Repository,
569        test_path: &std::path::Path,
570        filename: &str,
571        content: &str,
572    ) {
573        fs::write(test_path.join(filename), content).unwrap();
574        repo.add(&[filename]).unwrap();
575        repo.commit(&format!("Add {}", filename)).unwrap();
576    }
577
578    #[test]
579    fn test_stash_list_empty_repository() {
580        let (repo, test_path) = create_test_repo();
581
582        let stashes = repo.stash_list().unwrap();
583        assert!(stashes.is_empty());
584        assert_eq!(stashes.len(), 0);
585
586        // Clean up
587        fs::remove_dir_all(&test_path).unwrap();
588    }
589
590    #[test]
591    fn test_stash_save_and_list() {
592        let (repo, test_path) = create_test_repo();
593
594        // Create initial commit
595        create_test_commit(&repo, &test_path, "initial.txt", "initial content");
596
597        // Make some changes (modify existing tracked file)
598        fs::write(test_path.join("initial.txt"), "modified content").unwrap();
599
600        // Stash the changes
601        let stash = repo.stash_save("Test stash message").unwrap();
602        assert_eq!(stash.message, "Test stash message");
603        assert_eq!(stash.index, 0);
604
605        // Verify stash exists in list
606        let stashes = repo.stash_list().unwrap();
607        assert_eq!(stashes.len(), 1);
608        assert!(stashes.latest().is_some());
609        assert_eq!(stashes.latest().unwrap().message, "Test stash message");
610
611        // Clean up
612        fs::remove_dir_all(&test_path).unwrap();
613    }
614
615    #[test]
616    fn test_stash_push_with_options() {
617        let (repo, test_path) = create_test_repo();
618
619        // Create initial commit
620        create_test_commit(&repo, &test_path, "initial.txt", "initial content");
621
622        // Make some changes
623        fs::write(test_path.join("initial.txt"), "modified initial").unwrap(); // Modify tracked file
624        fs::write(test_path.join("tracked.txt"), "tracked content").unwrap();
625        fs::write(test_path.join("untracked.txt"), "untracked content").unwrap();
626
627        // Stage the files
628        repo.add(&["tracked.txt"]).unwrap();
629
630        // Stash with options
631        let options = StashOptions::new().with_untracked().with_keep_index();
632        let stash = repo.stash_push("Stash with options", options).unwrap();
633
634        assert_eq!(stash.message, "Stash with options");
635
636        // Clean up
637        fs::remove_dir_all(&test_path).unwrap();
638    }
639
640    #[test]
641    fn test_stash_apply_and_pop() {
642        let (repo, test_path) = create_test_repo();
643
644        // Create initial commit
645        create_test_commit(&repo, &test_path, "initial.txt", "initial content");
646
647        // Make and stash changes (modify existing tracked file)
648        fs::write(test_path.join("initial.txt"), "modified content").unwrap();
649        repo.stash_save("Test stash").unwrap();
650
651        // Verify file content is reverted after stash
652        let content = fs::read_to_string(test_path.join("initial.txt")).unwrap();
653        assert_eq!(content, "initial content");
654
655        // Apply stash
656        repo.stash_apply(0, StashApplyOptions::new()).unwrap();
657
658        // Verify file content is back to modified
659        let content = fs::read_to_string(test_path.join("initial.txt")).unwrap();
660        assert_eq!(content, "modified content");
661
662        // Stash should still exist
663        let stashes = repo.stash_list().unwrap();
664        assert_eq!(stashes.len(), 1);
665
666        // Reset working tree and pop
667        fs::write(test_path.join("initial.txt"), "initial content").unwrap(); // Reset to original
668        repo.stash_pop(0, StashApplyOptions::new()).unwrap();
669
670        // File content should be modified again and stash should be gone
671        let content = fs::read_to_string(test_path.join("initial.txt")).unwrap();
672        assert_eq!(content, "modified content");
673        let stashes = repo.stash_list().unwrap();
674        assert_eq!(stashes.len(), 0);
675
676        // Clean up
677        fs::remove_dir_all(&test_path).unwrap();
678    }
679
680    #[test]
681    fn test_stash_drop_and_clear() {
682        let (repo, test_path) = create_test_repo();
683
684        // Create initial commit
685        create_test_commit(&repo, &test_path, "initial.txt", "initial content");
686
687        // Create multiple stashes by modifying the tracked file
688        for i in 1..=3 {
689            fs::write(test_path.join("initial.txt"), format!("content {}", i)).unwrap();
690            repo.stash_save(&format!("Stash {}", i)).unwrap();
691        }
692
693        let stashes = repo.stash_list().unwrap();
694        assert_eq!(stashes.len(), 3);
695
696        // Drop middle stash
697        repo.stash_drop(1).unwrap();
698        let stashes = repo.stash_list().unwrap();
699        assert_eq!(stashes.len(), 2);
700
701        // Clear all stashes
702        repo.stash_clear().unwrap();
703        let stashes = repo.stash_list().unwrap();
704        assert_eq!(stashes.len(), 0);
705
706        // Clean up
707        fs::remove_dir_all(&test_path).unwrap();
708    }
709
710    #[test]
711    fn test_stash_show() {
712        let (repo, test_path) = create_test_repo();
713
714        // Create initial commit
715        create_test_commit(&repo, &test_path, "initial.txt", "initial content");
716
717        // Make changes and stash (modify existing tracked file)
718        fs::write(test_path.join("initial.txt"), "modified content").unwrap();
719        repo.stash_save("Test stash").unwrap();
720
721        // Show stash contents
722        let show_output = repo.stash_show(0).unwrap();
723        assert!(!show_output.is_empty());
724
725        // Clean up
726        fs::remove_dir_all(&test_path).unwrap();
727    }
728
729    #[test]
730    fn test_stash_list_filtering() {
731        let (repo, test_path) = create_test_repo();
732
733        // Create initial commit
734        create_test_commit(&repo, &test_path, "initial.txt", "initial content");
735
736        // Create stashes with different messages (modify existing tracked file)
737        fs::write(test_path.join("initial.txt"), "content1").unwrap();
738        repo.stash_save("feature work in progress").unwrap();
739
740        fs::write(test_path.join("initial.txt"), "content2").unwrap();
741        repo.stash_save("bugfix temporary save").unwrap();
742
743        fs::write(test_path.join("initial.txt"), "content3").unwrap();
744        repo.stash_save("feature enhancement").unwrap();
745
746        let stashes = repo.stash_list().unwrap();
747        assert_eq!(stashes.len(), 3);
748
749        // Test filtering
750        let feature_stashes: Vec<_> = stashes.find_containing("feature").collect();
751        assert_eq!(feature_stashes.len(), 2);
752
753        let bugfix_stashes: Vec<_> = stashes.find_containing("bugfix").collect();
754        assert_eq!(bugfix_stashes.len(), 1);
755
756        // Test get by index
757        assert!(stashes.get(0).is_some());
758        assert!(stashes.get(10).is_none());
759
760        // Clean up
761        fs::remove_dir_all(&test_path).unwrap();
762    }
763
764    #[test]
765    fn test_stash_options_builder() {
766        let options = StashOptions::new()
767            .with_untracked()
768            .with_keep_index()
769            .with_paths(vec!["file1.txt".into(), "file2.txt".into()]);
770
771        assert!(options.include_untracked);
772        assert!(options.keep_index);
773        assert_eq!(options.paths.len(), 2);
774
775        let apply_options = StashApplyOptions::new().with_index().with_quiet();
776
777        assert!(apply_options.restore_index);
778        assert!(apply_options.quiet);
779    }
780
781    #[test]
782    fn test_stash_display() {
783        let stash = Stash {
784            index: 0,
785            message: "Test stash message".to_string(),
786            hash: Hash::from("abc123"),
787            branch: "main".to_string(),
788            timestamp: Utc::now(),
789        };
790
791        let display_str = format!("{}", stash);
792        assert!(display_str.contains("stash@{0}"));
793        assert!(display_str.contains("Test stash message"));
794    }
795}