git_policy_cli/index_writer_commands.rs
1//! What:
2//! Whether a resolved forwarded command writes the real index.
3//! Why:
4//! Index writers take the landing lock so they never interleave with a landing commit.
5//! The set follows `SPEC.md` "Index-writer coordination":
6//! `add`,
7//! `rm`,
8//! `mv`,
9//! `restore --staged`,
10//! `reset` except `--soft`,
11//! `stash`,
12//! `checkout`,
13//! `switch`,
14//! `merge`,
15//! `rebase`,
16//! `cherry-pick`,
17//! `revert`,
18//! `apply --cached` and `apply --index`,
19//! `update-index`,
20//! `read-tree`,
21//! `am`,
22//! `pull` and `sparse-checkout`.
23//! Options count only before Git's `--`;
24//! long options match in full or as the abbreviations Git accepts
25//! (`src/index-lock/index-writer-commands.ts`).
26//!
27//! In TS you'd write (pseudocode):
28//! ```ts
29//! isIndexWriter(await resolveForwardedCommand({ args, gitPath }))
30//! ```
31
32/// The resolved command.
33use super::forwarded_command::ResolvedCommand;
34
35/// Commands that write the index in every form.
36pub const ALWAYS_WRITING_COMMANDS: [&[u8]; 15] = [
37 b"add",
38 b"am",
39 b"checkout",
40 b"cherry-pick",
41 b"merge",
42 b"mv",
43 b"pull",
44 b"read-tree",
45 b"rebase",
46 b"revert",
47 b"rm",
48 b"sparse-checkout",
49 b"stash",
50 b"switch",
51 b"update-index",
52];
53
54/// What:
55/// A long option with the shortest abbreviation Git accepts for it in its command.
56/// Why:
57/// Git's `parse_options` accepts unambiguous prefixes.
58///
59/// In TS you'd write (pseudocode):
60/// ```ts
61/// type LongOption = { name: string; minimumLength: number };
62/// ```
63#[derive(Clone, Copy, Debug)]
64pub struct LongOption {
65 /// Full spelling.
66 pub name: &'static [u8],
67 /// Shortest accepted prefix length,
68 /// `--` included.
69 pub minimum_length: usize,
70}
71
72/// `git restore --staged`;
73/// `--s` is ambiguous with `--source`.
74pub const RESTORE_STAGED: LongOption = LongOption { name: b"--staged", minimum_length: 4 };
75/// `git reset --soft`;
76/// `--s` is accepted.
77pub const RESET_SOFT: LongOption = LongOption { name: b"--soft", minimum_length: 3 };
78/// `git apply --cached`;
79/// `--c` is ambiguous with `--check`.
80pub const APPLY_CACHED: LongOption = LongOption { name: b"--cached", minimum_length: 4 };
81/// `git apply --index`;
82/// `--in` is ambiguous with `--intent-to-add` and `--inaccurate-eof`.
83pub const APPLY_INDEX: LongOption = LongOption { name: b"--index", minimum_length: 5 };
84
85/// What:
86/// The option tokens after the subcommand and before `--`.
87/// Why:
88/// `optionTokens`:
89/// a token is an option when it starts with `-` and is not `-` alone.
90///
91/// In TS you'd write (pseudocode):
92/// ```ts
93/// function optionTokens(command: ResolvedGitCommand): string[];
94/// ```
95pub fn option_tokens(command: &ResolvedCommand) -> Vec<&[u8]> {
96 // `mut` allows collecting tokens up to the separator.
97 let mut options: Vec<&[u8]> = Vec::new();
98 for token in command.region() {
99 let bytes: &[u8] = token.as_encoded_bytes();
100 if bytes == b"--" {
101 break;
102 }
103 if bytes.starts_with(b"-") && bytes != b"-" {
104 options.push(bytes);
105 }
106 }
107 return options;
108}
109
110/// What:
111/// Whether a token spells a long option or an accepted abbreviation of it.
112/// Why:
113/// `matchesLong`:
114/// an attached `=value` is ignored.
115///
116/// In TS you'd write (pseudocode):
117/// ```ts
118/// function matchesLong({ token, option }): boolean;
119/// ```
120pub fn matches_long(token: &[u8], option: LongOption) -> bool {
121 let name: &[u8] = match token.iter().position(is_equals) {
122 Some(end) => &token[..end],
123 None => token,
124 };
125 return name.len() >= option.minimum_length && option.name.starts_with(name);
126}
127
128/// What:
129/// Whether a byte is `=`.
130/// Why:
131/// A named predicate keeps the search free of closures.
132///
133/// In TS you'd write (pseudocode):
134/// ```ts
135/// (byte: number) => byte === 0x3d
136/// ```
137fn is_equals(byte: &u8) -> bool {
138 return *byte == b'=';
139}
140
141/// What:
142/// Whether a short-option cluster sets `letter` before a value-taking letter consumes the
143/// rest.
144/// Why:
145/// `clusterHas`:
146/// `-WS` sets `--staged`,
147/// `-sS` does not (`-s` takes the rest as a value).
148///
149/// In TS you'd write (pseudocode):
150/// ```ts
151/// function clusterHas({ token, letter, valueLetters }): boolean;
152/// ```
153pub fn cluster_has(token: &[u8], letter: u8, value_letters: &[u8]) -> bool {
154 if !token.starts_with(b"-") || token.starts_with(b"--") {
155 return false;
156 }
157 for byte in &token[1..] {
158 if *byte == letter {
159 return true;
160 }
161 if value_letters.contains(byte) {
162 return false;
163 }
164 }
165 return false;
166}
167
168/// What:
169/// Whether a resolved forwarded command writes the real index.
170/// Why:
171/// `isIndexWriter`.
172///
173/// In TS you'd write (pseudocode):
174/// ```ts
175/// function isIndexWriter(command: ResolvedGitCommand): boolean;
176/// ```
177pub fn is_index_writer(command: &ResolvedCommand) -> bool {
178 if !command.is_builtin() {
179 return false;
180 }
181 let word: &[u8] = command.word();
182 if ALWAYS_WRITING_COMMANDS.contains(&word) {
183 return true;
184 }
185 let options: Vec<&[u8]> = option_tokens(command);
186 if word == b"restore" {
187 for token in &options {
188 if matches_long(token, RESTORE_STAGED) || cluster_has(token, b'S', b"s") {
189 return true;
190 }
191 }
192 return false;
193 }
194 if word == b"reset" {
195 for token in &options {
196 if matches_long(token, RESET_SOFT) {
197 return false;
198 }
199 }
200 return true;
201 }
202 if word == b"apply" {
203 for token in &options {
204 if matches_long(token, APPLY_CACHED) || matches_long(token, APPLY_INDEX) {
205 return true;
206 }
207 }
208 }
209 return false;
210}
211
212/// Classification controls stay out of the release executable.
213#[cfg(test)]
214#[path = "index_writer_commands_tests.rs"]
215mod tests;