Files
sausage/userland/git/src/stash.rs
T

228 lines
7.8 KiB
Rust

//! 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<Vec<String>> {
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<gix::ObjectId>,
message: &str,
) -> Result<gix::ObjectId> {
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<String>,
entry: Option<String>,
quiet: bool,
) -> Result<i32> {
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::<usize>()
.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::<std::collections::BTreeSet<_>>();
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)
}