use std::path::Path;
use std::sync::Arc;
use anyhow::{Result, bail};
use git2::{ObjectType, Oid, Repository, TreeWalkMode, TreeWalkResult};
use lds_core::Session;
use crate::output::{
StashAbortOutput, StashApplyOutput, StashEntry, StashFinalizeOutput, StashListOutput,
StashRestoreOutput, StashShowOutput,
};
use crate::read::blocking;
use crate::{GitModule, TIMEOUT_LOCAL, git_cmd, git_cmd_combined};
impl GitModule {
pub async fn stash_list(&self) -> Result<StashListOutput> {
let session = Arc::clone(&self.session);
blocking(move || stash_list_sync(&session)).await
}
pub async fn stash_show(&self, index: usize) -> Result<StashShowOutput> {
let session = Arc::clone(&self.session);
blocking(move || stash_show_sync(&session, index)).await
}
pub async fn stash_apply(
&self,
working_dir: &Path,
index: usize,
expected_sha: Option<String>,
) -> Result<StashApplyOutput> {
self.ensure_session_scope(working_dir)?;
let entry = self.stash_entry_at(index).await?;
ensure_sha_matches(&entry, expected_sha.as_deref())?;
let (staged, unstaged) = dirty_paths(working_dir).await?;
if !staged.is_empty() || !unstaged.is_empty() {
bail!(
"stash apply refused: working tree must be clean (staged: [{}], unstaged: [{}]). \
Commit the changes first, or park them with git_worktree_add — mixing hand edits \
with a stash apply makes an abort impossible to do safely. Untracked files are \
allowed.",
staged.join(", "),
unstaged.join(", "),
);
}
let detail = self.stash_show(index).await?;
let collisions: Vec<&String> = detail
.untracked_paths
.iter()
.filter(|p| working_dir.join(p).exists())
.collect();
if !collisions.is_empty() {
bail!(
"stash apply refused: stash@{{{index}}} carries untracked files that already \
exist in the working tree: {}. Move or remove them first (git would abort \
mid-apply and leave the tree half-restored).",
collisions
.iter()
.map(|p| p.as_str())
.collect::<Vec<_>>()
.join(", "),
);
}
let spec = stash_spec(index);
if let Err(e) = git_cmd_combined(
working_dir,
&["stash", "apply", spec.as_str()],
TIMEOUT_LOCAL,
)
.await
{
rollback_paths(working_dir, &detail.files, &detail.untracked_paths).await?;
bail!(
"stash apply failed and was rolled back (working tree is back at HEAD; \
stash@{{{index}}} sha={} is intact and nothing was dropped): {e}",
entry.sha,
);
}
Ok(StashApplyOutput {
index,
sha: entry.sha,
applied_paths: detail.files,
restored_untracked: detail.untracked_paths,
entry_kept: true,
})
}
pub async fn stash_abort(
&self,
working_dir: &Path,
index: usize,
expected_sha: Option<String>,
) -> Result<StashAbortOutput> {
self.ensure_session_scope(working_dir)?;
let entry = self.stash_entry_at(index).await?;
ensure_sha_matches(&entry, expected_sha.as_deref())?;
let detail = self.stash_show(index).await?;
let report = rollback_paths(working_dir, &detail.files, &detail.untracked_paths).await?;
Ok(StashAbortOutput {
index,
sha: entry.sha,
reverted_paths: report.reverted,
removed_untracked: report.removed_untracked,
entry_kept: true,
})
}
pub async fn stash_finalize(
&self,
working_dir: &Path,
index: usize,
expected_sha: Option<String>,
) -> Result<StashFinalizeOutput> {
self.ensure_session_scope(working_dir)?;
let entry = self.stash_entry_at(index).await?;
ensure_sha_matches(&entry, expected_sha.as_deref())?;
let spec = stash_spec(index);
git_cmd(
working_dir,
&["stash", "drop", spec.as_str()],
TIMEOUT_LOCAL,
)
.await?;
Ok(StashFinalizeOutput {
index,
dropped_sha: entry.sha,
message: entry.message,
})
}
pub async fn stash_restore(
&self,
working_dir: &Path,
sha: &str,
message: Option<String>,
) -> Result<StashRestoreOutput> {
self.ensure_session_scope(working_dir)?;
let sha = sha.trim().to_string();
ensure_sha_shape(&sha)?;
let session = Arc::clone(&self.session);
let probe_sha = sha.clone();
let probe = blocking(move || resolve_commit_sync(&session, &probe_sha)).await?;
if probe.parent_count < 2 {
bail!(
"stash restore refused: {} is not a stash commit ({} parent(s); a stash commit \
has at least 2). Only shas produced by git_stash_finalize / git stash push can \
be restored.",
probe.sha,
probe.parent_count,
);
}
let list = self.stash_list().await?;
if let Some(existing) = list.stashes.iter().find(|e| e.sha == probe.sha) {
bail!(
"stash restore refused: {} is already present at stash@{{{}}} — restoring it \
again would put the same content in the list twice.",
probe.sha,
existing.index,
);
}
let message = message
.map(|m| m.trim().to_string())
.filter(|m| !m.is_empty())
.unwrap_or(probe.summary);
git_cmd(
working_dir,
&["stash", "store", "-m", message.as_str(), probe.sha.as_str()],
TIMEOUT_LOCAL,
)
.await?;
Ok(StashRestoreOutput {
restored_sha: probe.sha,
index: 0,
message,
})
}
pub(crate) async fn stash_entry_at(&self, index: usize) -> Result<StashEntry> {
let list = self.stash_list().await?;
let total = list.stashes.len();
list.stashes
.into_iter()
.find(|e| e.index == index)
.ok_or_else(|| {
anyhow::anyhow!("no stash entry at index {index} ({total} entr(y|ies) present)")
})
}
}
fn stash_spec(index: usize) -> String {
format!("stash@{{{index}}}")
}
fn ensure_sha_matches(entry: &StashEntry, expected: Option<&str>) -> Result<()> {
let Some(expected) = expected.map(str::trim).filter(|s| !s.is_empty()) else {
return Ok(());
};
if expected.len() < 7 {
bail!("expected_sha {expected:?} is too short (need at least 7 hex chars)");
}
if !entry.sha.starts_with(expected) {
bail!(
"stash index shifted: stash@{{{}}} is now {} (expected {expected}). \
Re-read git_stash_list and retry with the current index.",
entry.index,
entry.sha,
);
}
Ok(())
}
fn ensure_sha_shape(sha: &str) -> Result<()> {
if sha.len() < 7 {
bail!("sha {sha:?} is too short (need at least 7 hex chars)");
}
if !sha.chars().all(|c| c.is_ascii_hexdigit()) {
bail!(
"sha {sha:?} is not an object id — revspecs (HEAD, branch names, HEAD@{{1}}) are \
rejected here on purpose; pass the dropped_sha reported by git_stash_finalize."
);
}
Ok(())
}
pub(crate) async fn dirty_paths(working_dir: &Path) -> Result<(Vec<String>, Vec<String>)> {
let staged = git_cmd(
working_dir,
&["diff", "--cached", "--name-only", "-z"],
TIMEOUT_LOCAL,
)
.await?;
let unstaged = git_cmd(working_dir, &["diff", "--name-only", "-z"], TIMEOUT_LOCAL).await?;
Ok((split_nul(&staged), split_nul(&unstaged)))
}
struct RollbackReport {
reverted: Vec<String>,
removed_untracked: Vec<String>,
}
async fn rollback_paths(
working_dir: &Path,
tracked: &[String],
untracked: &[String],
) -> Result<RollbackReport> {
git_cmd(working_dir, &["reset", "--mixed", "HEAD"], TIMEOUT_LOCAL).await?;
let in_head = paths_in_head(working_dir, tracked).await?;
if !in_head.is_empty() {
let mut args = vec!["checkout", "-f", "HEAD", "--"];
args.extend(in_head.iter().map(|s| s.as_str()));
git_cmd(working_dir, &args, TIMEOUT_LOCAL).await?;
}
for path in tracked.iter().filter(|p| !in_head.contains(p)) {
remove_worktree_file(working_dir, path);
}
let mut removed_untracked = Vec::new();
for path in untracked {
if working_dir.join(path).exists() {
remove_worktree_file(working_dir, path);
removed_untracked.push(path.clone());
}
}
Ok(RollbackReport {
reverted: tracked.to_vec(),
removed_untracked,
})
}
async fn paths_in_head(working_dir: &Path, paths: &[String]) -> Result<Vec<String>> {
if paths.is_empty() {
return Ok(Vec::new());
}
let mut args = vec!["ls-tree", "-r", "-z", "--name-only", "HEAD", "--"];
args.extend(paths.iter().map(|s| s.as_str()));
let raw = git_cmd(working_dir, &args, TIMEOUT_LOCAL).await?;
Ok(split_nul(&raw))
}
fn remove_worktree_file(working_dir: &Path, rel_path: &str) {
let path = working_dir.join(rel_path);
match std::fs::remove_file(&path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
tracing::warn!(error = %e, path = %path.display(), "stash rollback: remove failed");
}
}
}
fn split_nul(raw: &str) -> Vec<String> {
raw.split('\0')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect()
}
fn stash_list_sync(session: &Session) -> Result<StashListOutput> {
let mut repo = Repository::open(session.root())?;
let raw = collect_stash_refs(&mut repo)?;
let mut stashes = Vec::with_capacity(raw.len());
for (index, message, oid) in raw {
let commit = repo.find_commit(oid)?;
stashes.push(StashEntry {
index,
sha: oid.to_string(),
message,
has_untracked: commit.parent_count() >= 3,
});
}
Ok(StashListOutput { stashes })
}
fn stash_show_sync(session: &Session, index: usize) -> Result<StashShowOutput> {
let mut repo = Repository::open(session.root())?;
let raw = collect_stash_refs(&mut repo)?;
let total = raw.len();
let (_, message, oid) = raw
.into_iter()
.find(|(i, _, _)| *i == index)
.ok_or_else(|| anyhow::anyhow!("no stash entry at index {index} ({total} present)"))?;
let commit = repo.find_commit(oid)?;
let stash_tree = commit.tree()?;
let base_tree = commit.parent(0)?.tree()?;
let diff = repo.diff_tree_to_tree(Some(&base_tree), Some(&stash_tree), None)?;
let file_count = diff.deltas().len();
let mut files = Vec::with_capacity(file_count);
for delta in diff.deltas() {
let path = delta
.new_file()
.path()
.or_else(|| delta.old_file().path())
.map(|p| p.to_string_lossy().to_string());
if let Some(path) = path {
files.push(path);
}
}
let mut patch = String::new();
diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
let origin = line.origin();
if matches!(origin, '+' | '-' | ' ') {
patch.push(origin);
}
patch.push_str(std::str::from_utf8(line.content()).unwrap_or(""));
true
})?;
let untracked_paths = if commit.parent_count() >= 3 {
collect_tree_paths(&commit.parent(2)?.tree()?)?
} else {
Vec::new()
};
Ok(StashShowOutput {
index,
sha: oid.to_string(),
message,
patch,
file_count,
files,
untracked_paths,
})
}
struct CommitProbe {
sha: String,
summary: String,
parent_count: usize,
}
fn resolve_commit_sync(session: &Session, sha: &str) -> Result<CommitProbe> {
let repo = Repository::open(session.root())?;
let object = repo.revparse_single(sha).map_err(|e| {
anyhow::anyhow!(
"cannot resolve {sha}: {e}. A dropped stash commit stays in the object database \
only until `git gc` prunes it — if gc has run since the drop, the content is gone."
)
})?;
let commit = object
.peel_to_commit()
.map_err(|e| anyhow::anyhow!("{sha} does not resolve to a commit: {e}"))?;
Ok(CommitProbe {
sha: commit.id().to_string(),
summary: commit.summary().unwrap_or_default().to_string(),
parent_count: commit.parent_count(),
})
}
fn collect_stash_refs(repo: &mut Repository) -> Result<Vec<(usize, String, Oid)>> {
let mut out = Vec::new();
repo.stash_foreach(|index, message, oid| {
out.push((index, message.to_string(), *oid));
true
})?;
Ok(out)
}
fn collect_tree_paths(tree: &git2::Tree<'_>) -> Result<Vec<String>> {
let mut paths = Vec::new();
tree.walk(TreeWalkMode::PreOrder, |root, entry| {
if entry.kind() == Some(ObjectType::Blob) {
paths.push(format!("{root}{}", entry.name().unwrap_or_default()));
}
TreeWalkResult::Ok
})?;
Ok(paths)
}