1use std::path::Path;
36use std::sync::Arc;
37
38use anyhow::{Result, bail};
39use git2::{ObjectType, Oid, Repository, TreeWalkMode, TreeWalkResult};
40use lds_core::Session;
41
42use crate::output::{
43 StashAbortOutput, StashApplyOutput, StashEntry, StashFinalizeOutput, StashListOutput,
44 StashRestoreOutput, StashShowOutput,
45};
46use crate::read::blocking;
47use crate::{GitModule, TIMEOUT_LOCAL, git_cmd, git_cmd_combined};
48
49impl GitModule {
50 pub async fn stash_list(&self) -> Result<StashListOutput> {
56 let session = Arc::clone(&self.session);
57 blocking(move || stash_list_sync(&session)).await
58 }
59
60 pub async fn stash_show(&self, index: usize) -> Result<StashShowOutput> {
66 let session = Arc::clone(&self.session);
67 blocking(move || stash_show_sync(&session, index)).await
68 }
69
70 pub async fn stash_apply(
88 &self,
89 working_dir: &Path,
90 index: usize,
91 expected_sha: Option<String>,
92 ) -> Result<StashApplyOutput> {
93 self.ensure_session_scope(working_dir)?;
94
95 let entry = self.stash_entry_at(index).await?;
96 ensure_sha_matches(&entry, expected_sha.as_deref())?;
97
98 let (staged, unstaged) = dirty_paths(working_dir).await?;
99 if !staged.is_empty() || !unstaged.is_empty() {
100 bail!(
101 "stash apply refused: working tree must be clean (staged: [{}], unstaged: [{}]). \
102 Commit the changes first, or park them with git_worktree_add — mixing hand edits \
103 with a stash apply makes an abort impossible to do safely. Untracked files are \
104 allowed.",
105 staged.join(", "),
106 unstaged.join(", "),
107 );
108 }
109
110 let detail = self.stash_show(index).await?;
111 let collisions: Vec<&String> = detail
112 .untracked_paths
113 .iter()
114 .filter(|p| working_dir.join(p).exists())
115 .collect();
116 if !collisions.is_empty() {
117 bail!(
118 "stash apply refused: stash@{{{index}}} carries untracked files that already \
119 exist in the working tree: {}. Move or remove them first (git would abort \
120 mid-apply and leave the tree half-restored).",
121 collisions
122 .iter()
123 .map(|p| p.as_str())
124 .collect::<Vec<_>>()
125 .join(", "),
126 );
127 }
128
129 let spec = stash_spec(index);
130 if let Err(e) = git_cmd_combined(
131 working_dir,
132 &["stash", "apply", spec.as_str()],
133 TIMEOUT_LOCAL,
134 )
135 .await
136 {
137 rollback_paths(working_dir, &detail.files, &detail.untracked_paths).await?;
138 bail!(
139 "stash apply failed and was rolled back (working tree is back at HEAD; \
140 stash@{{{index}}} sha={} is intact and nothing was dropped): {e}",
141 entry.sha,
142 );
143 }
144
145 Ok(StashApplyOutput {
146 index,
147 sha: entry.sha,
148 applied_paths: detail.files,
149 restored_untracked: detail.untracked_paths,
150 entry_kept: true,
151 })
152 }
153
154 pub async fn stash_abort(
163 &self,
164 working_dir: &Path,
165 index: usize,
166 expected_sha: Option<String>,
167 ) -> Result<StashAbortOutput> {
168 self.ensure_session_scope(working_dir)?;
169
170 let entry = self.stash_entry_at(index).await?;
171 ensure_sha_matches(&entry, expected_sha.as_deref())?;
172
173 let detail = self.stash_show(index).await?;
174 let report = rollback_paths(working_dir, &detail.files, &detail.untracked_paths).await?;
175
176 Ok(StashAbortOutput {
177 index,
178 sha: entry.sha,
179 reverted_paths: report.reverted,
180 removed_untracked: report.removed_untracked,
181 entry_kept: true,
182 })
183 }
184
185 pub async fn stash_finalize(
193 &self,
194 working_dir: &Path,
195 index: usize,
196 expected_sha: Option<String>,
197 ) -> Result<StashFinalizeOutput> {
198 self.ensure_session_scope(working_dir)?;
199
200 let entry = self.stash_entry_at(index).await?;
201 ensure_sha_matches(&entry, expected_sha.as_deref())?;
202
203 let spec = stash_spec(index);
204 git_cmd(
205 working_dir,
206 &["stash", "drop", spec.as_str()],
207 TIMEOUT_LOCAL,
208 )
209 .await?;
210
211 Ok(StashFinalizeOutput {
212 index,
213 dropped_sha: entry.sha,
214 message: entry.message,
215 })
216 }
217
218 pub async fn stash_restore(
242 &self,
243 working_dir: &Path,
244 sha: &str,
245 message: Option<String>,
246 ) -> Result<StashRestoreOutput> {
247 self.ensure_session_scope(working_dir)?;
248
249 let sha = sha.trim().to_string();
250 ensure_sha_shape(&sha)?;
251
252 let session = Arc::clone(&self.session);
253 let probe_sha = sha.clone();
254 let probe = blocking(move || resolve_commit_sync(&session, &probe_sha)).await?;
255
256 if probe.parent_count < 2 {
259 bail!(
260 "stash restore refused: {} is not a stash commit ({} parent(s); a stash commit \
261 has at least 2). Only shas produced by git_stash_finalize / git stash push can \
262 be restored.",
263 probe.sha,
264 probe.parent_count,
265 );
266 }
267
268 let list = self.stash_list().await?;
269 if let Some(existing) = list.stashes.iter().find(|e| e.sha == probe.sha) {
270 bail!(
271 "stash restore refused: {} is already present at stash@{{{}}} — restoring it \
272 again would put the same content in the list twice.",
273 probe.sha,
274 existing.index,
275 );
276 }
277
278 let message = message
279 .map(|m| m.trim().to_string())
280 .filter(|m| !m.is_empty())
281 .unwrap_or(probe.summary);
282
283 git_cmd(
284 working_dir,
285 &["stash", "store", "-m", message.as_str(), probe.sha.as_str()],
286 TIMEOUT_LOCAL,
287 )
288 .await?;
289
290 Ok(StashRestoreOutput {
291 restored_sha: probe.sha,
292 index: 0,
293 message,
294 })
295 }
296
297 pub(crate) async fn stash_entry_at(&self, index: usize) -> Result<StashEntry> {
300 let list = self.stash_list().await?;
301 let total = list.stashes.len();
302 list.stashes
303 .into_iter()
304 .find(|e| e.index == index)
305 .ok_or_else(|| {
306 anyhow::anyhow!("no stash entry at index {index} ({total} entr(y|ies) present)")
307 })
308 }
309}
310
311fn stash_spec(index: usize) -> String {
314 format!("stash@{{{index}}}")
315}
316
317fn ensure_sha_matches(entry: &StashEntry, expected: Option<&str>) -> Result<()> {
321 let Some(expected) = expected.map(str::trim).filter(|s| !s.is_empty()) else {
322 return Ok(());
323 };
324 if expected.len() < 7 {
325 bail!("expected_sha {expected:?} is too short (need at least 7 hex chars)");
326 }
327 if !entry.sha.starts_with(expected) {
328 bail!(
329 "stash index shifted: stash@{{{}}} is now {} (expected {expected}). \
330 Re-read git_stash_list and retry with the current index.",
331 entry.index,
332 entry.sha,
333 );
334 }
335 Ok(())
336}
337
338fn ensure_sha_shape(sha: &str) -> Result<()> {
345 if sha.len() < 7 {
346 bail!("sha {sha:?} is too short (need at least 7 hex chars)");
347 }
348 if !sha.chars().all(|c| c.is_ascii_hexdigit()) {
349 bail!(
350 "sha {sha:?} is not an object id — revspecs (HEAD, branch names, HEAD@{{1}}) are \
351 rejected here on purpose; pass the dropped_sha reported by git_stash_finalize."
352 );
353 }
354 Ok(())
355}
356
357pub(crate) async fn dirty_paths(working_dir: &Path) -> Result<(Vec<String>, Vec<String>)> {
365 let staged = git_cmd(
366 working_dir,
367 &["diff", "--cached", "--name-only", "-z"],
368 TIMEOUT_LOCAL,
369 )
370 .await?;
371 let unstaged = git_cmd(working_dir, &["diff", "--name-only", "-z"], TIMEOUT_LOCAL).await?;
372 Ok((split_nul(&staged), split_nul(&unstaged)))
373}
374
375struct RollbackReport {
377 reverted: Vec<String>,
379 removed_untracked: Vec<String>,
381}
382
383async fn rollback_paths(
389 working_dir: &Path,
390 tracked: &[String],
391 untracked: &[String],
392) -> Result<RollbackReport> {
393 git_cmd(working_dir, &["reset", "--mixed", "HEAD"], TIMEOUT_LOCAL).await?;
397
398 let in_head = paths_in_head(working_dir, tracked).await?;
399 if !in_head.is_empty() {
400 let mut args = vec!["checkout", "-f", "HEAD", "--"];
401 args.extend(in_head.iter().map(|s| s.as_str()));
402 git_cmd(working_dir, &args, TIMEOUT_LOCAL).await?;
403 }
404
405 for path in tracked.iter().filter(|p| !in_head.contains(p)) {
409 remove_worktree_file(working_dir, path);
410 }
411
412 let mut removed_untracked = Vec::new();
413 for path in untracked {
414 if working_dir.join(path).exists() {
415 remove_worktree_file(working_dir, path);
416 removed_untracked.push(path.clone());
417 }
418 }
419
420 Ok(RollbackReport {
421 reverted: tracked.to_vec(),
422 removed_untracked,
423 })
424}
425
426async fn paths_in_head(working_dir: &Path, paths: &[String]) -> Result<Vec<String>> {
428 if paths.is_empty() {
429 return Ok(Vec::new());
430 }
431 let mut args = vec!["ls-tree", "-r", "-z", "--name-only", "HEAD", "--"];
432 args.extend(paths.iter().map(|s| s.as_str()));
433 let raw = git_cmd(working_dir, &args, TIMEOUT_LOCAL).await?;
434 Ok(split_nul(&raw))
435}
436
437fn remove_worktree_file(working_dir: &Path, rel_path: &str) {
442 let path = working_dir.join(rel_path);
443 match std::fs::remove_file(&path) {
444 Ok(()) => {}
445 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
446 Err(e) => {
447 tracing::warn!(error = %e, path = %path.display(), "stash rollback: remove failed");
448 }
449 }
450}
451
452fn split_nul(raw: &str) -> Vec<String> {
454 raw.split('\0')
455 .filter(|s| !s.is_empty())
456 .map(|s| s.to_string())
457 .collect()
458}
459
460fn stash_list_sync(session: &Session) -> Result<StashListOutput> {
461 let mut repo = Repository::open(session.root())?;
462 let raw = collect_stash_refs(&mut repo)?;
463
464 let mut stashes = Vec::with_capacity(raw.len());
465 for (index, message, oid) in raw {
466 let commit = repo.find_commit(oid)?;
467 stashes.push(StashEntry {
468 index,
469 sha: oid.to_string(),
470 message,
471 has_untracked: commit.parent_count() >= 3,
474 });
475 }
476 Ok(StashListOutput { stashes })
477}
478
479fn stash_show_sync(session: &Session, index: usize) -> Result<StashShowOutput> {
480 let mut repo = Repository::open(session.root())?;
481 let raw = collect_stash_refs(&mut repo)?;
482 let total = raw.len();
483 let (_, message, oid) = raw
484 .into_iter()
485 .find(|(i, _, _)| *i == index)
486 .ok_or_else(|| anyhow::anyhow!("no stash entry at index {index} ({total} present)"))?;
487
488 let commit = repo.find_commit(oid)?;
489 let stash_tree = commit.tree()?;
490 let base_tree = commit.parent(0)?.tree()?;
493
494 let diff = repo.diff_tree_to_tree(Some(&base_tree), Some(&stash_tree), None)?;
495 let file_count = diff.deltas().len();
496
497 let mut files = Vec::with_capacity(file_count);
498 for delta in diff.deltas() {
499 let path = delta
500 .new_file()
501 .path()
502 .or_else(|| delta.old_file().path())
503 .map(|p| p.to_string_lossy().to_string());
504 if let Some(path) = path {
505 files.push(path);
506 }
507 }
508
509 let mut patch = String::new();
510 diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
511 let origin = line.origin();
512 if matches!(origin, '+' | '-' | ' ') {
513 patch.push(origin);
514 }
515 patch.push_str(std::str::from_utf8(line.content()).unwrap_or(""));
516 true
517 })?;
518
519 let untracked_paths = if commit.parent_count() >= 3 {
522 collect_tree_paths(&commit.parent(2)?.tree()?)?
523 } else {
524 Vec::new()
525 };
526
527 Ok(StashShowOutput {
528 index,
529 sha: oid.to_string(),
530 message,
531 patch,
532 file_count,
533 files,
534 untracked_paths,
535 })
536}
537
538struct CommitProbe {
541 sha: String,
543 summary: String,
546 parent_count: usize,
547}
548
549fn resolve_commit_sync(session: &Session, sha: &str) -> Result<CommitProbe> {
555 let repo = Repository::open(session.root())?;
556 let object = repo.revparse_single(sha).map_err(|e| {
557 anyhow::anyhow!(
558 "cannot resolve {sha}: {e}. A dropped stash commit stays in the object database \
559 only until `git gc` prunes it — if gc has run since the drop, the content is gone."
560 )
561 })?;
562 let commit = object
563 .peel_to_commit()
564 .map_err(|e| anyhow::anyhow!("{sha} does not resolve to a commit: {e}"))?;
565
566 Ok(CommitProbe {
567 sha: commit.id().to_string(),
568 summary: commit.summary().unwrap_or_default().to_string(),
569 parent_count: commit.parent_count(),
570 })
571}
572
573fn collect_stash_refs(repo: &mut Repository) -> Result<Vec<(usize, String, Oid)>> {
578 let mut out = Vec::new();
579 repo.stash_foreach(|index, message, oid| {
580 out.push((index, message.to_string(), *oid));
581 true
582 })?;
583 Ok(out)
584}
585
586fn collect_tree_paths(tree: &git2::Tree<'_>) -> Result<Vec<String>> {
588 let mut paths = Vec::new();
589 tree.walk(TreeWalkMode::PreOrder, |root, entry| {
590 if entry.kind() == Some(ObjectType::Blob) {
591 paths.push(format!("{root}{}", entry.name().unwrap_or_default()));
593 }
594 TreeWalkResult::Ok
595 })?;
596 Ok(paths)
597}