Skip to main content

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(&region[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;