use crate::error::{GitError, Result};
use crate::repository::Repository;
use crate::types::Hash;
use crate::utils::{git, parse_unix_timestamp};
use chrono::{DateTime, Utc};
use std::fmt;
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq)]
pub struct Stash {
pub index: usize,
pub message: String,
pub hash: Hash,
pub branch: String,
pub timestamp: DateTime<Utc>,
}
impl fmt::Display for Stash {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "stash@{{{}}}: {}", self.index, self.message)
}
}
#[derive(Debug, Clone)]
pub struct StashList {
stashes: Box<[Stash]>,
}
impl StashList {
pub fn new(stashes: Vec<Stash>) -> Self {
Self {
stashes: stashes.into_boxed_slice(),
}
}
pub fn iter(&self) -> impl Iterator<Item = &Stash> + '_ {
self.stashes.iter()
}
pub fn latest(&self) -> Option<&Stash> {
self.stashes.first()
}
pub fn get(&self, index: usize) -> Option<&Stash> {
self.stashes.iter().find(|stash| stash.index == index)
}
pub fn find_containing<'a>(
&'a self,
substring: &'a str,
) -> impl Iterator<Item = &'a Stash> + 'a {
self.stashes
.iter()
.filter(move |stash| stash.message.contains(substring))
}
pub fn for_branch<'a>(&'a self, branch: &'a str) -> impl Iterator<Item = &'a Stash> + 'a {
self.stashes
.iter()
.filter(move |stash| stash.branch == branch)
}
pub fn len(&self) -> usize {
self.stashes.len()
}
pub fn is_empty(&self) -> bool {
self.stashes.is_empty()
}
}
#[derive(Debug, Clone, Default)]
pub struct StashOptions {
pub include_untracked: bool,
pub include_all: bool,
pub keep_index: bool,
pub patch: bool,
pub staged_only: bool,
pub paths: Vec<PathBuf>,
}
impl StashOptions {
pub fn new() -> Self {
Self::default()
}
pub fn with_untracked(mut self) -> Self {
self.include_untracked = true;
self
}
pub fn with_all(mut self) -> Self {
self.include_all = true;
self.include_untracked = true; self
}
pub fn with_keep_index(mut self) -> Self {
self.keep_index = true;
self
}
pub fn with_patch(mut self) -> Self {
self.patch = true;
self
}
pub fn with_staged_only(mut self) -> Self {
self.staged_only = true;
self
}
pub fn with_paths(mut self, paths: Vec<PathBuf>) -> Self {
self.paths = paths;
self
}
}
#[derive(Debug, Clone, Default)]
pub struct StashApplyOptions {
pub restore_index: bool,
pub quiet: bool,
}
impl StashApplyOptions {
pub fn new() -> Self {
Self::default()
}
pub fn with_index(mut self) -> Self {
self.restore_index = true;
self
}
pub fn with_quiet(mut self) -> Self {
self.quiet = true;
self
}
}
impl Repository {
pub fn stash_list(&self) -> Result<StashList> {
Self::ensure_git()?;
let output = git(
&["stash", "list", "--format=%gd %H %ct %gs"],
Some(self.repo_path()),
)?;
if output.trim().is_empty() {
return Ok(StashList::new(vec![]));
}
let mut stashes = Vec::new();
for (index, line) in output.lines().enumerate() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Ok(stash) = parse_stash_line(index, line) {
stashes.push(stash);
}
}
Ok(StashList::new(stashes))
}
pub fn stash_save(&self, message: &str) -> Result<Stash> {
let options = StashOptions::new();
self.stash_push(message, options)
}
pub fn stash_push(&self, message: &str, options: StashOptions) -> Result<Stash> {
Self::ensure_git()?;
let mut args = vec!["stash", "push"];
if options.include_all {
args.push("--all");
} else if options.include_untracked {
args.push("--include-untracked");
}
if options.keep_index {
args.push("--keep-index");
}
if options.patch {
args.push("--patch");
}
if options.staged_only {
args.push("--staged");
}
args.extend(&["-m", message]);
if !options.paths.is_empty() {
args.push("--");
for path in &options.paths {
if let Some(path_str) = path.to_str() {
args.push(path_str);
}
}
}
git(&args, Some(self.repo_path()))?;
let stashes = self.stash_list()?;
stashes.latest().cloned().ok_or_else(|| {
GitError::CommandFailed(
"Failed to create stash or retrieve stash information".to_string(),
)
})
}
pub fn stash_apply(&self, index: usize, options: StashApplyOptions) -> Result<()> {
Self::ensure_git()?;
let mut args = vec!["stash", "apply"];
if options.restore_index {
args.push("--index");
}
if options.quiet {
args.push("--quiet");
}
let stash_ref = format!("stash@{{{}}}", index);
args.push(&stash_ref);
git(&args, Some(self.repo_path()))?;
Ok(())
}
pub fn stash_pop(&self, index: usize, options: StashApplyOptions) -> Result<()> {
Self::ensure_git()?;
let mut args = vec!["stash", "pop"];
if options.restore_index {
args.push("--index");
}
if options.quiet {
args.push("--quiet");
}
let stash_ref = format!("stash@{{{}}}", index);
args.push(&stash_ref);
git(&args, Some(self.repo_path()))?;
Ok(())
}
pub fn stash_show(&self, index: usize) -> Result<String> {
Self::ensure_git()?;
let output = git(
&["stash", "show", &format!("stash@{{{}}}", index)],
Some(self.repo_path()),
)?;
Ok(output)
}
pub fn stash_drop(&self, index: usize) -> Result<()> {
Self::ensure_git()?;
git(
&["stash", "drop", &format!("stash@{{{}}}", index)],
Some(self.repo_path()),
)?;
Ok(())
}
pub fn stash_clear(&self) -> Result<()> {
Self::ensure_git()?;
git(&["stash", "clear"], Some(self.repo_path()))?;
Ok(())
}
}
fn parse_stash_line(index: usize, line: &str) -> Result<Stash> {
let parts: Vec<&str> = line.splitn(4, ' ').collect();
if parts.len() < 4 {
return Err(GitError::CommandFailed(format!(
"Invalid stash list format: expected 4 parts, got {}",
parts.len()
)));
}
let hash = Hash::from(parts[1]);
let timestamp = parse_unix_timestamp(parts[2]).unwrap_or_else(|_| {
DateTime::from_timestamp(0, 0).unwrap_or_else(Utc::now)
});
let remainder = parts[3];
if remainder.is_empty() {
return Err(GitError::CommandFailed(
"Invalid stash format: missing branch and message information".to_string(),
));
}
let (branch, message) = if let Some(colon_pos) = remainder.find(':') {
let branch_part = &remainder[..colon_pos];
let message_part = &remainder[colon_pos + 1..].trim();
let branch = if let Some(stripped) = branch_part.strip_prefix("On ") {
stripped.to_string()
} else if let Some(stripped) = branch_part.strip_prefix("WIP on ") {
stripped.to_string()
} else {
"unknown".to_string()
};
(branch, message_part.to_string())
} else {
("unknown".to_string(), remainder.to_string())
};
Ok(Stash {
index,
message,
hash,
branch,
timestamp,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
use std::fs;
fn create_test_repo() -> (Repository, std::path::PathBuf) {
use std::thread;
use std::time::{SystemTime, UNIX_EPOCH};
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let thread_id = format!("{:?}", thread::current().id());
let test_path = env::temp_dir().join(format!(
"rustic_git_stash_test_{}_{}_{}",
std::process::id(),
timestamp,
thread_id.replace("ThreadId(", "").replace(")", "")
));
if test_path.exists() {
fs::remove_dir_all(&test_path).unwrap();
}
let repo = Repository::init(&test_path, false).unwrap();
repo.config()
.set_user("Test User", "test@example.com")
.unwrap();
(repo, test_path)
}
fn create_test_commit(
repo: &Repository,
test_path: &std::path::Path,
filename: &str,
content: &str,
) {
fs::write(test_path.join(filename), content).unwrap();
repo.add(&[filename]).unwrap();
repo.commit(&format!("Add {}", filename)).unwrap();
}
#[test]
fn test_stash_list_empty_repository() {
let (repo, test_path) = create_test_repo();
let stashes = repo.stash_list().unwrap();
assert!(stashes.is_empty());
assert_eq!(stashes.len(), 0);
fs::remove_dir_all(&test_path).unwrap();
}
#[test]
fn test_stash_save_and_list() {
let (repo, test_path) = create_test_repo();
create_test_commit(&repo, &test_path, "initial.txt", "initial content");
fs::write(test_path.join("initial.txt"), "modified content").unwrap();
let stash = repo.stash_save("Test stash message").unwrap();
assert_eq!(stash.message, "Test stash message");
assert_eq!(stash.index, 0);
let stashes = repo.stash_list().unwrap();
assert_eq!(stashes.len(), 1);
assert!(stashes.latest().is_some());
assert_eq!(stashes.latest().unwrap().message, "Test stash message");
fs::remove_dir_all(&test_path).unwrap();
}
#[test]
fn test_stash_push_with_options() {
let (repo, test_path) = create_test_repo();
create_test_commit(&repo, &test_path, "initial.txt", "initial content");
fs::write(test_path.join("initial.txt"), "modified initial").unwrap(); fs::write(test_path.join("tracked.txt"), "tracked content").unwrap();
fs::write(test_path.join("untracked.txt"), "untracked content").unwrap();
repo.add(&["tracked.txt"]).unwrap();
let options = StashOptions::new().with_untracked().with_keep_index();
let stash = repo.stash_push("Stash with options", options).unwrap();
assert_eq!(stash.message, "Stash with options");
fs::remove_dir_all(&test_path).unwrap();
}
#[test]
fn test_stash_apply_and_pop() {
let (repo, test_path) = create_test_repo();
create_test_commit(&repo, &test_path, "initial.txt", "initial content");
fs::write(test_path.join("initial.txt"), "modified content").unwrap();
repo.stash_save("Test stash").unwrap();
let content = fs::read_to_string(test_path.join("initial.txt")).unwrap();
assert_eq!(content, "initial content");
repo.stash_apply(0, StashApplyOptions::new()).unwrap();
let content = fs::read_to_string(test_path.join("initial.txt")).unwrap();
assert_eq!(content, "modified content");
let stashes = repo.stash_list().unwrap();
assert_eq!(stashes.len(), 1);
fs::write(test_path.join("initial.txt"), "initial content").unwrap(); repo.stash_pop(0, StashApplyOptions::new()).unwrap();
let content = fs::read_to_string(test_path.join("initial.txt")).unwrap();
assert_eq!(content, "modified content");
let stashes = repo.stash_list().unwrap();
assert_eq!(stashes.len(), 0);
fs::remove_dir_all(&test_path).unwrap();
}
#[test]
fn test_stash_drop_and_clear() {
let (repo, test_path) = create_test_repo();
create_test_commit(&repo, &test_path, "initial.txt", "initial content");
for i in 1..=3 {
fs::write(test_path.join("initial.txt"), format!("content {}", i)).unwrap();
repo.stash_save(&format!("Stash {}", i)).unwrap();
}
let stashes = repo.stash_list().unwrap();
assert_eq!(stashes.len(), 3);
repo.stash_drop(1).unwrap();
let stashes = repo.stash_list().unwrap();
assert_eq!(stashes.len(), 2);
repo.stash_clear().unwrap();
let stashes = repo.stash_list().unwrap();
assert_eq!(stashes.len(), 0);
fs::remove_dir_all(&test_path).unwrap();
}
#[test]
fn test_stash_show() {
let (repo, test_path) = create_test_repo();
create_test_commit(&repo, &test_path, "initial.txt", "initial content");
fs::write(test_path.join("initial.txt"), "modified content").unwrap();
repo.stash_save("Test stash").unwrap();
let show_output = repo.stash_show(0).unwrap();
assert!(!show_output.is_empty());
fs::remove_dir_all(&test_path).unwrap();
}
#[test]
fn test_stash_list_filtering() {
let (repo, test_path) = create_test_repo();
create_test_commit(&repo, &test_path, "initial.txt", "initial content");
fs::write(test_path.join("initial.txt"), "content1").unwrap();
repo.stash_save("feature work in progress").unwrap();
fs::write(test_path.join("initial.txt"), "content2").unwrap();
repo.stash_save("bugfix temporary save").unwrap();
fs::write(test_path.join("initial.txt"), "content3").unwrap();
repo.stash_save("feature enhancement").unwrap();
let stashes = repo.stash_list().unwrap();
assert_eq!(stashes.len(), 3);
let feature_stashes: Vec<_> = stashes.find_containing("feature").collect();
assert_eq!(feature_stashes.len(), 2);
let bugfix_stashes: Vec<_> = stashes.find_containing("bugfix").collect();
assert_eq!(bugfix_stashes.len(), 1);
assert!(stashes.get(0).is_some());
assert!(stashes.get(10).is_none());
fs::remove_dir_all(&test_path).unwrap();
}
#[test]
fn test_stash_options_builder() {
let options = StashOptions::new()
.with_untracked()
.with_keep_index()
.with_paths(vec!["file1.txt".into(), "file2.txt".into()]);
assert!(options.include_untracked);
assert!(options.keep_index);
assert_eq!(options.paths.len(), 2);
let apply_options = StashApplyOptions::new().with_index().with_quiet();
assert!(apply_options.restore_index);
assert!(apply_options.quiet);
}
#[test]
fn test_stash_display() {
let stash = Stash {
index: 0,
message: "Test stash message".to_string(),
hash: Hash::from("abc123"),
branch: "main".to_string(),
timestamp: Utc::now(),
};
let display_str = format!("{}", stash);
assert!(display_str.contains("stash@{0}"));
assert!(display_str.contains("Test stash message"));
}
#[test]
fn test_parse_stash_line_invalid_format() {
let invalid_line = "stash@{0} abc123"; let result = parse_stash_line(0, invalid_line);
assert!(result.is_err());
if let Err(GitError::CommandFailed(msg)) = result {
assert!(msg.contains("Invalid stash list format"));
assert!(msg.contains("expected 4 parts"));
assert!(msg.contains("got 2"));
} else {
panic!("Expected CommandFailed error with specific message");
}
}
#[test]
fn test_parse_stash_line_empty_remainder() {
let invalid_line = "stash@{0} abc123 1234567890 "; let result = parse_stash_line(0, invalid_line);
assert!(result.is_err());
if let Err(GitError::CommandFailed(msg)) = result {
assert!(msg.contains("missing branch and message information"));
} else {
panic!("Expected CommandFailed error for empty remainder");
}
}
#[test]
fn test_parse_stash_line_valid_format() {
let valid_line = "stash@{0} abc123def456 1234567890 On master: test message";
let result = parse_stash_line(0, valid_line);
assert!(result.is_ok());
let stash = result.unwrap();
assert_eq!(stash.index, 0);
assert_eq!(stash.hash.as_str(), "abc123def456");
assert_eq!(stash.branch, "master");
assert_eq!(stash.message, "test message");
}
#[test]
fn test_parse_stash_line_with_invalid_timestamp() {
let line_with_invalid_timestamp =
"stash@{0} abc123def456 invalid-timestamp On master: test message";
let result = parse_stash_line(0, line_with_invalid_timestamp);
assert!(result.is_ok());
let stash = result.unwrap();
assert_eq!(stash.index, 0);
assert_eq!(stash.hash.as_str(), "abc123def456");
assert_eq!(stash.branch, "master");
assert_eq!(stash.message, "test message");
assert_eq!(stash.timestamp.timestamp(), 0); assert_eq!(stash.timestamp.format("%Y-%m-%d").to_string(), "1970-01-01");
}
}