Skip to main content

mkit_cli/commands/
stash.rs

1//! `mkit stash save|list|pop|drop|show` — stash working-directory
2//! changes. Backing logic lives in `mkit_core::ops::stash`.
3
4use std::io::Write;
5
6use clap::{Parser, Subcommand, ValueEnum};
7use mkit_core::layout::RepoLayout;
8use mkit_core::ops::stash;
9use mkit_core::store::ObjectStore;
10
11use crate::clap_shim;
12use crate::exit;
13use crate::format::{self, JsonObject};
14
15#[derive(Debug, Clone, Copy, ValueEnum)]
16enum StashFormat {
17    Default,
18    Json,
19}
20
21#[derive(Debug, Parser)]
22#[command(name = "mkit stash", about = "Stash working-directory changes.")]
23struct StashOpts {
24    #[command(subcommand)]
25    sub: StashCmd,
26    /// Emit a machine-readable JSON result object to stdout. On `list`
27    /// this is JSONL (one `{"index":N,"hash":"<hex>","message":"..."}`
28    /// per entry); every other subcommand emits one outcome object.
29    #[arg(long, value_enum, default_value = "default", global = true)]
30    format: StashFormat,
31}
32
33#[derive(Debug, Parser)]
34struct SaveOpts {
35    /// Stash message.
36    #[arg(short, long, default_value = "")]
37    message: String,
38}
39
40#[derive(Debug, Subcommand)]
41enum StashCmd {
42    /// Save the current worktree changes as a new stash entry.
43    Save(SaveOpts),
44    /// List all stash entries.
45    List,
46    /// Apply and remove a stash entry (default: entry 0).
47    Pop {
48        /// Also restore the staged state recorded by the stash (like
49        /// `git stash pop --index`), not just the worktree changes.
50        #[arg(long = "index")]
51        restore_index: bool,
52        #[arg(default_value = "0")]
53        index: String,
54    },
55    /// Apply a stash entry WITHOUT removing it (default: entry 0).
56    Apply {
57        /// Also restore the staged state recorded by the stash (like
58        /// `git stash apply --index`), not just the worktree changes.
59        #[arg(long = "index")]
60        restore_index: bool,
61        #[arg(default_value = "0")]
62        index: String,
63    },
64    /// Remove ALL stash entries.
65    Clear,
66    /// Remove a stash entry without applying it (default: entry 0).
67    Drop {
68        #[arg(default_value = "0")]
69        index: String,
70    },
71    /// Show the diff of a stash entry (default: entry 0).
72    Show {
73        #[arg(default_value = "0")]
74        index: String,
75    },
76}
77
78/// Parse a stash entry reference. Accepts a bare index (`2`) or git's
79/// `stash@{N}` revision syntax (`stash@{2}`) — the same spelling `stash
80/// list` prints, so users can copy it back verbatim.
81fn parse_stash_index(spec: &str) -> Result<usize, String> {
82    let core = spec
83        .strip_prefix("stash@{")
84        .and_then(|rest| rest.strip_suffix('}'))
85        .unwrap_or(spec);
86    core.parse::<usize>()
87        .map_err(|_| format!("invalid stash reference '{spec}' (expected N or stash@{{N}})"))
88}
89
90#[must_use]
91pub fn run(args: &[String]) -> u8 {
92    // `mkit stash` (no args) = save with empty message.
93    // `mkit stash -m <msg>` = save with message.
94    // Either is the "save is the default subcommand" form, which
95    // clap doesn't model directly; rewrite the argv so clap sees an
96    // explicit `save` subcommand when the user omitted it.
97    let needs_default = args.first().is_none_or(|a| {
98        !matches!(
99            a.as_str(),
100            "save" | "list" | "pop" | "apply" | "drop" | "clear" | "show" | "-h" | "--help"
101        )
102    });
103    let rewritten: Vec<String> = if needs_default {
104        std::iter::once("save".to_owned())
105            .chain(args.iter().cloned())
106            .collect()
107    } else {
108        args.to_vec()
109    };
110
111    let opts = match clap_shim::parse::<StashOpts>("mkit stash", &rewritten) {
112        Ok(o) => o,
113        Err(code) => return code,
114    };
115    let cwd = match std::env::current_dir() {
116        Ok(p) => p,
117        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
118    };
119    let layout = match super::resolve_layout(&cwd) {
120        Ok(layout) => layout,
121        Err(code) => return code,
122    };
123    let store = match super::open_store_configured(&layout) {
124        Ok(s) => s,
125        Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
126    };
127
128    // Commands that mutate the worktree/index/manifest must serialise
129    // against other worktree commands: `save`/`pop`/`apply`/`drop`/`clear`.
130    // (`apply` writes the worktree; `clear` rewrites the manifest.)
131    // `list` and `show` are read-only and run unlocked.
132    let lock = match opts.sub {
133        StashCmd::Save(_)
134        | StashCmd::Pop { .. }
135        | StashCmd::Apply { .. }
136        | StashCmd::Drop { .. }
137        | StashCmd::Clear => match super::acquire_worktree_lock(&layout) {
138            Ok(l) => Some(l),
139            Err(code) => return code,
140        },
141        StashCmd::List | StashCmd::Show { .. } => None,
142    };
143
144    let json = matches!(opts.format, StashFormat::Json);
145    // `lock` is held until this binding drops at the end of `run`, so the
146    // worktree stays serialised across the whole `dispatch` call.
147    let code = dispatch(opts.sub, &store, &layout, json);
148    drop(lock);
149    code
150}
151
152/// `error(msg, code)` plus, when `json` is set, a `{"ok":false,...}`
153/// line on stdout.
154fn emit_err_json(msg: &str, code: u8, json: bool) -> u8 {
155    if json {
156        let mut obj = JsonObject::new();
157        obj.field_bool("ok", false).field_str("error", msg);
158        let mut stdout = std::io::stdout().lock();
159        let _ = writeln!(stdout, "{}", obj.finish());
160    }
161    emit_err(msg, code)
162}
163
164/// Run a parsed stash subcommand. Split out of [`run`] so the worktree
165/// lock acquisition / mode dispatch stays small enough for clippy's
166/// `too_many_lines`.
167#[allow(clippy::too_many_lines)] // linear per-subcommand dispatch, plus --format=json branches
168fn dispatch(sub: StashCmd, store: &ObjectStore, layout: &RepoLayout, json: bool) -> u8 {
169    let emit_err = |msg: &str, code: u8| emit_err_json(msg, code, json);
170    match sub {
171        StashCmd::Save(save) => {
172            // git stores the descriptor in the stash message itself, so a
173            // no-message save records the auto `WIP on <branch>: <hash>
174            // <subject>` line and `stash list` shows it verbatim. An
175            // explicit message records `On <branch>: <message>`.
176            let branch = super::head_branch_name(layout);
177            let effective = if save.message.is_empty() {
178                match head_descriptor(store, layout) {
179                    Some((short, subject)) => format!("WIP on {branch}: {short} {subject}"),
180                    None => format!("WIP on {branch}"),
181                }
182            } else {
183                format!("On {branch}: {}", save.message)
184            };
185            match stash::save(store, layout, &effective) {
186                Ok(()) => {
187                    let mut stderr = std::io::stderr().lock();
188                    let _ = writeln!(
189                        stderr,
190                        "Saved working directory and index state {effective}"
191                    );
192                    drop(stderr);
193                    if json {
194                        let mut obj = JsonObject::new();
195                        obj.field_bool("ok", true)
196                            .field_str("kind", "save")
197                            .field_str("message", &effective);
198                        let mut stdout = std::io::stdout().lock();
199                        let _ = writeln!(stdout, "{}", obj.finish());
200                    }
201                    exit::OK
202                }
203                Err(e) => emit_err(&format!("stash save: {e}"), exit::GENERAL_ERROR),
204            }
205        }
206        StashCmd::List => match stash::list(layout) {
207            Ok(list) => {
208                // git prints nothing for an empty stash; one
209                // `stash@{N}: <message>` line per entry otherwise (no hash
210                // column, matching git). `--format=json` emits the same
211                // set as JSONL, mirroring `branch --format=json`.
212                let mut stdout = std::io::stdout().lock();
213                for (i, e) in list.entries.iter().enumerate() {
214                    if json {
215                        let mut obj = JsonObject::new();
216                        obj.field_u64("index", i as u64)
217                            .field_hash("hash", &e.commit_hash)
218                            .field_str("message", &e.message);
219                        let _ = writeln!(stdout, "{}", obj.finish());
220                    } else {
221                        let _ = writeln!(stdout, "stash@{{{i}}}: {}", e.message);
222                    }
223                }
224                exit::OK
225            }
226            Err(e) => emit_err(&format!("stash list: {e}"), exit::GENERAL_ERROR),
227        },
228        // `pop` removes the entry after a successful restore; `apply`
229        // leaves it in place. Both run the same #205/#176 destructive-
230        // restore guard so they never clobber uncommitted edits on
231        // unrelated paths.
232        StashCmd::Pop {
233            index,
234            restore_index,
235        } => match parse_stash_index(&index) {
236            Ok(i) => restore_entry(store, layout, i, true, restore_index, json),
237            Err(e) => emit_err(&e, exit::USAGE),
238        },
239        StashCmd::Apply {
240            index,
241            restore_index,
242        } => match parse_stash_index(&index) {
243            Ok(i) => restore_entry(store, layout, i, false, restore_index, json),
244            Err(e) => emit_err(&e, exit::USAGE),
245        },
246        StashCmd::Clear => match stash::clear(layout) {
247            Ok(()) => {
248                let mut stderr = std::io::stderr().lock();
249                let _ = writeln!(stderr, "cleared all stash entries");
250                drop(stderr);
251                if json {
252                    let mut obj = JsonObject::new();
253                    obj.field_bool("ok", true).field_str("kind", "clear");
254                    let mut stdout = std::io::stdout().lock();
255                    let _ = writeln!(stdout, "{}", obj.finish());
256                }
257                exit::OK
258            }
259            Err(e) => emit_err(&format!("stash clear: {e}"), exit::GENERAL_ERROR),
260        },
261        StashCmd::Drop { index } => match parse_stash_index(&index) {
262            Ok(i) => {
263                // Capture the entry id before removal for git's
264                // `Dropped stash@{N} (<id>)` confirmation.
265                let was = stash::list(layout)
266                    .ok()
267                    .and_then(|l| l.entries.get(i).map(|e| e.commit_hash));
268                match stash::drop(layout, i) {
269                    Ok(()) => {
270                        let mut stderr = std::io::stderr().lock();
271                        match was {
272                            Some(h) => {
273                                let _ = writeln!(
274                                    stderr,
275                                    "Dropped refs/stash@{{{i}}} ({})",
276                                    format::short_hash(&h, format::SUMMARY_ABBREV)
277                                );
278                            }
279                            None => {
280                                let _ = writeln!(stderr, "Dropped refs/stash@{{{i}}}");
281                            }
282                        }
283                        drop(stderr);
284                        if json {
285                            let mut obj = JsonObject::new();
286                            obj.field_bool("ok", true)
287                                .field_str("kind", "drop")
288                                .field_u64("index", i as u64)
289                                .field_opt_hash("hash", was.as_ref());
290                            let mut stdout = std::io::stdout().lock();
291                            let _ = writeln!(stdout, "{}", obj.finish());
292                        }
293                        exit::OK
294                    }
295                    Err(e) => emit_err(&format!("stash drop: {e}"), exit::GENERAL_ERROR),
296                }
297            }
298            Err(e) => emit_err(&e, exit::USAGE),
299        },
300        StashCmd::Show { index } => match parse_stash_index(&index) {
301            Ok(i) => match stash::render_stash_show(store, layout, i) {
302                Ok(output) => {
303                    let mut stdout = std::io::stdout().lock();
304                    let _ = stdout.write_all(output.as_bytes());
305                    exit::OK
306                }
307                Err(e) => emit_err(&format!("stash show: {e}"), exit::GENERAL_ERROR),
308            },
309            Err(e) => emit_err(&e, exit::USAGE),
310        },
311    }
312}
313
314/// Restore stash entry `index` into the worktree. `drop_entry` chooses
315/// between `pop` (removes the entry after a clean restore) and `apply`
316/// (leaves it on the stack). Both run the #205/#176 destructive-restore
317/// guard up-front so a refusal leaves the stash and worktree untouched.
318fn restore_entry(
319    store: &ObjectStore,
320    layout: &RepoLayout,
321    index: usize,
322    drop_entry: bool,
323    restore_index: bool,
324    json: bool,
325) -> u8 {
326    let emit_err = |msg: &str, code: u8| emit_err_json(msg, code, json);
327    let verb = if drop_entry { "pop" } else { "apply" };
328    // Empty stash → git's `No stash entries found.` (exit 1).
329    let entries = stash::list(layout).map(|l| l.entries).unwrap_or_default();
330    if entries.is_empty() {
331        return emit_err("No stash entries found.", exit::GENERAL_ERROR);
332    }
333    let entry_hash = entries.get(index).map(|e| e.commit_hash);
334    let tree_hash = match stash::entry_tree_hash(store, layout, index) {
335        Ok(h) => h,
336        Err(e) => return emit_err(&format!("stash {verb}: {e}"), exit::GENERAL_ERROR),
337    };
338    if let Err(e) = super::ensure_restore_safe(layout, store, tree_hash) {
339        return emit_err(&format!("stash {verb}: {e}"), exit::GENERAL_ERROR);
340    }
341
342    // Without `--index`, keep the original behavior: `pop` restores the
343    // worktree, records recovery, and drops the entry; `apply` restores and
344    // keeps it.
345    if !restore_index {
346        let result = if drop_entry {
347            stash::pop(store, layout, index)
348        } else {
349            stash::apply(store, layout, index)
350        };
351        return match result {
352            Ok(()) => {
353                report_restore(drop_entry, index, entry_hash, json);
354                exit::OK
355            }
356            Err(e) => emit_err(&format!("stash {verb}: {e}"), exit::GENERAL_ERROR),
357        };
358    }
359
360    // `--index`: restore the worktree (keeping the entry), then re-stage the
361    // recorded index, and only THEN drop the entry (for `pop`). Sequencing
362    // the entry removal last means a failure in the index rewrite leaves the
363    // stash in place for a normal retry, rather than dropping it with the
364    // index half-restored.
365    let snapshot_index = match stash::entry_index(store, layout, index) {
366        Ok(i) => i,
367        Err(e) => return emit_err(&format!("stash {verb}: {e}"), exit::GENERAL_ERROR),
368    };
369    if let Err(e) = stash::apply(store, layout, index) {
370        return emit_err(&format!("stash {verb}: {e}"), exit::GENERAL_ERROR);
371    }
372    if let Some(restored) = snapshot_index {
373        // Write the exact recorded index — preserving staged deletions, which
374        // a tree round-trip would drop.
375        if let Err(e) = mkit_core::index::write_index(layout, &restored) {
376            return emit_err(
377                &format!("stash {verb}: restore index: {e}"),
378                exit::GENERAL_ERROR,
379            );
380        }
381    } else {
382        let mut stderr = std::io::stderr().lock();
383        let _ = writeln!(
384            stderr,
385            "note: this stash has no recorded index state; --index had no effect"
386        );
387    }
388    if drop_entry && let Err(e) = stash::pop_finalize(layout, index) {
389        return emit_err(&format!("stash {verb}: {e}"), exit::GENERAL_ERROR);
390    }
391    report_restore(drop_entry, index, entry_hash, json);
392    exit::OK
393}
394
395/// git-shaped post-restore line: `pop` reports `Dropped refs/stash@{N}
396/// (<id>)` (the entry was removed); `apply` reports it stays on the stack.
397/// When `json` is set, also emits the `--format=json` outcome object.
398fn report_restore(
399    drop_entry: bool,
400    index: usize,
401    entry_hash: Option<mkit_core::hash::Hash>,
402    json: bool,
403) {
404    let entry_short = entry_hash.map(|h| format::short_hash(&h, format::SUMMARY_ABBREV));
405    let mut stderr = std::io::stderr().lock();
406    if drop_entry {
407        match entry_short.as_deref() {
408            Some(id) => {
409                let _ = writeln!(stderr, "Dropped refs/stash@{{{index}}} ({id})");
410            }
411            None => {
412                let _ = writeln!(stderr, "Dropped refs/stash@{{{index}}}");
413            }
414        }
415    } else {
416        let _ = writeln!(stderr, "Applied stash@{{{index}}} (kept on the stack)");
417    }
418    drop(stderr);
419    if json {
420        let mut obj = JsonObject::new();
421        obj.field_bool("ok", true)
422            .field_str("kind", if drop_entry { "pop" } else { "apply" })
423            .field_u64("index", index as u64)
424            .field_opt_hash("hash", entry_hash.as_ref());
425        let mut stdout = std::io::stdout().lock();
426        let _ = writeln!(stdout, "{}", obj.finish());
427    }
428}
429
430/// `(short-hash, subject)` of the current HEAD commit, for git's auto
431/// stash message. `None` when HEAD is unborn or unreadable.
432fn head_descriptor(store: &ObjectStore, layout: &RepoLayout) -> Option<(String, String)> {
433    let head = mkit_core::refs::resolve_head(layout).ok().flatten()?;
434    let subject = match store.read_object(&head).ok()? {
435        mkit_core::object::Object::Commit(c) => String::from_utf8_lossy(&c.message)
436            .lines()
437            .next()
438            .unwrap_or("")
439            .to_owned(),
440        _ => String::new(),
441    };
442    Some((format::short_hash(&head, format::SUMMARY_ABBREV), subject))
443}
444
445use super::error as emit_err;
446
447#[cfg(test)]
448mod tests {
449    use super::parse_stash_index;
450
451    #[test]
452    fn parses_bare_index() {
453        assert_eq!(parse_stash_index("0").unwrap(), 0);
454        assert_eq!(parse_stash_index("3").unwrap(), 3);
455    }
456
457    #[test]
458    fn parses_stash_at_brace_syntax() {
459        assert_eq!(parse_stash_index("stash@{0}").unwrap(), 0);
460        assert_eq!(parse_stash_index("stash@{12}").unwrap(), 12);
461    }
462
463    #[test]
464    fn rejects_malformed_references() {
465        assert!(parse_stash_index("stash@{}").is_err());
466        assert!(parse_stash_index("stash@{x}").is_err());
467        assert!(parse_stash_index("-1").is_err());
468        assert!(parse_stash_index("stash@{1").is_err());
469    }
470}