git_policy_cli/command_stash.rs
1//! What:
2//! Which `git stash` subcommand a region runs in Git 2.56.0,
3//! and where wrapper-only
4//! flags sit in it.
5//! Why:
6//! The linked-worktree policy guards stash;
7//! its escape hatch must be found in option
8//! position of the subcommand that actually reads the arguments,
9//! so a message or a
10//! path that spells the hatch is forwarded untouched.
11//!
12//! In TS you'd write (pseudocode):
13//! ```ts
14//! // parseStashRegion(['push', '-m', '--no-enforce-worktree']).hasEscapeHatch === false
15//! ```
16
17use super::command_options::{
18 OptionError, OptionErrorKind, OptionSpec, ParseMode, ParsedOptions, WrapperOccurrence,
19 parse_options,
20};
21use super::command_options_query::{WrapperFlags, split_wrapper_flags};
22use super::command_stash_table::{
23 STASH_APPLY_TABLE, STASH_DROP_TABLE, STASH_EMPTY_TABLE, STASH_EXPORT_TABLE,
24 STASH_KEEP_UNKNOWN_MODE, STASH_PLAIN_MODE, STASH_POP_TABLE, STASH_PUSH_TABLE, STASH_SAVE_TABLE,
25 STASH_SHOW_TABLE, STASH_STOP_MODE, STASH_STORE_TABLE,
26};
27/// What:
28/// Bring the shared hatch spelling,
29/// the tokenizer,
30/// its questions and the stash tables
31/// into this file.
32/// Why:
33/// This module only dispatches as `cmd_stash` does and interprets the tokenizer.
34///
35/// In TS you'd write (pseudocode):
36/// ```ts
37/// import { parseOptions } from './command_options.ts';
38/// ```
39use super::escape_hatch::WORKTREE_ENFORCEMENT_ESCAPE_HATCH;
40/// `OsString` is owned operating-system text of raw bytes (sibling `String` must be UTF-8).
41use std::ffi::OsString;
42
43/// What:
44/// The subcommand `cmd_stash` dispatches to (builtin/stash.c:2465-2479).
45/// An `enum` is
46/// a closed set of named alternatives.
47/// Why:
48/// `AssumedPush` is the form without a subcommand word,
49/// which Git parses as `push`
50/// with stricter flags (2515-2528).
51///
52/// In TS you'd write (pseudocode):
53/// ```ts
54/// type StashSubcommand = 'apply' | 'clear' | /* ... */ | 'assumed-push';
55/// ```
56#[derive(Clone, Copy, Debug, Eq, PartialEq)]
57pub enum StashSubcommand {
58 /// `git stash apply`.
59 Apply,
60 /// `git stash clear`.
61 Clear,
62 /// `git stash drop`.
63 Drop,
64 /// `git stash pop`.
65 Pop,
66 /// `git stash branch`.
67 Branch,
68 /// `git stash list`.
69 List,
70 /// `git stash show`.
71 Show,
72 /// `git stash store`.
73 Store,
74 /// `git stash create`:
75 /// every argument is message text.
76 Create,
77 /// `git stash push`.
78 Push,
79 /// `git stash export`.
80 Export,
81 /// `git stash import`.
82 Import,
83 /// `git stash save`,
84 /// the legacy spelling of `push`.
85 Save,
86 /// `git stash` with no subcommand word.
87 AssumedPush,
88}
89
90/// What:
91/// Facts of one `git stash` region.
92/// Why:
93/// The shipped policy guards every stash form;
94/// the subcommand is reported for its
95/// diagnostics and the hatch for its bypass.
96///
97/// In TS you'd write (pseudocode):
98/// ```ts
99/// type StashRegion = { hasEscapeHatch: boolean };
100/// ```
101#[derive(Clone, Debug, Eq, PartialEq)]
102pub struct StashRegion {
103 /// The subcommand that reads the arguments.
104 pub subcommand: StashSubcommand,
105 /// Wrapper-only flags in option position;
106 /// `escape` is `--no-enforce-worktree`.
107 pub wrapper: WrapperFlags,
108}
109
110/// Subcommand words in `cmd_stash` table order,
111/// each with the subcommand it selects.
112const SUBCOMMAND_WORDS: &[(&str, StashSubcommand)] = &[
113 ("apply", StashSubcommand::Apply),
114 ("clear", StashSubcommand::Clear),
115 ("drop", StashSubcommand::Drop),
116 ("pop", StashSubcommand::Pop),
117 ("branch", StashSubcommand::Branch),
118 ("list", StashSubcommand::List),
119 ("show", StashSubcommand::Show),
120 ("store", StashSubcommand::Store),
121 ("create", StashSubcommand::Create),
122 ("push", StashSubcommand::Push),
123 ("export", StashSubcommand::Export),
124 ("import", StashSubcommand::Import),
125 ("save", StashSubcommand::Save),
126];
127
128/// What:
129/// The subcommand a word selects,
130/// or nothing.
131/// `&[u8]` borrows the word's bytes.
132/// Why:
133/// Git compares the whole word exactly (`parse_subcommand`,
134/// parse-options.c:609-619).
135///
136/// In TS you'd write (pseudocode):
137/// ```ts
138/// const subcommand = SUBCOMMAND_WORDS.find(([word]) => word === token)?.[1];
139/// ```
140fn subcommand_of(word: &[u8]) -> Option<StashSubcommand> {
141 // `for (name, subcommand) in SUBCOMMAND_WORDS` unpacks each pair of the table.
142 for (name, subcommand) in SUBCOMMAND_WORDS {
143 if name.as_bytes() == word {
144 // `*subcommand` copies the small enum value out of the borrowed table.
145 return Some(*subcommand);
146 }
147 }
148 return None;
149}
150
151/// What:
152/// The table and flags a subcommand parses its arguments with.
153/// `(&'static [OptionSpec], ParseMode)` is a pair of a baked-in table and its mode.
154/// Why:
155/// Each `*_stash` function calls `parse_options` with its own table and flags.
156///
157/// In TS you'd write (pseudocode):
158/// ```ts
159/// const [table, mode] = GRAMMAR[subcommand];
160/// ```
161fn grammar_of(subcommand: StashSubcommand) -> (&'static [OptionSpec], ParseMode) {
162 // `match` picks one arm per variant; the compiler refuses a forgotten variant.
163 return match subcommand {
164 StashSubcommand::Apply => (STASH_APPLY_TABLE, STASH_PLAIN_MODE),
165 StashSubcommand::Clear => (STASH_EMPTY_TABLE, STASH_STOP_MODE),
166 StashSubcommand::Drop => (STASH_DROP_TABLE, STASH_PLAIN_MODE),
167 StashSubcommand::Pop => (STASH_POP_TABLE, STASH_PLAIN_MODE),
168 StashSubcommand::Branch => (STASH_EMPTY_TABLE, STASH_PLAIN_MODE),
169 StashSubcommand::List => (STASH_EMPTY_TABLE, STASH_KEEP_UNKNOWN_MODE),
170 StashSubcommand::Show => (STASH_SHOW_TABLE, STASH_KEEP_UNKNOWN_MODE),
171 StashSubcommand::Store => (STASH_STORE_TABLE, STASH_KEEP_UNKNOWN_MODE),
172 // `create` joins every argument into the message without parsing options
173 // (builtin/stash.c:1654-1655); the empty stopping grammar is never used for it.
174 StashSubcommand::Create => (STASH_EMPTY_TABLE, STASH_STOP_MODE),
175 StashSubcommand::Push => (STASH_PUSH_TABLE, STASH_PLAIN_MODE),
176 StashSubcommand::Export => (STASH_EXPORT_TABLE, STASH_PLAIN_MODE),
177 StashSubcommand::Import => (STASH_EMPTY_TABLE, STASH_PLAIN_MODE),
178 StashSubcommand::Save => (STASH_SAVE_TABLE, STASH_PLAIN_MODE),
179 StashSubcommand::AssumedPush => (STASH_PUSH_TABLE, STASH_STOP_MODE),
180 };
181}
182
183/// What:
184/// Refuse a first token that spells a subcommand word after one dash.
185/// `Result<(), OptionError>` is "nothing on success,
186/// or a refusal".
187/// Why:
188/// Before assuming `push`,
189/// Git's top-level pass applies the typo check against the
190/// subcommand words,
191/// so `git stash -push` is an error (parse-options.c:622-640,
192/// 1067-1072,
193/// 1127-1131).
194/// Gotcha:
195/// That pass also refuses `-no-<x>` and shows usage for `--help` and for a cluster
196/// starting with `h`.
197/// The assumed-push pass that follows refuses those tokens with
198/// the same kind at the same index,
199/// so they need no check here.
200///
201/// In TS you'd write (pseudocode):
202/// ```ts
203/// function checkTopLevelToken(token: string, index: number): void; // throws OptionError
204/// ```
205fn check_top_level_token(token: &[u8], index: usize) -> Result<(), OptionError> {
206 // Git checks clusters of at least three letters: one dash, then three or more bytes.
207 if token.len() < 4 || token[0] != b'-' || token[1] == b'-' {
208 return Ok(());
209 }
210 // `&token[1..]` borrows the letters after the dash.
211 let cluster: &[u8] = &token[1..];
212 for (name, _) in SUBCOMMAND_WORDS {
213 if name.as_bytes().starts_with(cluster) {
214 return Err(OptionError {
215 kind: OptionErrorKind::SingleDashLongOption,
216 token: index,
217 });
218 }
219 }
220 return Ok(());
221}
222
223/// What:
224/// Parse the region after `stash`.
225/// `Result<A, B>` is "either success `A` or failure `B`".
226/// Why:
227/// Leading wrapper flags are skipped first,
228/// so `git stash --no-enforce-worktree list`
229/// still dispatches to `list`;
230/// the rest follows the selected subcommand's grammar.
231///
232/// In TS you'd write (pseudocode):
233/// ```ts
234/// function parseStashRegion(region: string[], wrapperFlags: string[]): StashRegion; // throws
235/// ```
236pub fn parse_stash_region(
237 region: &[OsString],
238 wrapper_flags: &[&[u8]],
239) -> Result<StashRegion, OptionError> {
240 // The command's own escape hatch is flag 0; the caller's flags follow.
241 let mut flags: Vec<&[u8]> = Vec::<&[u8]>::with_capacity(wrapper_flags.len() + 1);
242 flags.push(WORKTREE_ENFORCEMENT_ESCAPE_HATCH.as_bytes());
243 for flag in wrapper_flags {
244 // `*flag` reads the `&[u8]` the loop variable points at.
245 flags.push(*flag);
246 }
247 let mut wrapper: Vec<WrapperOccurrence> = Vec::<WrapperOccurrence>::new();
248 // Skip wrapper flags written before the subcommand word.
249 let mut first: usize = 0;
250 while first < region.len() {
251 // `.as_encoded_bytes()` lends the raw bytes; nothing is decoded or copied.
252 let token: &[u8] = region[first].as_encoded_bytes();
253 // Index of the wrapper flag this token spells; the list length means "none".
254 let mut flag: usize = 0;
255 while flag < flags.len() && flags[flag] != token {
256 flag += 1;
257 }
258 if flag == flags.len() {
259 break;
260 }
261 wrapper.push(WrapperOccurrence { flag, token: first });
262 first += 1;
263 }
264 let mut subcommand: StashSubcommand = StashSubcommand::AssumedPush;
265 // Index of the first token the subcommand's own parser reads.
266 let mut start: usize = first;
267 if first < region.len() {
268 let word: &[u8] = region[first].as_encoded_bytes();
269 if let Some(selected) = subcommand_of(word) {
270 subcommand = selected;
271 start = first + 1;
272 } else {
273 // `?` returns the refusal to our caller, or continues on success.
274 check_top_level_token(word, first)?;
275 }
276 }
277 if subcommand != StashSubcommand::Create {
278 let (table, mode): (&[OptionSpec], ParseMode) = grammar_of(subcommand);
279 // What: `match` on a `Result` unpacks the success value or handles the failure.
280 // Why: The refusal's token index is relative to the subcommand's arguments; it is
281 // shifted so it points into the whole region.
282 //
283 // In TS you'd write (pseudocode):
284 // ```ts
285 // try { parsed = parseOptions(...); } catch (e) { e.token += start; throw e; }
286 // ```
287 let parsed: ParsedOptions =
288 match parse_options(®ion[start..], table, mode, flags.as_slice()) {
289 Ok(accepted) => accepted,
290 Err(error) => {
291 return Err(OptionError {
292 kind: error.kind,
293 token: error.token + start,
294 });
295 }
296 };
297 for occurrence in &parsed.wrapper {
298 wrapper.push(WrapperOccurrence {
299 flag: occurrence.flag,
300 token: occurrence.token + start,
301 });
302 }
303 }
304 // `Ok(x)` is the success case of `Result`.
305 return Ok(StashRegion {
306 subcommand,
307 wrapper: split_wrapper_flags(wrapper.as_slice()),
308 });
309}
310
311/// Dispatch,
312/// per-subcommand value positions,
313/// and real-Git controls of the tables.
314#[cfg(test)]
315#[path = "command_stash_tests.rs"]
316mod tests;