Skip to main content

git_policy_cli/
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 super::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///  Normalises the post-`commit` argument region, keeping the pathspec tail verbatim.
122/// Why:
123///   Later transforms recognize separated values only, and the pathspec tail after `--` must not change.
124///
125/// In TS you'd write (pseudocode):
126/// ```ts
127/// normaliseCommitArgs(['-amhello', 'file.ts']); // ['-a', '-m', 'hello', 'file.ts']
128/// ```
129pub fn normalise_commit_args(args: &[String]) -> Vec<String> {
130    let separator: Option<usize> = args.iter().position(|token| { return token == PATHSPEC_SEPARATOR; });
131    let (region, tail): (&[String], &[String]) = match separator {
132        Some(index) => (&args[..index], &args[index..]),
133        None => (args, &[]),
134    };
135    let mut normalised: Vec<String> = region
136        .iter()
137        .flat_map(|token| { return normalise_inline_short(token); })
138        .collect();
139    normalised.extend(tail.iter().cloned());
140    return normalised;
141}
142
143/// What:
144///  Splits a transformed argument list into the kept global options and the commit region.
145/// Why:
146///   Redirecting options (`-C`, `--git-dir`, `--work-tree`, `--bare`) would send native commit elsewhere,
147///  so they are dropped with their values, and every other global option is kept in order.
148///
149/// In TS you'd write (pseudocode):
150/// ```ts
151/// splitCommitInvocation({ args, subcommandIndex });
152/// ```
153pub fn split_commit_invocation(args: &[String], subcommand_index: usize) -> (Vec<String>, Vec<String>) {
154    let globals: &[String] = &args[..subcommand_index];
155    let mut kept: Vec<String> = Vec::new();
156    let mut index: usize = 0;
157    while index < globals.len() {
158        let token: &str = globals[index].as_str();
159        let name: &str = if token.starts_with("--") && token.contains('=') {
160            &token[..token.find('=').unwrap_or(token.len())]
161        } else {
162            token
163        };
164        let separated: bool = VALUE_TAKING_GLOBAL_OPTIONS.contains(&token);
165        if REDIRECTING_VALUE_OPTIONS.contains(&name) || token == "--bare" {
166            if separated {
167                index += 1;
168            }
169            index += 1;
170            continue;
171        }
172        kept.push(String::from(token));
173        if separated {
174            if let Some(value) = globals.get(index + 1) {
175                kept.push(value.clone());
176            }
177            index += 1;
178        }
179        index += 1;
180    }
181    return (kept, args[subcommand_index + 1..].to_vec());
182}
183
184/// What:
185///  Removes `-a`/`--all` from the commit region, keeping clustered short options.
186/// Why:
187///   The private index already holds every tracked change, so native commit must not restage worktree bytes.
188///
189/// In TS you'd write (pseudocode):
190/// ```ts
191/// withoutAllFlag(['-am', 'x']); // ['-m', 'x']
192/// ```
193pub fn without_all_flag(commit_args: &[String]) -> Vec<String> {
194    let normalised: Vec<String> = normalise_commit_args(commit_args);
195    let mut kept: Vec<String> = Vec::new();
196    let mut value_next: bool = false;
197    let mut after_separator: bool = false;
198    for token in normalised {
199        if after_separator || value_next {
200            kept.push(token);
201            value_next = false;
202            continue;
203        }
204        if token == PATHSPEC_SEPARATOR {
205            after_separator = true;
206            kept.push(token);
207            continue;
208        }
209        if token == "--all" {
210            continue;
211        }
212        if token.starts_with('-') && !token.starts_with("--") && token.len() > 1 {
213            let letters: Vec<char> = token[1..].chars().collect();
214            let value_start: Option<usize> = letters
215                .iter()
216                .position(|letter| { return OPTIONAL_VALUE_LETTERS.contains(letter); });
217            let flags: &[char] = match value_start {
218                Some(index) => &letters[..index],
219                None => &letters[..],
220            };
221            let mut cluster: String = String::from("-");
222            for letter in flags.iter().filter(|letter| { return **letter != 'a'; }) {
223                cluster.push(*letter);
224            }
225            if let Some(index) = value_start {
226                for letter in &letters[index..] {
227                    cluster.push(*letter);
228                }
229            }
230            if cluster == "-" {
231                continue;
232            }
233            value_next = COMMIT_VALUE_OPTIONS.contains(&cluster.as_str());
234            kept.push(cluster);
235            continue;
236        }
237        value_next = COMMIT_VALUE_OPTIONS.contains(&token.as_str());
238        kept.push(token);
239    }
240    return kept;
241}
242
243/// What:
244///  Removes the pathspec-file controls and the explicit pathspecs,
245///  then the `--` separator and `-o`/`--only`, from the commit region.
246/// Why:
247///   The private index already holds the selected paths, so the real commit names none of them.
248///  The pathspec removal runs from the last pathspec backwards with `lastIndexOf`, as the incumbent does.
249///
250/// In TS you'd write (pseudocode):
251/// ```ts
252/// privateExplicitCommitArgs({ args, pathspecs });
253/// ```
254pub fn private_explicit_commit_args(args: &[String], pathspecs: &[String]) -> Vec<String> {
255    let mut without_file: Vec<String> = Vec::new();
256    let mut index: usize = 0;
257    while index < args.len() {
258        let token: &str = args[index].as_str();
259        if token == "--pathspec-from-file" {
260            index += 2;
261            continue;
262        }
263        if token.starts_with("--pathspec-from-file=") || token == "--pathspec-file-nul" {
264            index += 1;
265            continue;
266        }
267        without_file.push(String::from(token));
268        index += 1;
269    }
270    for pathspec in pathspecs.iter().rev() {
271        if let Some(position) = without_file.iter().rposition(|token| { return token == pathspec; }) {
272            without_file.remove(position);
273        }
274    }
275    return without_file
276        .into_iter()
277        .filter(|token| { return token != "--" && token != "-o" && token != "--only"; })
278        .collect();
279}
280
281/// What:
282///  Removes the selection controls once the private index holds the exact user choice.
283/// Why:
284///   `-i`, `-p`, `--include`, `--interactive` and `--patch` already ran against the private index.
285///
286/// In TS you'd write (pseudocode):
287/// ```ts
288/// privateSelectionCommitArgs({ args, pathspecs });
289/// ```
290pub fn private_selection_commit_args(args: &[String], pathspecs: &[String]) -> Vec<String> {
291    return private_explicit_commit_args(args, pathspecs)
292        .into_iter()
293        .filter(|token| { return !["-i", "-p", "--include", "--interactive", "--patch"].contains(&token.as_str()); })
294        .collect();
295}
296
297/// What:
298///  Chooses the arguments of the real `git commit` for the commit mode.
299/// Why:
300///   Explicit-path commits drop their pathspecs; index commits drop selection controls only
301///  when selection ran privately.
302///
303/// In TS you'd write (pseudocode):
304/// ```ts
305/// resolvePrivateCommitArgs({ args, pathspecs, mode, selectedPrivately });
306/// ```
307pub fn resolve_private_commit_args(
308    args: &[String],
309    pathspecs: &[String],
310    mode: TransactionMode,
311    selected_privately: bool,
312) -> Vec<String> {
313    if mode == TransactionMode::ExplicitPath {
314        return private_explicit_commit_args(args, pathspecs);
315    }
316    if selected_privately {
317        return private_selection_commit_args(args, pathspecs);
318    }
319    return args.to_vec();
320}
321
322/// Argument transforms stay out of the release executable.
323#[cfg(test)]
324#[path = "commit_args_tests.rs"]
325mod tests;