Skip to main content

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}