Skip to main content

lds_git/
stash.rs

1//! Stash as an explicit transaction, not a one-shot restore.
2//!
3//! The rest of this crate routes "park my work" through worktrees on
4//! purpose, so a stash entry that shows up here is almost always a human's
5//! own `git stash push`. The failure mode this module exists to prevent is
6//! an agent doing `stash pop` → conflict → `reset --hard`, which throws that
7//! work away leaving nothing to recover from. The surface is therefore split
8//! so that no single call can both restore and discard:
9//!
10//! * [`GitModule::stash_apply`] restores content and *keeps* the entry. It
11//!   refuses to run unless the working tree is clean, so the applied change
12//!   is never mixed with hand edits and a rollback is always total.
13//! * [`GitModule::stash_abort`] puts the touched paths back to HEAD.
14//! * [`GitModule::stash_finalize`] drops the entry and reports its sha.
15//!
16//! Between apply and finalize there is room for an actual verification step
17//! — that gap is the whole point.
18//!
19//! Even the drop is reversible for a while: [`GitModule::stash_restore`] puts
20//! a finalized entry back on `refs/stash` from the `dropped_sha` that
21//! `stash_finalize` returned, and stays usable until `git gc` prunes the now
22//! unreferenced commit.
23//!
24//! **Permanently out of scope.** `stash push`, `stash pop`, a bare
25//! `stash drop`, and `stash clear` are not offered and are not planned:
26//!
27//! * `push` — parking work belongs to [`GitModule::worktree_add`].
28//! * `pop` — apply + drop fused together, with the drop landing *before*
29//!   anyone can check the apply. Use `stash_apply` then `stash_finalize`.
30//! * bare `drop` / `clear` — discard entries without surfacing what was
31//!   discarded, i.e. exactly the "it's just gone" outcome above.
32//!   `stash_finalize` is the supported drop and always returns
33//!   [`StashFinalizeOutput::dropped_sha`].
34
35use 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    /// List every stash entry, newest first.
51    ///
52    /// Read-only, so no ownership check — the entries belong to the
53    /// repository, not to a session. Runs the libgit2 walk on the
54    /// [`tokio::task::spawn_blocking`] pool.
55    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    /// Show what `stash@{index}` would restore: the patch against the commit
61    /// the stash was taken on, plus the untracked paths it carries.
62    ///
63    /// Read-only; nothing is applied. Runs the libgit2 diff on the
64    /// [`tokio::task::spawn_blocking`] pool.
65    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    /// Restore `stash@{index}` into `working_dir` **without dropping it**.
71    ///
72    /// Preconditions, all of them refusals rather than best-effort merges:
73    ///
74    /// 1. `expected_sha` (when given) must match the entry at `index`. The
75    ///    index shifts on every drop, the sha does not — pass it whenever
76    ///    the caller resolved the entry in an earlier turn.
77    /// 2. `working_dir` must have no staged and no unstaged changes.
78    ///    Untracked files are fine. This is what makes the rollback below
79    ///    total: everything the working tree gains came from the stash, so
80    ///    undoing the apply can never eat a hand edit.
81    /// 3. None of the entry's untracked paths may already exist on disk —
82    ///    git would refuse halfway through and leave a partial apply.
83    ///
84    /// On failure (conflict, or anything else `git stash apply` reports) the
85    /// working tree is rolled back automatically and the error says so. The
86    /// entry is never touched, so a failed apply costs nothing.
87    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    /// Undo an applied stash: every path `stash@{index}` touches goes back to
155    /// its HEAD state, and the entry itself is left alone.
156    ///
157    /// This is a path-scoped revert, not a diff-aware one — edits made on top
158    /// of the applied stash are discarded together with it. That's the
159    /// deliberate trade for [`GitModule::stash_apply`]'s clean-tree
160    /// precondition: within that contract, "back to HEAD" and "undo the
161    /// apply" are the same thing.
162    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    /// Drop `stash@{index}` once its content has been verified.
186    ///
187    /// The sha is read *before* the drop and returned as
188    /// [`StashFinalizeOutput::dropped_sha`]: the stash commit stays reachable
189    /// until `git gc` prunes it, so [`GitModule::stash_restore`] can put the
190    /// entry back. Callers should record it — that record is the difference
191    /// between "dropped" and "lost".
192    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    /// Put a dropped stash commit back on `refs/stash`.
219    ///
220    /// `sha` is the [`StashFinalizeOutput::dropped_sha`] of an earlier
221    /// finalize (or any stash commit sha). Dropping only removes the reflog
222    /// entry — the commit itself lingers, unreferenced, until `git gc` prunes
223    /// it, and this call re-references it.
224    ///
225    /// Guarded so `refs/stash` cannot be turned into a dumping ground:
226    ///
227    /// 1. `sha` must be >= 7 hex chars. Revspecs (`HEAD`, branch names,
228    ///    `HEAD@{1}`) are rejected — restoring is only ever about an object id
229    ///    somebody wrote down.
230    /// 2. The object must resolve. When it doesn't, `git gc` is the likely
231    ///    reason and the error says so.
232    /// 3. The commit must be stash-shaped (>= 2 parents). Storing an ordinary
233    ///    commit would produce an entry whose "apply" means something nobody
234    ///    intended.
235    /// 4. The entry must not already be in the list — a second reference to
236    ///    the same content is a footgun, not a restore.
237    ///
238    /// `message` overrides the reflog message; when omitted the stash
239    /// commit's own summary is reused, which is what the entry was called
240    /// before it was dropped.
241    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        // A stash commit always has at least 2 parents (HEAD + index state),
257        // plus a 3rd for the untracked snapshot.
258        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    /// Resolve `stash@{index}` to its [`StashEntry`], erroring when the index
298    /// is out of range (the common shape of "an entry was dropped under me").
299    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
311/// `stash@{N}` — kept in one place because the brace escaping is easy to get
312/// wrong in a `format!`.
313fn stash_spec(index: usize) -> String {
314    format!("stash@{{{index}}}")
315}
316
317/// Verify that the entry the caller *thinks* they are acting on is the entry
318/// that currently sits at that index. A prefix of at least 7 chars is
319/// accepted, matching git's own short-sha convention.
320fn 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
338/// Accept only what an object id can look like: >= 7 hex chars, nothing else.
339///
340/// Rejecting revspecs (`HEAD`, `main`, `HEAD@{1}`) matters because
341/// [`GitModule::stash_restore`] feeds this straight into `revparse_single` —
342/// a caller who passes a branch name means something the restore path cannot
343/// honour, and silently resolving it would store a non-stash commit.
344fn 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
357/// `(staged, unstaged)` path lists for `working_dir`.
358///
359/// Uses `git diff --name-only` rather than `--porcelain` because
360/// [`git_cmd`] trims stdout, which would eat the leading status column of a
361/// ` M path` line. Untracked files are deliberately absent: they don't
362/// conflict with an apply unless the stash carries the same path, which
363/// [`GitModule::stash_apply`] checks separately.
364pub(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
375/// What [`rollback_paths`] actually did, so callers can report it verbatim.
376struct RollbackReport {
377    /// Tracked paths returned to their HEAD state.
378    reverted: Vec<String>,
379    /// Untracked paths removed from the working tree.
380    removed_untracked: Vec<String>,
381}
382
383/// Return the paths a stash entry touches to their HEAD state.
384///
385/// Safe as a *whole-tree* rollback only because
386/// [`GitModule::stash_apply`] guarantees the tree was clean beforehand —
387/// there is nothing else in those paths that could be destroyed.
388async fn rollback_paths(
389    working_dir: &Path,
390    tracked: &[String],
391    untracked: &[String],
392) -> Result<RollbackReport> {
393    // Drop whatever the apply put in the index — including the unmerged
394    // entries a conflict leaves behind, which would otherwise block the
395    // checkout below.
396    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    // Paths the entry *added* have no HEAD state to restore. After the
406    // `reset --mixed` they are plain untracked files, so removing them is
407    // the whole job.
408    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
426/// Subset of `paths` that exists in the HEAD tree.
427async 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
437/// Best-effort worktree file removal. A missing file is the desired end
438/// state, so `NotFound` is success; anything else is warned about rather
439/// than propagated — the caller is already in a rollback path and a partial
440/// cleanup must not mask the original error.
441fn 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
452/// Split nul-delimited git output (`-z`), dropping the trailing empty record.
453fn 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            // A stash commit has 2 parents normally (HEAD + index state) and
472            // a 3rd holding the untracked snapshot when pushed with `-u`.
473            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    // parent(0) is the commit HEAD pointed at when the stash was created —
491    // diffing against it is what `git stash show -p` reports.
492    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    // The untracked snapshot is a standalone commit in the 3rd parent slot;
520    // its tree *is* the untracked file set, so a plain walk enumerates it.
521    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
538/// What [`GitModule::stash_restore`] needs to know about a candidate commit
539/// before it is allowed near `refs/stash`.
540struct CommitProbe {
541    /// Full 40-char sha (the caller may have passed a prefix).
542    sha: String,
543    /// First line of the commit message — the reflog message a stash entry
544    /// carried before it was dropped.
545    summary: String,
546    parent_count: usize,
547}
548
549/// Resolve `sha` to a commit without touching any ref.
550///
551/// A dropped stash commit is unreferenced but still in the object database,
552/// which is exactly what makes restore possible — and what makes `git gc` the
553/// deadline.
554fn 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
573/// `(index, message, oid)` for every stash entry, in reflog order.
574///
575/// `stash_foreach` needs `&mut Repository`, so the tuples are collected
576/// eagerly and the borrow released before the caller inspects commits.
577fn 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
586/// Every blob path in `tree`, repo-relative.
587fn 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            // `root` is "" at the top level and "dir/" below it.
592            paths.push(format!("{root}{}", entry.name().unwrap_or_default()));
593        }
594        TreeWalkResult::Ok
595    })?;
596    Ok(paths)
597}