1use 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#[derive(Debug, Clone, PartialEq)]
41pub struct Stash {
42 pub index: usize,
44 pub message: String,
46 pub hash: Hash,
48 pub branch: String,
50 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#[derive(Debug, Clone)]
62pub struct StashList {
63 stashes: Box<[Stash]>,
64}
65
66impl StashList {
67 pub fn new(stashes: Vec<Stash>) -> Self {
69 Self {
70 stashes: stashes.into_boxed_slice(),
71 }
72 }
73
74 pub fn iter(&self) -> impl Iterator<Item = &Stash> + '_ {
76 self.stashes.iter()
77 }
78
79 pub fn latest(&self) -> Option<&Stash> {
81 self.stashes.first()
82 }
83
84 pub fn get(&self, index: usize) -> Option<&Stash> {
86 self.stashes.iter().find(|stash| stash.index == index)
87 }
88
89 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 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 pub fn len(&self) -> usize {
108 self.stashes.len()
109 }
110
111 pub fn is_empty(&self) -> bool {
113 self.stashes.is_empty()
114 }
115}
116
117#[derive(Debug, Clone, Default)]
119pub struct StashOptions {
120 pub include_untracked: bool,
122 pub include_all: bool,
124 pub keep_index: bool,
126 pub patch: bool,
128 pub staged_only: bool,
130 pub paths: Vec<PathBuf>,
132}
133
134impl StashOptions {
135 pub fn new() -> Self {
137 Self::default()
138 }
139
140 pub fn with_untracked(mut self) -> Self {
142 self.include_untracked = true;
143 self
144 }
145
146 pub fn with_all(mut self) -> Self {
148 self.include_all = true;
149 self.include_untracked = true; self
151 }
152
153 pub fn with_keep_index(mut self) -> Self {
155 self.keep_index = true;
156 self
157 }
158
159 pub fn with_patch(mut self) -> Self {
161 self.patch = true;
162 self
163 }
164
165 pub fn with_staged_only(mut self) -> Self {
167 self.staged_only = true;
168 self
169 }
170
171 pub fn with_paths(mut self, paths: Vec<PathBuf>) -> Self {
173 self.paths = paths;
174 self
175 }
176}
177
178#[derive(Debug, Clone, Default)]
180pub struct StashApplyOptions {
181 pub restore_index: bool,
183 pub quiet: bool,
185}
186
187impl StashApplyOptions {
188 pub fn new() -> Self {
190 Self::default()
191 }
192
193 pub fn with_index(mut self) -> Self {
195 self.restore_index = true;
196 self
197 }
198
199 pub fn with_quiet(mut self) -> Self {
201 self.quiet = true;
202 self
203 }
204}
205
206impl Repository {
207 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 pub fn stash_save(&self, message: &str) -> Result<Stash> {
272 let options = StashOptions::new();
273 self.stash_push(message, options)
274 }
275
276 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 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 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 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 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 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 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 pub fn stash_clear(&self) -> Result<()> {
481 Self::ensure_git()?;
482
483 git(&["stash", "clear"], Some(self.repo_path()))?;
484 Ok(())
485 }
486}
487
488fn parse_stash_line(index: usize, line: &str) -> Result<Stash> {
490 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 let timestamp = parse_unix_timestamp(parts[2]).unwrap_or_else(|_| {
505 DateTime::from_timestamp(0, 0).unwrap_or_else(Utc::now)
508 });
509
510 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 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 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 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 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_test_commit(&repo, &test_path, "initial.txt", "initial content");
611
612 fs::write(test_path.join("initial.txt"), "modified content").unwrap();
614
615 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 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 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_test_commit(&repo, &test_path, "initial.txt", "initial content");
636
637 fs::write(test_path.join("initial.txt"), "modified initial").unwrap(); fs::write(test_path.join("tracked.txt"), "tracked content").unwrap();
640 fs::write(test_path.join("untracked.txt"), "untracked content").unwrap();
641
642 repo.add(&["tracked.txt"]).unwrap();
644
645 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 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_test_commit(&repo, &test_path, "initial.txt", "initial content");
661
662 fs::write(test_path.join("initial.txt"), "modified content").unwrap();
664 repo.stash_save("Test stash").unwrap();
665
666 let content = fs::read_to_string(test_path.join("initial.txt")).unwrap();
668 assert_eq!(content, "initial content");
669
670 repo.stash_apply(0, StashApplyOptions::new()).unwrap();
672
673 let content = fs::read_to_string(test_path.join("initial.txt")).unwrap();
675 assert_eq!(content, "modified content");
676
677 let stashes = repo.stash_list().unwrap();
679 assert_eq!(stashes.len(), 1);
680
681 fs::write(test_path.join("initial.txt"), "initial content").unwrap(); repo.stash_pop(0, StashApplyOptions::new()).unwrap();
684
685 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 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_test_commit(&repo, &test_path, "initial.txt", "initial content");
701
702 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 repo.stash_drop(1).unwrap();
713 let stashes = repo.stash_list().unwrap();
714 assert_eq!(stashes.len(), 2);
715
716 repo.stash_clear().unwrap();
718 let stashes = repo.stash_list().unwrap();
719 assert_eq!(stashes.len(), 0);
720
721 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_test_commit(&repo, &test_path, "initial.txt", "initial content");
731
732 fs::write(test_path.join("initial.txt"), "modified content").unwrap();
734 repo.stash_save("Test stash").unwrap();
735
736 let show_output = repo.stash_show(0).unwrap();
738 assert!(!show_output.is_empty());
739
740 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_test_commit(&repo, &test_path, "initial.txt", "initial content");
750
751 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 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 assert!(stashes.get(0).is_some());
773 assert!(stashes.get(10).is_none());
774
775 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 let invalid_line = "stash@{0} abc123"; 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 let invalid_line = "stash@{0} abc123 1234567890 "; 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 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 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 assert_eq!(stash.timestamp.timestamp(), 0); assert_eq!(stash.timestamp.format("%Y-%m-%d").to_string(), "1970-01-01");
873 }
874}