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, parse_unix_timestamp};
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 %ct %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 timestamp On branch: message"
491    let parts: Vec<&str> = line.splitn(4, ' ').collect();
492
493    if parts.len() < 4 {
494        return Err(GitError::CommandFailed(format!(
495            "Invalid stash list format: expected 4 parts, got {}",
496            parts.len()
497        )));
498    }
499
500    let hash = Hash::from(parts[1]);
501
502    // Parse timestamp - if it fails, the stash metadata may be corrupted
503    // Use Unix epoch as fallback to clearly indicate corrupted/invalid timestamp data
504    let timestamp = parse_unix_timestamp(parts[2]).unwrap_or_else(|_| {
505        // Timestamp parsing failed - this indicates malformed git stash metadata
506        // Use Unix epoch (1970-01-01) as fallback to make data corruption obvious
507        DateTime::from_timestamp(0, 0).unwrap_or_else(Utc::now)
508    });
509
510    // Extract branch name and message from parts[3] (should be "On branch: message")
511    let remainder = parts[3];
512    if remainder.is_empty() {
513        return Err(GitError::CommandFailed(
514            "Invalid stash format: missing branch and message information".to_string(),
515        ));
516    }
517
518    let (branch, message) = if let Some(colon_pos) = remainder.find(':') {
519        let branch_part = &remainder[..colon_pos];
520        let message_part = &remainder[colon_pos + 1..].trim();
521
522        // Extract branch name from "On branch_name" or "WIP on branch_name"
523        let branch = if let Some(stripped) = branch_part.strip_prefix("On ") {
524            stripped.to_string()
525        } else if let Some(stripped) = branch_part.strip_prefix("WIP on ") {
526            stripped.to_string()
527        } else {
528            "unknown".to_string()
529        };
530
531        (branch, message_part.to_string())
532    } else {
533        ("unknown".to_string(), remainder.to_string())
534    };
535
536    Ok(Stash {
537        index,
538        message,
539        hash,
540        branch,
541        timestamp,
542    })
543}
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548    use std::env;
549    use std::fs;
550
551    fn create_test_repo() -> (Repository, std::path::PathBuf) {
552        use std::thread;
553        use std::time::{SystemTime, UNIX_EPOCH};
554
555        let timestamp = SystemTime::now()
556            .duration_since(UNIX_EPOCH)
557            .unwrap()
558            .as_nanos();
559        let thread_id = format!("{:?}", thread::current().id());
560        let test_path = env::temp_dir().join(format!(
561            "rustic_git_stash_test_{}_{}_{}",
562            std::process::id(),
563            timestamp,
564            thread_id.replace("ThreadId(", "").replace(")", "")
565        ));
566
567        // Ensure clean state
568        if test_path.exists() {
569            fs::remove_dir_all(&test_path).unwrap();
570        }
571
572        let repo = Repository::init(&test_path, false).unwrap();
573
574        // Configure git user for commits
575        repo.config()
576            .set_user("Test User", "test@example.com")
577            .unwrap();
578
579        (repo, test_path)
580    }
581
582    fn create_test_commit(
583        repo: &Repository,
584        test_path: &std::path::Path,
585        filename: &str,
586        content: &str,
587    ) {
588        fs::write(test_path.join(filename), content).unwrap();
589        repo.add(&[filename]).unwrap();
590        repo.commit(&format!("Add {}", filename)).unwrap();
591    }
592
593    #[test]
594    fn test_stash_list_empty_repository() {
595        let (repo, test_path) = create_test_repo();
596
597        let stashes = repo.stash_list().unwrap();
598        assert!(stashes.is_empty());
599        assert_eq!(stashes.len(), 0);
600
601        // Clean up
602        fs::remove_dir_all(&test_path).unwrap();
603    }
604
605    #[test]
606    fn test_stash_save_and_list() {
607        let (repo, test_path) = create_test_repo();
608
609        // Create initial commit
610        create_test_commit(&repo, &test_path, "initial.txt", "initial content");
611
612        // Make some changes (modify existing tracked file)
613        fs::write(test_path.join("initial.txt"), "modified content").unwrap();
614
615        // Stash the changes
616        let stash = repo.stash_save("Test stash message").unwrap();
617        assert_eq!(stash.message, "Test stash message");
618        assert_eq!(stash.index, 0);
619
620        // Verify stash exists in list
621        let stashes = repo.stash_list().unwrap();
622        assert_eq!(stashes.len(), 1);
623        assert!(stashes.latest().is_some());
624        assert_eq!(stashes.latest().unwrap().message, "Test stash message");
625
626        // Clean up
627        fs::remove_dir_all(&test_path).unwrap();
628    }
629
630    #[test]
631    fn test_stash_push_with_options() {
632        let (repo, test_path) = create_test_repo();
633
634        // Create initial commit
635        create_test_commit(&repo, &test_path, "initial.txt", "initial content");
636
637        // Make some changes
638        fs::write(test_path.join("initial.txt"), "modified initial").unwrap(); // Modify tracked file
639        fs::write(test_path.join("tracked.txt"), "tracked content").unwrap();
640        fs::write(test_path.join("untracked.txt"), "untracked content").unwrap();
641
642        // Stage the files
643        repo.add(&["tracked.txt"]).unwrap();
644
645        // Stash with options
646        let options = StashOptions::new().with_untracked().with_keep_index();
647        let stash = repo.stash_push("Stash with options", options).unwrap();
648
649        assert_eq!(stash.message, "Stash with options");
650
651        // Clean up
652        fs::remove_dir_all(&test_path).unwrap();
653    }
654
655    #[test]
656    fn test_stash_apply_and_pop() {
657        let (repo, test_path) = create_test_repo();
658
659        // Create initial commit
660        create_test_commit(&repo, &test_path, "initial.txt", "initial content");
661
662        // Make and stash changes (modify existing tracked file)
663        fs::write(test_path.join("initial.txt"), "modified content").unwrap();
664        repo.stash_save("Test stash").unwrap();
665
666        // Verify file content is reverted after stash
667        let content = fs::read_to_string(test_path.join("initial.txt")).unwrap();
668        assert_eq!(content, "initial content");
669
670        // Apply stash
671        repo.stash_apply(0, StashApplyOptions::new()).unwrap();
672
673        // Verify file content is back to modified
674        let content = fs::read_to_string(test_path.join("initial.txt")).unwrap();
675        assert_eq!(content, "modified content");
676
677        // Stash should still exist
678        let stashes = repo.stash_list().unwrap();
679        assert_eq!(stashes.len(), 1);
680
681        // Reset working tree and pop
682        fs::write(test_path.join("initial.txt"), "initial content").unwrap(); // Reset to original
683        repo.stash_pop(0, StashApplyOptions::new()).unwrap();
684
685        // File content should be modified again and stash should be gone
686        let content = fs::read_to_string(test_path.join("initial.txt")).unwrap();
687        assert_eq!(content, "modified content");
688        let stashes = repo.stash_list().unwrap();
689        assert_eq!(stashes.len(), 0);
690
691        // Clean up
692        fs::remove_dir_all(&test_path).unwrap();
693    }
694
695    #[test]
696    fn test_stash_drop_and_clear() {
697        let (repo, test_path) = create_test_repo();
698
699        // Create initial commit
700        create_test_commit(&repo, &test_path, "initial.txt", "initial content");
701
702        // Create multiple stashes by modifying the tracked file
703        for i in 1..=3 {
704            fs::write(test_path.join("initial.txt"), format!("content {}", i)).unwrap();
705            repo.stash_save(&format!("Stash {}", i)).unwrap();
706        }
707
708        let stashes = repo.stash_list().unwrap();
709        assert_eq!(stashes.len(), 3);
710
711        // Drop middle stash
712        repo.stash_drop(1).unwrap();
713        let stashes = repo.stash_list().unwrap();
714        assert_eq!(stashes.len(), 2);
715
716        // Clear all stashes
717        repo.stash_clear().unwrap();
718        let stashes = repo.stash_list().unwrap();
719        assert_eq!(stashes.len(), 0);
720
721        // Clean up
722        fs::remove_dir_all(&test_path).unwrap();
723    }
724
725    #[test]
726    fn test_stash_show() {
727        let (repo, test_path) = create_test_repo();
728
729        // Create initial commit
730        create_test_commit(&repo, &test_path, "initial.txt", "initial content");
731
732        // Make changes and stash (modify existing tracked file)
733        fs::write(test_path.join("initial.txt"), "modified content").unwrap();
734        repo.stash_save("Test stash").unwrap();
735
736        // Show stash contents
737        let show_output = repo.stash_show(0).unwrap();
738        assert!(!show_output.is_empty());
739
740        // Clean up
741        fs::remove_dir_all(&test_path).unwrap();
742    }
743
744    #[test]
745    fn test_stash_list_filtering() {
746        let (repo, test_path) = create_test_repo();
747
748        // Create initial commit
749        create_test_commit(&repo, &test_path, "initial.txt", "initial content");
750
751        // Create stashes with different messages (modify existing tracked file)
752        fs::write(test_path.join("initial.txt"), "content1").unwrap();
753        repo.stash_save("feature work in progress").unwrap();
754
755        fs::write(test_path.join("initial.txt"), "content2").unwrap();
756        repo.stash_save("bugfix temporary save").unwrap();
757
758        fs::write(test_path.join("initial.txt"), "content3").unwrap();
759        repo.stash_save("feature enhancement").unwrap();
760
761        let stashes = repo.stash_list().unwrap();
762        assert_eq!(stashes.len(), 3);
763
764        // Test filtering
765        let feature_stashes: Vec<_> = stashes.find_containing("feature").collect();
766        assert_eq!(feature_stashes.len(), 2);
767
768        let bugfix_stashes: Vec<_> = stashes.find_containing("bugfix").collect();
769        assert_eq!(bugfix_stashes.len(), 1);
770
771        // Test get by index
772        assert!(stashes.get(0).is_some());
773        assert!(stashes.get(10).is_none());
774
775        // Clean up
776        fs::remove_dir_all(&test_path).unwrap();
777    }
778
779    #[test]
780    fn test_stash_options_builder() {
781        let options = StashOptions::new()
782            .with_untracked()
783            .with_keep_index()
784            .with_paths(vec!["file1.txt".into(), "file2.txt".into()]);
785
786        assert!(options.include_untracked);
787        assert!(options.keep_index);
788        assert_eq!(options.paths.len(), 2);
789
790        let apply_options = StashApplyOptions::new().with_index().with_quiet();
791
792        assert!(apply_options.restore_index);
793        assert!(apply_options.quiet);
794    }
795
796    #[test]
797    fn test_stash_display() {
798        let stash = Stash {
799            index: 0,
800            message: "Test stash message".to_string(),
801            hash: Hash::from("abc123"),
802            branch: "main".to_string(),
803            timestamp: Utc::now(),
804        };
805
806        let display_str = format!("{}", stash);
807        assert!(display_str.contains("stash@{0}"));
808        assert!(display_str.contains("Test stash message"));
809    }
810
811    #[test]
812    fn test_parse_stash_line_invalid_format() {
813        // Test with insufficient parts
814        let invalid_line = "stash@{0} abc123"; // Only 2 parts instead of 4
815        let result = parse_stash_line(0, invalid_line);
816
817        assert!(result.is_err());
818        if let Err(GitError::CommandFailed(msg)) = result {
819            assert!(msg.contains("Invalid stash list format"));
820            assert!(msg.contains("expected 4 parts"));
821            assert!(msg.contains("got 2"));
822        } else {
823            panic!("Expected CommandFailed error with specific message");
824        }
825    }
826
827    #[test]
828    fn test_parse_stash_line_empty_remainder() {
829        // Test with empty remainder part
830        let invalid_line = "stash@{0} abc123 1234567890 "; // Empty 4th part
831        let result = parse_stash_line(0, invalid_line);
832
833        assert!(result.is_err());
834        if let Err(GitError::CommandFailed(msg)) = result {
835            assert!(msg.contains("missing branch and message information"));
836        } else {
837            panic!("Expected CommandFailed error for empty remainder");
838        }
839    }
840
841    #[test]
842    fn test_parse_stash_line_valid_format() {
843        // Test with valid format
844        let valid_line = "stash@{0} abc123def456 1234567890 On master: test message";
845        let result = parse_stash_line(0, valid_line);
846
847        assert!(result.is_ok());
848        let stash = result.unwrap();
849        assert_eq!(stash.index, 0);
850        assert_eq!(stash.hash.as_str(), "abc123def456");
851        assert_eq!(stash.branch, "master");
852        assert_eq!(stash.message, "test message");
853    }
854
855    #[test]
856    fn test_parse_stash_line_with_invalid_timestamp() {
857        // Test stash with invalid timestamp - should still parse but use fallback timestamp
858        let line_with_invalid_timestamp =
859            "stash@{0} abc123def456 invalid-timestamp On master: test message";
860        let result = parse_stash_line(0, line_with_invalid_timestamp);
861
862        assert!(result.is_ok());
863        let stash = result.unwrap();
864        assert_eq!(stash.index, 0);
865        assert_eq!(stash.hash.as_str(), "abc123def456");
866        assert_eq!(stash.branch, "master");
867        assert_eq!(stash.message, "test message");
868
869        // The timestamp should use Unix epoch (1970-01-01) as fallback for invalid data
870        // Verify fallback timestamp is Unix epoch (indicates data corruption)
871        assert_eq!(stash.timestamp.timestamp(), 0); // Unix epoch
872        assert_eq!(stash.timestamp.format("%Y-%m-%d").to_string(), "1970-01-01");
873    }
874}