workon/stash.rs
1//! Labeled autostash for stack-aware checkout.
2//!
3//! `refs/stash` is shared across all worktrees (it lives in the common git dir),
4//! so entries from every worktree appear in the same list. Entries are scoped by
5//! a label embedded in the stash message and matched on the exact
6//! `(branch, worktree)` pair:
7//!
8//! ```text
9//! workon-autostash: <branch> @ <worktree>
10//! ```
11//!
12//! This scheme requires no additional state file — the label is the only
13//! coordination mechanism. All operations require a `&mut Repository` opened on
14//! the **host worktree's path** (not the bare root), so that HEAD/index target
15//! that worktree's working directory.
16//!
17//! A clean apply drops the entry (the work now lives in the working tree); on
18//! conflict the entry is kept intact so the user can recover manually. No work
19//! is ever silently discarded.
20
21use git2::{Repository, Signature, StashFlags};
22
23use crate::error::{CheckoutError, Result};
24
25fn label(branch: &str, worktree: &str) -> String {
26 format!("workon-autostash: {} @ {}", branch, worktree)
27}
28
29/// Parse a stash message into its `(branch, worktree)` label pair.
30///
31/// Stash messages carry a git-generated `On <branch>: ` prefix before the
32/// label, so the label is located by marker rather than by position. The
33/// worktree is the part after the *last* ` @ `, tolerating branch names that
34/// contain the separator. Returns `None` for non-workon stash entries.
35fn parse_label(message: &str) -> Option<(&str, &str)> {
36 let (_, rest) = message.split_once("workon-autostash: ")?;
37 rest.rsplit_once(" @ ")
38}
39
40/// Create a labeled stash in `wt_repo` (a worktree-specific `&mut Repository`).
41///
42/// `branch` is the branch whose dirty state is being shelved; `worktree` is the
43/// host worktree name. Together they form the label used by [`apply_labeled_stash`].
44pub fn create_labeled_stash(
45 wt_repo: &mut Repository,
46 branch: &str,
47 worktree: &str,
48) -> Result<git2::Oid> {
49 let sig = wt_repo
50 .signature()
51 .or_else(|_| Signature::now("workon", "workon@localhost"))
52 .map_err(CheckoutError::Git)?;
53 let msg = label(branch, worktree);
54 wt_repo
55 .stash_save2(&sig, Some(&msg), Some(StashFlags::INCLUDE_UNTRACKED))
56 .map_err(CheckoutError::Git)
57 .map_err(Into::into)
58}
59
60/// Find the stash-list index for the `(branch, worktree)` entry.
61///
62/// Returns `None` when no matching entry exists.
63pub fn find_labeled_stash(
64 repo: &mut Repository,
65 branch: &str,
66 worktree: &str,
67) -> Result<Option<usize>> {
68 let mut found: Option<usize> = None;
69 repo.stash_foreach(|index, message, _oid| {
70 if found.is_none() && parse_label(message) == Some((branch, worktree)) {
71 found = Some(index);
72 }
73 true
74 })
75 .map_err(CheckoutError::Git)?;
76 Ok(found)
77}
78
79/// Outcome of [`apply_labeled_stash`].
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum StashRestore {
82 /// The stash was applied cleanly and the entry dropped — the work now
83 /// lives in the working tree, so keeping the entry would re-apply it on
84 /// every subsequent restore.
85 Applied,
86 /// The apply conflicted; the stash entry is kept intact for manual
87 /// recovery. Conflict markers may be present in the working tree (same
88 /// behavior as `git stash apply`).
89 Conflict,
90 /// No stash entry matched the label.
91 NotFound,
92}
93
94/// Apply the stash labeled for `(branch, worktree)`, dropping it on a clean apply.
95///
96/// On `Conflict` the entry is kept so the user can recover manually. The
97/// caller is responsible for user-facing messaging.
98pub fn apply_labeled_stash(
99 repo: &mut Repository,
100 branch: &str,
101 worktree: &str,
102) -> Result<StashRestore> {
103 let Some(index) = find_labeled_stash(repo, branch, worktree)? else {
104 return Ok(StashRestore::NotFound);
105 };
106 match repo.stash_apply(index, None) {
107 Ok(()) => {
108 // A merge conflict is NOT an error: stash_apply writes conflict
109 // markers, leaves a conflicted index, and returns success. Check
110 // the index before dropping, or the only recovery copy is lost.
111 if repo.index().map_err(CheckoutError::Git)?.has_conflicts() {
112 return Ok(StashRestore::Conflict);
113 }
114 repo.stash_drop(index).map_err(CheckoutError::Git)?;
115 Ok(StashRestore::Applied)
116 }
117 // GIT_ECONFLICT: dirty local files block the apply; GIT_EMERGECONFLICT:
118 // the merge itself cannot proceed. Both keep the entry intact.
119 Err(e)
120 if matches!(
121 e.code(),
122 git2::ErrorCode::Conflict | git2::ErrorCode::MergeConflict
123 ) =>
124 {
125 Ok(StashRestore::Conflict)
126 }
127 Err(e) => Err(CheckoutError::Git(e).into()),
128 }
129}
130
131/// List all labeled stash entries belonging to `worktree`.
132///
133/// Used by the prune command (PR-4) to warn about orphaned stashes before a
134/// worktree is removed.
135pub fn list_labeled_for_worktree(repo: &mut Repository, worktree: &str) -> Result<Vec<String>> {
136 let mut entries = Vec::new();
137 repo.stash_foreach(|_index, message, _oid| {
138 if parse_label(message).is_some_and(|(_, wt)| wt == worktree) {
139 entries.push(message.to_string());
140 }
141 true
142 })
143 .map_err(CheckoutError::Git)?;
144 Ok(entries)
145}