Skip to main content

git_policy_cli/
command_add.rs

1//! What:
2//!  The complete `git add` option table of Git 2.56.0 and the bulk-staging facts read
3//!       from it.
4//! Why:
5//!  The add-explicit policy rejects staging that names no explicit path.
6//!  Git accepts
7//!      `-A` inside any cluster (`-vA`),
8//!  by abbreviation (`--al`) and through
9//!      `--no-ignore-removal`;
10//!  a list of exact tokens misses those.
11//!
12//! In TS you'd write (pseudocode):
13//! ```ts
14//! // parseAddRegion(postSubcommandArgs): AddRegion  (throws OptionError)
15//! ```
16
17/// What:
18///  Bring the tokenizer,
19///  its table builder and its questions into this file.
20/// Why:
21///   This module only declares Git's table and interprets what the tokenizer found.
22///
23/// In TS you'd write (pseudocode):
24/// ```ts
25/// import { parseOptions, row } from './command_options.ts';
26/// ```
27use super::command_options::{
28    Arity, DEFAULT_MODE, Occurrence, OptionError, OptionSpec, ParsedOptions, UNREAD, parse_options,
29    row,
30};
31use super::command_options_query::{
32    WrapperFlags, is_enabled, last_occurrence, positional_tokens, split_wrapper_flags,
33};
34/// `OsString` is owned operating-system text of raw bytes (sibling `String` must be UTF-8).
35use std::ffi::OsString;
36
37/// Wrapper-only flag that suppresses bulk-add enforcement for one invocation.
38pub const ADD_ESCAPE_HATCH: &str = "--no-enforce-bulk-add";
39
40/// `-A`,
41///  `--all`.
42pub const ALL: u16 = 1;
43/// `--ignore-removal`,
44///  the same variable as `--no-all` (builtin/add.c:252-259).
45pub const IGNORE_REMOVAL: u16 = 2;
46/// `-u`,
47///  `--update`.
48pub const UPDATE: u16 = 3;
49/// `--resolved`:
50///  add every conflict-resolved tracked file.
51pub const RESOLVED: u16 = 4;
52
53/// What:
54///  Rows of `builtin_add_options` (builtin/add.c:261-292),
55///  in source order.
56///       `&[OptionSpec]` is a borrowed table baked into the program.
57/// Why:
58///   Only `--chmod`,
59///  `--pathspec-from-file`,
60///  `-U` and `--inter-hunk-context` take a
61///       value;
62///  every other token after an option is a pathspec.
63///
64/// In TS you'd write (pseudocode):
65/// ```ts
66/// export const ADD_TABLE = [row(UNREAD, 'n', 'dry-run', 'none', true), /* ... */] as const;
67/// ```
68pub const ADD_TABLE: &[OptionSpec] = &[
69    row(UNREAD, Some(b'n'), Some("dry-run"), Arity::None, true),
70    row(UNREAD, Some(b'v'), Some("verbose"), Arity::None, true),
71    row(UNREAD, Some(b'i'), Some("interactive"), Arity::None, true),
72    row(UNREAD, Some(b'p'), Some("patch"), Arity::None, true),
73    row(UNREAD, None, Some("auto-advance"), Arity::None, true),
74    row(UNREAD, Some(b'U'), Some("unified"), Arity::Required, false),
75    row(
76        UNREAD,
77        None,
78        Some("inter-hunk-context"),
79        Arity::Required,
80        false,
81    ),
82    row(UNREAD, Some(b'e'), Some("edit"), Arity::None, true),
83    row(UNREAD, Some(b'f'), Some("force"), Arity::None, true),
84    row(UPDATE, Some(b'u'), Some("update"), Arity::None, true),
85    row(UNREAD, None, Some("renormalize"), Arity::None, true),
86    row(RESOLVED, None, Some("resolved"), Arity::None, true),
87    row(UNREAD, Some(b'N'), Some("intent-to-add"), Arity::None, true),
88    row(ALL, Some(b'A'), Some("all"), Arity::None, true),
89    row(
90        IGNORE_REMOVAL,
91        None,
92        Some("ignore-removal"),
93        Arity::None,
94        true,
95    ),
96    row(UNREAD, None, Some("refresh"), Arity::None, true),
97    row(UNREAD, None, Some("ignore-errors"), Arity::None, true),
98    row(UNREAD, None, Some("ignore-missing"), Arity::None, true),
99    row(UNREAD, None, Some("sparse"), Arity::None, true),
100    row(UNREAD, None, Some("chmod"), Arity::Required, true),
101    row(UNREAD, None, Some("warn-embedded-repo"), Arity::None, true),
102    row(
103        UNREAD,
104        None,
105        Some("pathspec-from-file"),
106        Arity::Required,
107        true,
108    ),
109    row(UNREAD, None, Some("pathspec-file-nul"), Arity::None, true),
110];
111
112/// Pathspecs that match every changed path under the working directory or repository.
113const BULK_PATHSPECS: &[&[u8]] = &[b".", b"./", b"*", b":/"];
114
115/// What:
116///  Why a token stages in bulk.
117///  An `enum` is a closed set of named alternatives.
118/// Why:
119///   The policy names the matched tokens;
120///  the kind says which Git meaning matched.
121///
122/// In TS you'd write (pseudocode):
123/// ```ts
124/// type BulkKind = 'all-flag' | 'update-flag' | 'pathspec';
125/// ```
126#[derive(Clone, Copy, Debug, Eq, PartialEq)]
127pub enum BulkKind {
128    /// `-A`/`--all` is on after every option was applied.
129    AllFlag,
130    /// `-u`/`--update` is on after every option was applied.
131    UpdateFlag,
132    /// A positional pathspec equal to `.`,
133    ///  `./`,
134    ///  `*` or `:/`.
135    Pathspec,
136}
137
138/// What:
139///  One bulk-staging match.
140///  `usize` is the list index type.
141/// Why:
142///   The token index lets the diagnostic quote the caller's exact spelling.
143///
144/// In TS you'd write (pseudocode):
145/// ```ts
146/// type BulkMatch = { kind: BulkKind; token: number };
147/// ```
148#[derive(Clone, Copy, Debug, Eq, PartialEq)]
149pub struct BulkMatch {
150    /// Which Git meaning matched.
151    pub kind: BulkKind,
152    /// Region token index of the token that turned the meaning on.
153    pub token: usize,
154}
155
156/// What:
157///  Facts of one `git add` region.
158///  `Vec<BulkMatch>` is an owned list in argument order.
159/// Why:
160///   The add-explicit policy rejects when the list is non-empty and no hatch is given.
161///
162/// In TS you'd write (pseudocode):
163/// ```ts
164/// type AddRegion = { bulkMatches: string[]; hasEscapeHatch: boolean };
165/// ```
166#[derive(Clone, Debug, Eq, PartialEq)]
167pub struct AddRegion {
168    /// Bulk-staging matches in argument order.
169    pub bulk_matches: Vec<BulkMatch>,
170    /// `--resolved` is on.
171    ///  Not a bulk match in the shipped policy;
172    ///  reported for its owner.
173    pub resolved: bool,
174    /// Wrapper-only flags in option position;
175    ///  `escape` is `--no-enforce-bulk-add`.
176    pub wrapper: WrapperFlags,
177}
178
179/// What:
180///  The token that left `-A` on,
181///  or nothing.
182///  `Option<usize>` is "an index or nothing".
183/// Why:
184///   `--all` and `--ignore-removal` write one variable in opposite senses,
185///  so they are
186///       applied together in argument order (`ignore_removal_cb`,
187///  add.c:252-259).
188///
189/// In TS you'd write (pseudocode):
190/// ```ts
191/// let on; for (const o of occurrences) { if (o.id === ALL) on = o.negated ? undefined : o.token; /* ... */ }
192/// ```
193fn all_token(parsed: &ParsedOptions) -> Option<usize> {
194    let mut on: Option<usize> = None;
195    // `for occurrence in &parsed.occurrences` borrows each record in the order Git applied it.
196    for occurrence in &parsed.occurrences {
197        // `--all` turns it on; `--no-all` turns it off.
198        let all_sets: bool = occurrence.id == ALL && !occurrence.negated;
199        // `--no-ignore-removal` turns it on; `--ignore-removal` turns it off.
200        let removal_sets: bool = occurrence.id == IGNORE_REMOVAL && occurrence.negated;
201        if all_sets || removal_sets {
202            // `Some(x)` is the "present" case of `Option`.
203            on = Some(occurrence.token);
204        } else if occurrence.id == ALL || occurrence.id == IGNORE_REMOVAL {
205            // `None` is the "absent" case of `Option`.
206            on = None;
207        }
208    }
209    return on;
210}
211
212/// What:
213///  The token that left `-u` on,
214///  or nothing.
215/// Why:
216///   `--update --no-update` leaves it off,
217///  as Git's `OPT_BOOL` does.
218///
219/// In TS you'd write (pseudocode):
220/// ```ts
221/// const token = last !== undefined && !last.negated ? last.token : undefined;
222/// ```
223fn update_token(parsed: &ParsedOptions) -> Option<usize> {
224    let last: Option<Occurrence> = last_occurrence(parsed, UPDATE);
225    // `if let Some(found) = last` runs only when the option was written.
226    if let Some(found) = last {
227        if found.negated {
228            return None;
229        }
230        return Some(found.token);
231    }
232    return None;
233}
234
235/// What:
236///  The sort key of a match:
237///  its token index.
238///  `&BulkMatch` borrows one match.
239/// Why:
240///   Matches are reported in the order the caller wrote them.
241///
242/// In TS you'd write (pseudocode):
243/// ```ts
244/// matches.sort((a, b) => a.token - b.token);
245/// ```
246fn match_token(found: &BulkMatch) -> usize {
247    return found.token;
248}
249
250/// What:
251///  Parse the region after `add`.
252///  `Result<A, B>` is "either success `A` or failure `B`".
253/// Why:
254///   A region Git itself would refuse yields the refusal,
255///  never a guessed fact set.
256///
257/// In TS you'd write (pseudocode):
258/// ```ts
259/// function parseAddRegion(region: string[], wrapperFlags: string[]): AddRegion; // throws
260/// ```
261pub fn parse_add_region(
262    region: &[OsString],
263    wrapper_flags: &[&[u8]],
264) -> Result<AddRegion, OptionError> {
265    // The command's own escape hatch is flag 0; the caller's flags follow.
266    let mut flags: Vec<&[u8]> = Vec::<&[u8]>::with_capacity(wrapper_flags.len() + 1);
267    flags.push(ADD_ESCAPE_HATCH.as_bytes());
268    for flag in wrapper_flags {
269        // `*flag` reads the `&[u8]` the loop variable points at.
270        flags.push(*flag);
271    }
272    // `?` returns Git's refusal to our caller, or unwraps the tokenized region.
273    let parsed: ParsedOptions = parse_options(region, ADD_TABLE, DEFAULT_MODE, flags.as_slice())?;
274    let mut bulk_matches: Vec<BulkMatch> = Vec::<BulkMatch>::new();
275    // `if let Some(token) = ...` runs only when `-A` is on.
276    if let Some(token) = all_token(&parsed) {
277        bulk_matches.push(BulkMatch {
278            kind: BulkKind::AllFlag,
279            token,
280        });
281    }
282    if let Some(token) = update_token(&parsed) {
283        bulk_matches.push(BulkMatch {
284            kind: BulkKind::UpdateFlag,
285            token,
286        });
287    }
288    for token in positional_tokens(&parsed, region.len()) {
289        // `.as_encoded_bytes()` lends the raw bytes; nothing is decoded or copied.
290        if BULK_PATHSPECS.contains(&region[token].as_encoded_bytes()) {
291            bulk_matches.push(BulkMatch {
292                kind: BulkKind::Pathspec,
293                token,
294            });
295        }
296    }
297    // `sort_by_key` orders the list by the value `match_token` returns for each item.
298    bulk_matches.sort_by_key(match_token);
299    // `Ok(x)` is the success case of `Result`.
300    return Ok(AddRegion {
301        bulk_matches,
302        resolved: is_enabled(&parsed, RESOLVED),
303        wrapper: split_wrapper_flags(parsed.wrapper.as_slice()),
304    });
305}
306
307/// Bulk matches in every accepted spelling,
308///  and real-Git controls of the table.
309#[cfg(test)]
310#[path = "command_add_tests.rs"]
311mod tests;