1use 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#[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 %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(
495 "Invalid stash list format".to_string(),
496 ));
497 }
498
499 let hash = Hash::from(parts[1]);
500
501 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 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(), })
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 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 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 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_test_commit(&repo, &test_path, "initial.txt", "initial content");
596
597 fs::write(test_path.join("initial.txt"), "modified content").unwrap();
599
600 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 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 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_test_commit(&repo, &test_path, "initial.txt", "initial content");
621
622 fs::write(test_path.join("initial.txt"), "modified initial").unwrap(); fs::write(test_path.join("tracked.txt"), "tracked content").unwrap();
625 fs::write(test_path.join("untracked.txt"), "untracked content").unwrap();
626
627 repo.add(&["tracked.txt"]).unwrap();
629
630 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 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_test_commit(&repo, &test_path, "initial.txt", "initial content");
646
647 fs::write(test_path.join("initial.txt"), "modified content").unwrap();
649 repo.stash_save("Test stash").unwrap();
650
651 let content = fs::read_to_string(test_path.join("initial.txt")).unwrap();
653 assert_eq!(content, "initial content");
654
655 repo.stash_apply(0, StashApplyOptions::new()).unwrap();
657
658 let content = fs::read_to_string(test_path.join("initial.txt")).unwrap();
660 assert_eq!(content, "modified content");
661
662 let stashes = repo.stash_list().unwrap();
664 assert_eq!(stashes.len(), 1);
665
666 fs::write(test_path.join("initial.txt"), "initial content").unwrap(); repo.stash_pop(0, StashApplyOptions::new()).unwrap();
669
670 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 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_test_commit(&repo, &test_path, "initial.txt", "initial content");
686
687 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 repo.stash_drop(1).unwrap();
698 let stashes = repo.stash_list().unwrap();
699 assert_eq!(stashes.len(), 2);
700
701 repo.stash_clear().unwrap();
703 let stashes = repo.stash_list().unwrap();
704 assert_eq!(stashes.len(), 0);
705
706 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_test_commit(&repo, &test_path, "initial.txt", "initial content");
716
717 fs::write(test_path.join("initial.txt"), "modified content").unwrap();
719 repo.stash_save("Test stash").unwrap();
720
721 let show_output = repo.stash_show(0).unwrap();
723 assert!(!show_output.is_empty());
724
725 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_test_commit(&repo, &test_path, "initial.txt", "initial content");
735
736 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 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 assert!(stashes.get(0).is_some());
758 assert!(stashes.get(10).is_none());
759
760 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}