Skip to main content

git_policy_cli/commit/
commit_args.rs

1//! What:
2//!  Argument transforms of native preparation:
3//!       the normalised `commit` argument region,
4//!       the split of global options from the commit region,
5//!       the removal of `-a`/`--all`,
6//!       and the arguments of the real `git commit` that run against the complete private index.
7//! Why:
8//!  Native `git commit` must see the policy-settled index, not worktree bytes again,
9//!  so selection controls and pathspecs are removed once the private index already holds them
10//!  (`src/policy-engine/commit-preparation-native.ts`,
11//!  `commit-transaction-selection.ts`,
12//!  `src/parser/commit-normalise.ts`).
13//!  The removals match the incumbent token for token, including its use of `lastIndexOf`,
14//!  so a `-m` value equal to a pathspec is removed from the same position.
15//!
16//! In TS you'd write (pseudocode):
17//! ```ts
18//! const invocation = splitCommitInvocation({ args, subcommandIndex });
19//! const commitArgs = withoutAllFlag(invocation.commitArgs);
20//! ```
21
22/// Pathspec separator token, shared with the escape hatch module.
23/// The pathspec separator that ends option parsing in Git and in the incumbent.
24const PATHSPEC_SEPARATOR: &str = "--";
25/// Commit selection mode of the transaction.
26use crate::transaction_journal::TransactionMode;
27
28/// Global options that redirect native Git away from the shadow repository, so they are dropped.
29const REDIRECTING_VALUE_OPTIONS: [&str; 3] = ["-C", "--git-dir", "--work-tree"];
30
31/// Global options that consume the next token as their value.
32const VALUE_TAKING_GLOBAL_OPTIONS: [&str; 7] = [
33    "-c",
34    "-C",
35    "--git-dir",
36    "--work-tree",
37    "--namespace",
38    "--super-prefix",
39    "--attr-source",
40];
41
42/// Commit options whose separated value never names a flag.
43const COMMIT_VALUE_OPTIONS: [&str; 20] = [
44    "-m",
45    "-F",
46    "-C",
47    "-c",
48    "-t",
49    "-U",
50    "--message",
51    "--file",
52    "--reuse-message",
53    "--reedit-message",
54    "--squash",
55    "--fixup",
56    "--author",
57    "--date",
58    "--cleanup",
59    "--trailer",
60    "--template",
61    "--unified",
62    "--inter-hunk-context",
63    "--pathspec-from-file",
64];
65
66/// Short-value options whose clustered form can carry the value in the same token.
67const SHORT_VALUE_OPTIONS: [&str; 6] = ["-m", "-F", "-C", "-c", "-t", "-U"];
68
69/// Short commit options with an optional attached value (`-S[<keyid>]`, `-u[<mode>]`).
70const OPTIONAL_VALUE_LETTERS: [char; 2] = ['S', 'u'];
71
72/// What:
73///  Finds the first value-taking short option letter inside a cluster token.
74/// Why:
75///   A cluster such as `-am` carries `m`, whose value is the following token or the rest of the cluster.
76///
77/// In TS you'd write (pseudocode):
78/// ```ts
79/// findValueOptionIndex(token)
80/// ```
81fn find_value_option_index(letters: &[char]) -> Option<usize> {
82    for (index, letter) in letters.iter().enumerate().skip(1) {
83        let option: String = format!("-{letter}");
84        if SHORT_VALUE_OPTIONS.contains(&option.as_str()) {
85            return Some(index);
86        }
87    }
88    return None;
89}
90
91/// What:
92///  Splits one short cluster that carries its value inline into the separated form.
93/// Why:
94///   `-amhello` becomes `-a`, `-m`, `hello`, so the value is a token of its own.
95///
96/// In TS you'd write (pseudocode):
97/// ```ts
98/// normaliseInlineShort('-amhello') // ['-a', '-m', 'hello']
99/// ```
100fn normalise_inline_short(token: &str) -> Vec<String> {
101    if !token.starts_with('-') || token.starts_with("--") || token.len() <= 2 {
102        return vec![String::from(token)];
103    }
104    let letters: Vec<char> = token.chars().collect();
105    let Some(value_index) = find_value_option_index(letters.as_slice()) else {
106        return vec![String::from(token)];
107    };
108    let mut tokens: Vec<String> = letters[1..value_index]
109        .iter()
110        .map(|letter| format!("-{letter}"))
111        .collect();
112    tokens.push(format!("-{}", letters[value_index]));
113    let inline: String = letters[value_index + 1..].iter().collect();
114    if !inline.is_empty() {
115        tokens.push(inline);
116    }
117    return tokens;
118}
119
120/// What:
121///  Whether a commit option token takes the next token as its value.
122/// Why:
123///   A value can be spelled like the separator (`-m --`), and only a token in option position can end the options.
124///  A short cluster takes the next token when its value letter ends it; `-S` and `-u` take their value attached only.
125///
126/// In TS you'd write (pseudocode):
127/// ```ts
128/// takesNextToken('-m'); // true
129/// takesNextToken('-qm'); // true
130/// takesNextToken('-mhello'); // false
131/// ```
132fn takes_next_token(token: &str) -> bool {
133    if token.starts_with("--") {
134        return COMMIT_VALUE_OPTIONS.contains(&token);
135    }
136    if !token.starts_with('-') || token.len() < 2 {
137        return false;
138    }
139    let letters: Vec<char> = token.chars().skip(1).collect();
140    for (index, letter) in letters.iter().enumerate() {
141        if OPTIONAL_VALUE_LETTERS.contains(letter) {
142            return false;
143        }
144        if SHORT_VALUE_OPTIONS.contains(&format!("-{letter}").as_str()) {
145            return index + 1 == letters.len();
146        }
147    }
148    return false;
149}
150
151/// What:
152///  The index of the `--` that ends the options of a commit region, or nothing when the person typed none.
153/// Why:
154///   Everything after it is a path, so a wrapper option must never follow it; a `--` that is the value of an option such as
155///  `-m` is a message and not the separator.
156///
157/// In TS you'd write (pseudocode):
158/// ```ts
159/// separatorIndex(['-m', '--', '--', 'a.txt']); // 2
160/// ```
161pub fn separator_index(region: &[String]) -> Option<usize> {
162    let mut index: usize = 0;
163    while index < region.len() {
164        let token: &str = region[index].as_str();
165        if token == PATHSPEC_SEPARATOR {
166            return Some(index);
167        }
168        if takes_next_token(token) {
169            index += 1;
170        }
171        index += 1;
172    }
173    return None;
174}
175
176/// What:
177///  Normalises the post-`commit` argument region, keeping the pathspec tail verbatim.
178/// Why:
179///   Later transforms recognize separated values only, and the pathspec tail after `--` must not change.
180///
181/// In TS you'd write (pseudocode):
182/// ```ts
183/// normaliseCommitArgs(['-amhello', 'file.ts']); // ['-a', '-m', 'hello', 'file.ts']
184/// ```
185pub fn normalise_commit_args(args: &[String]) -> Vec<String> {
186    let separator: Option<usize> = separator_index(args);
187    let (region, tail): (&[String], &[String]) = match separator {
188        Some(index) => (&args[..index], &args[index..]),
189        None => (args, &[]),
190    };
191    let mut normalised: Vec<String> = Vec::new();
192    let mut value_next: bool = false;
193    for token in region {
194        // The value of an option is the person's text and is never read as a cluster of options.
195        if value_next {
196            normalised.push(token.clone());
197            value_next = false;
198            continue;
199        }
200        normalised.extend(normalise_inline_short(token));
201        value_next = takes_next_token(token);
202    }
203    normalised.extend(tail.iter().cloned());
204    return normalised;
205}
206
207/// What:
208///  Splits a transformed argument list into the kept global options and the commit region.
209/// Why:
210///   Redirecting options (`-C`, `--git-dir`, `--work-tree`, `--bare`) would send native commit elsewhere,
211///  so they are dropped with their values, and every other global option is kept in order.
212///
213/// In TS you'd write (pseudocode):
214/// ```ts
215/// splitCommitInvocation({ args, subcommandIndex });
216/// ```
217pub fn split_commit_invocation(args: &[String], subcommand_index: usize) -> (Vec<String>, Vec<String>) {
218    let globals: &[String] = &args[..subcommand_index];
219    let mut kept: Vec<String> = Vec::new();
220    let mut index: usize = 0;
221    while index < globals.len() {
222        let token: &str = globals[index].as_str();
223        let name: &str = if token.starts_with("--") && token.contains('=') {
224            &token[..token.find('=').unwrap_or(token.len())]
225        } else {
226            token
227        };
228        let separated: bool = VALUE_TAKING_GLOBAL_OPTIONS.contains(&token);
229        if REDIRECTING_VALUE_OPTIONS.contains(&name) || token == "--bare" {
230            if separated {
231                index += 1;
232            }
233            index += 1;
234            continue;
235        }
236        kept.push(String::from(token));
237        if separated {
238            if let Some(value) = globals.get(index + 1) {
239                kept.push(value.clone());
240            }
241            index += 1;
242        }
243        index += 1;
244    }
245    return (kept, args[subcommand_index + 1..].to_vec());
246}
247
248/// What:
249///  Removes `-a`/`--all` from the commit region, keeping clustered short options.
250/// Why:
251///   The private index already holds every tracked change, so native commit must not restage worktree bytes.
252///
253/// In TS you'd write (pseudocode):
254/// ```ts
255/// withoutAllFlag(['-am', 'x']); // ['-m', 'x']
256/// ```
257pub fn without_all_flag(commit_args: &[String]) -> Vec<String> {
258    let normalised: Vec<String> = normalise_commit_args(commit_args);
259    let mut kept: Vec<String> = Vec::new();
260    let mut value_next: bool = false;
261    let mut after_separator: bool = false;
262    for token in normalised {
263        if after_separator || value_next {
264            kept.push(token);
265            value_next = false;
266            continue;
267        }
268        if token == PATHSPEC_SEPARATOR {
269            after_separator = true;
270            kept.push(token);
271            continue;
272        }
273        if token == "--all" {
274            continue;
275        }
276        if token.starts_with('-') && !token.starts_with("--") && token.len() > 1 {
277            let letters: Vec<char> = token[1..].chars().collect();
278            let value_start: Option<usize> = letters
279                .iter()
280                .position(|letter| { return OPTIONAL_VALUE_LETTERS.contains(letter); });
281            let flags: &[char] = match value_start {
282                Some(index) => &letters[..index],
283                None => &letters[..],
284            };
285            let mut cluster: String = String::from("-");
286            for letter in flags.iter().filter(|letter| { return **letter != 'a'; }) {
287                cluster.push(*letter);
288            }
289            if let Some(index) = value_start {
290                for letter in &letters[index..] {
291                    cluster.push(*letter);
292                }
293            }
294            if cluster == "-" {
295                continue;
296            }
297            value_next = COMMIT_VALUE_OPTIONS.contains(&cluster.as_str());
298            kept.push(cluster);
299            continue;
300        }
301        value_next = COMMIT_VALUE_OPTIONS.contains(&token.as_str());
302        kept.push(token);
303    }
304    return kept;
305}
306
307/// What:
308///  Removes the pathspec-file controls and the explicit pathspecs,
309///  then the `--` separator and `-o`/`--only`, from the commit region.
310/// Why:
311///   The private index already holds the selected paths, so the real commit names none of them.
312///  The pathspec removal runs from the last pathspec backwards with `lastIndexOf`, as the incumbent does.
313///
314/// In TS you'd write (pseudocode):
315/// ```ts
316/// privateExplicitCommitArgs({ args, pathspecs });
317/// ```
318pub fn private_explicit_commit_args(args: &[String], pathspecs: &[String]) -> Vec<String> {
319    let mut without_file: Vec<String> = Vec::new();
320    let mut index: usize = 0;
321    while index < args.len() {
322        let token: &str = args[index].as_str();
323        if token == "--pathspec-from-file" {
324            index += 2;
325            continue;
326        }
327        if token.starts_with("--pathspec-from-file=") || token == "--pathspec-file-nul" {
328            index += 1;
329            continue;
330        }
331        without_file.push(String::from(token));
332        index += 1;
333    }
334    for pathspec in pathspecs.iter().rev() {
335        if let Some(position) = without_file.iter().rposition(|token| { return token == pathspec; }) {
336            without_file.remove(position);
337        }
338    }
339    // Only the separator and the `-o` and `--only` the person typed in option position go; the same text as the value of an option
340    //  (`-m --`) is the person's and stays.
341    let separator: Option<usize> = separator_index(without_file.as_slice());
342    let mut kept: Vec<String> = Vec::new();
343    let mut value_next: bool = false;
344    for (position, token) in without_file.into_iter().enumerate() {
345        if separator.is_some_and(|at| { return position > at; }) {
346            kept.push(token);
347            continue;
348        }
349        if separator == Some(position) {
350            continue;
351        }
352        if value_next {
353            kept.push(token);
354            value_next = false;
355            continue;
356        }
357        value_next = takes_next_token(token.as_str());
358        if token == "-o" || token == "--only" {
359            continue;
360        }
361        kept.push(token);
362    }
363    return kept;
364}
365
366/// What:
367///  Removes the selection controls once the private index holds the exact user choice.
368/// Why:
369///   `-i`, `-p`, `--include`, `--interactive` and `--patch` already ran against the private index.
370///
371/// In TS you'd write (pseudocode):
372/// ```ts
373/// privateSelectionCommitArgs({ args, pathspecs });
374/// ```
375pub fn private_selection_commit_args(args: &[String], pathspecs: &[String]) -> Vec<String> {
376    return private_explicit_commit_args(args, pathspecs)
377        .into_iter()
378        .filter(|token| { return !["-i", "-p", "--include", "--interactive", "--patch"].contains(&token.as_str()); })
379        .collect();
380}
381
382/// What:
383///  Chooses the arguments of the real `git commit` for the commit mode.
384/// Why:
385///   Explicit-path commits drop their pathspecs; index commits drop selection controls only
386///  when selection ran privately.
387///
388/// In TS you'd write (pseudocode):
389/// ```ts
390/// resolvePrivateCommitArgs({ args, pathspecs, mode, selectedPrivately });
391/// ```
392pub fn resolve_private_commit_args(
393    args: &[String],
394    pathspecs: &[String],
395    mode: TransactionMode,
396    selected_privately: bool,
397) -> Vec<String> {
398    if mode == TransactionMode::ExplicitPath {
399        return private_explicit_commit_args(args, pathspecs);
400    }
401    if selected_privately {
402        return private_selection_commit_args(args, pathspecs);
403    }
404    return args.to_vec();
405}
406
407/// Argument transforms stay out of the release executable.
408#[cfg(test)]
409#[path = "commit_args_tests.rs"]
410mod tests;