//! Stashes are standard two-parent Git commits with a refs/stash reflog. use crate::{ Result, branches, changes, checkout, cherry_pick, local, output, repo::error, staging, }; use gix::{ bstr::ByteSlice, refs::{ Target, transaction::{Change, LogChange, PreviousValue, RefEdit}, }, }; fn log_path(repo: &gix::Repository) -> std::path::PathBuf { repo.common_dir().join("logs/refs/stash") } fn logs(repo: &gix::Repository) -> Result> { match std::fs::read_to_string(log_path(repo)) { Ok(text) => Ok(text.lines().map(str::to_owned).collect()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(vec![]), Err(e) => Err(error("stash log", e)), } } fn commit( repo: &gix::Repository, tree: gix::ObjectId, parents: Vec, message: &str, ) -> Result { let sig = staging::signature(None, None)?; repo.write_object(&gix::objs::Commit { tree, parents: parents.into_iter().collect(), author: sig.clone(), committer: sig, encoding: None, message: message.into(), extra_headers: vec![], }) .map(|id| id.detach()) .map_err(|e| error("stash commit", e)) } fn update(repo: &gix::Repository, id: gix::ObjectId, message: &str) -> Result<()> { branches::edit_refs( repo, vec![RefEdit { name: "refs/stash".try_into().unwrap(), deref: false, change: Change::Update { expected: PreviousValue::Any, new: Target::Object(id), log: LogChange { message: message.into(), force_create_reflog: true, ..Default::default() }, }, }], )?; Ok(()) } fn drop_entry(repo: &gix::Repository, n: usize, id: gix::ObjectId, quiet: bool) -> Result<()> { let mut rows = logs(repo)?; let offset = rows .len() .checked_sub(n + 1) .ok_or_else(|| error("stash", "no such stash"))?; rows.remove(offset); if rows.is_empty() { repo.find_reference("refs/stash") .map_err(|e| error("stash ref", e))? .delete() .map_err(|e| error("stash drop", e))?; } else { let tip = rows.last().unwrap().split_whitespace().nth(1).unwrap(); update( repo, gix::ObjectId::from_hex(tip.as_bytes()).map_err(|e| error("stash id", e))?, "stash drop", )?; std::fs::write(log_path(repo), format!("{}\n", rows.join("\n"))) .map_err(|e| error("stash log", e))?; } if !quiet { println!("Dropped refs/stash@{{{n}}} ({id})"); } Ok(()) } pub(crate) fn run( repo: &gix::Repository, command: &str, message: Option, entry: Option, quiet: bool, ) -> Result { if !matches!(command, "push" | "list" | "apply" | "pop" | "drop") { return Err(error("stash", "unsupported subcommand")); } if command == "push" { crate::merge::ensure_idle(repo)?; let status = changes::status(repo)?; if status.staged.is_empty() && status.unstaged.is_empty() { if !quiet { println!("No local changes to save"); } return Ok(0); } let head = branches::resolve(repo, "HEAD")?; let original = repo.find_commit(head).map_err(|e| error("stash HEAD", e))?; let subject = original .decode() .map_err(|e| error("stash message", e))? .message .to_str_lossy() .lines() .next() .unwrap_or("") .to_owned(); let branch = crate::head_info(repo)? .branch .unwrap_or_else(|| "(no branch)".into()) .trim_start_matches("refs/heads/") .to_owned(); let description = format!("{branch}: {} {subject}", &head.to_string()[..7]); let index = repo.index_or_empty().map_err(|e| error("stash index", e))?; let index_tree = staging::tree_from_index(repo, &index)?; let index_commit = commit( repo, index_tree, vec![head], &format!("index on {description}\n"), )?; let work_tree = staging::tracked_tree(repo)?; let message = message .map(|m| format!("On {branch}: {m}")) .unwrap_or_else(|| format!("WIP on {description}")); let stash = commit(repo, work_tree, vec![head, index_commit], &message)?; update(repo, stash, &message)?; checkout::write_tree_to_worktree(repo, None, local::tree(repo, "HEAD")?, true)?; if !quiet { println!("Saved working directory and index state {message}"); } return Ok(0); } let rows = logs(repo)?; if command == "list" { for (n, row) in rows.iter().rev().enumerate() { println!( "stash@{{{n}}}: {}", row.split_once('\t').map(|(_, m)| m).unwrap_or("") ); } return Ok(0); } let n = entry .as_deref() .map(|s| { s.strip_prefix("stash@{") .and_then(|s| s.strip_suffix('}')) .unwrap_or(s) .parse::() .map_err(|e| error("stash entry", e)) }) .transpose()? .unwrap_or(0); let Some(row) = rows.iter().rev().nth(n) else { eprintln!("No stash entries found."); return Ok(1); }; let id = gix::ObjectId::from_hex(row.split_whitespace().nth(1).unwrap().as_bytes()) .map_err(|e| error("stash id", e))?; if command == "drop" { drop_entry(repo, n, id, quiet)?; return Ok(0); } if !matches!(command, "apply" | "pop") { eprintln!("usage: git stash [push|list|pop|apply|drop]"); return Ok(129); } let stash = repo.find_commit(id).map_err(|e| error("stash commit", e))?; let decoded = stash.decode().map_err(|e| error("stash decode", e))?; let base = local::tree(repo, &decoded.parents[0].to_string())?; let old_index = repo.index_or_empty().map_err(|e| error("stash index", e))?; let ours = staging::tracked_tree(repo)?; let (merged, mut index, conflicts) = cherry_pick::merge_trees( repo, base, ours, decoded.tree(), "Updated upstream", "Stashed changes", )?; checkout::write_tree_to_worktree(repo, Some(ours), merged, true)?; if conflicts.is_empty() { // Without --index, tracked modifications remain unstaged. Stashed additions stay staged. let base_index = gix::index::State::from_tree(&base, &repo.objects, Default::default()) .map_err(|e| error("stash base", e))?; let added = index .entries() .iter() .filter(|e| { !base_index .entries() .iter() .any(|b| b.path(&base_index) == e.path(&index)) }) .map(|e| e.path(&index).to_str_lossy().into_owned()) .collect::>(); index.remove_entries(|_, path, _| !added.contains(path.to_str_lossy().as_ref())); for e in old_index.entries() { if !added.contains(e.path(&old_index).to_str_lossy().as_ref()) { index.dangerously_push_entry(e.stat, e.id, e.flags, e.mode, e.path(&old_index)); } } } staging::publish(repo, index)?; let repo = crate::repo::open(&repo.workdir().unwrap().to_string_lossy())?; output::conflicts(&conflicts, false); if !quiet { output::long_status(&changes::status(&repo)?); } if !conflicts.is_empty() { return Ok(1); } if command == "pop" { drop_entry(&repo, n, id, quiet)?; } Ok(0) }