1use 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 #[arg(long, value_enum, default_value = "default", global = true)]
30 format: StashFormat,
31}
32
33#[derive(Debug, Parser)]
34struct SaveOpts {
35 #[arg(short, long, default_value = "")]
37 message: String,
38}
39
40#[derive(Debug, Subcommand)]
41enum StashCmd {
42 Save(SaveOpts),
44 List,
46 Pop {
48 #[arg(long = "index")]
51 restore_index: bool,
52 #[arg(default_value = "0")]
53 index: String,
54 },
55 Apply {
57 #[arg(long = "index")]
60 restore_index: bool,
61 #[arg(default_value = "0")]
62 index: String,
63 },
64 Clear,
66 Drop {
68 #[arg(default_value = "0")]
69 index: String,
70 },
71 Show {
73 #[arg(default_value = "0")]
74 index: String,
75 },
76}
77
78fn 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 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 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 let code = dispatch(opts.sub, &store, &layout, json);
148 drop(lock);
149 code
150}
151
152fn 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#[allow(clippy::too_many_lines)] fn 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 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 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 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 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
314fn 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 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 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 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 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
395fn 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
430fn 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}