git_policy_cli/command_commit.rs
1//! What:
2//! Facts about the arguments after `git commit`,
3//! read as Git 2.56.0 reads them.
4//! Why:
5//! The commit-only transform,
6//! the dry-run check and the commit transaction all decide
7//! from these facts.
8//! Every fact is the final state after Git applied each option in
9//! order,
10//! so `--all --no-all` reports `all` off,
11//! exactly as `git commit` would act.
12//!
13//! In TS you'd write (pseudocode):
14//! ```ts
15//! // parseCommitRegion(postSubcommandArgs): CommitRegion (throws OptionError)
16//! ```
17
18/// What:
19/// Bring the commit table,
20/// the tokenizer and its questions into this file.
21/// Why:
22/// This module only interprets what the shared tokenizer found.
23///
24/// In TS you'd write (pseudocode):
25/// ```ts
26/// import { COMMIT_TABLE, ALL, ONLY /* ... */ } from './command_commit_table.ts';
27/// import { parseOptions } from './command_options.ts';
28/// ```
29use super::command_commit_table::{
30 ALL, ALLOW_EMPTY, AMEND, COMMIT_TABLE, DRY_RUN, FIXUP, INCLUDE, INTERACTIVE, LONG, NULL, ONLY,
31 PATCH, PATHSPEC_FILE_NUL, PATHSPEC_FROM_FILE, PORCELAIN, SHORT,
32};
33use super::command_options::{
34 DEFAULT_MODE, Occurrence, OptionError, OptionValue, ParsedOptions, parse_options,
35};
36use super::command_options_query::{
37 WrapperFlags, is_enabled, last_occurrence, positional_tokens, split_wrapper_flags, value_bytes,
38};
39/// `OsString` is owned operating-system text of raw bytes (sibling `String` must be UTF-8).
40use std::ffi::OsString;
41
42/// Wrapper-only flag that suppresses `-o` injection for one commit invocation.
43pub const COMMIT_ESCAPE_HATCH: &str = "--no-enforce-only";
44
45/// What:
46/// The form of a `--fixup` value (builtin/commit.c:1378-1411).
47/// An `enum` is a closed
48/// set of named alternatives.
49/// Why:
50/// `amend:` and `reword:` turn on `--allow-empty`;
51/// `reword:` also turns on `--only`
52/// itself and refuses paths and an explicit `-o`.
53///
54/// In TS you'd write (pseudocode):
55/// ```ts
56/// type FixupKind = 'plain' | 'amend' | 'reword' | 'unknown-suboption';
57/// ```
58#[derive(Clone, Copy, Debug, Eq, PartialEq)]
59pub enum FixupKind {
60 /// `--fixup=<commit>`.
61 Plain,
62 /// `--fixup=amend:<commit>`.
63 Amend,
64 /// `--fixup=reword:<commit>`.
65 Reword,
66 /// Letters then `:` that are neither `amend` nor `reword`;
67 /// Git dies on these.
68 UnknownSuboption,
69}
70
71/// What:
72/// Final-state facts of one `git commit` region.
73/// `Vec<usize>` is an owned list of
74/// token indexes;
75/// `Option<bool>` is "true,
76/// false,
77/// or never stated".
78/// Why:
79/// Indexes point into the caller's unchanged arguments,
80/// so paths keep their bytes.
81///
82/// In TS you'd write (pseudocode):
83/// ```ts
84/// type CommitRegion = { all: boolean; only?: boolean; include: boolean; /* ... */ pathspecs: number[] };
85/// ```
86#[derive(Clone, Debug, Eq, PartialEq)]
87pub struct CommitRegion {
88 /// `-a`/`--all` is on.
89 pub all: bool,
90 /// `Some(true)` after `-o`/`--only`,
91 /// `Some(false)` after `--no-only`,
92 /// `None` if unstated.
93 pub only: Option<bool>,
94 /// `-i`/`--include` is on.
95 pub include: bool,
96 /// `--interactive` is on.
97 pub interactive: bool,
98 /// `-p`/`--patch` is on.
99 pub patch: bool,
100 /// `--dry-run`,
101 /// a status format,
102 /// or `-z` makes Git record no commit (commit.c:1422-1423).
103 pub dry_run: bool,
104 /// `--amend` is on.
105 pub amend: bool,
106 /// `--allow-empty` is on.
107 pub allow_empty: bool,
108 /// Form of the final `--fixup` value,
109 /// when one is set.
110 pub fixup: Option<FixupKind>,
111 /// Position of the final `--pathspec-from-file` value,
112 /// when one is set.
113 pub pathspec_from_file: Option<OptionValue>,
114 /// `--pathspec-file-nul` is on.
115 pub pathspec_file_nul: bool,
116 /// Region token indexes of positional pathspecs,
117 /// before and after `--`.
118 pub pathspecs: Vec<usize>,
119 /// Wrapper-only flags in option position;
120 /// `escape` is `--no-enforce-only`.
121 pub wrapper: WrapperFlags,
122}
123
124/// What:
125/// Classify a `--fixup` value by its leading letters.
126/// `&[u8]` borrows the value bytes.
127/// Why:
128/// Git treats `<letters>:` as a suboption and anything else as a commit name.
129///
130/// In TS you'd write (pseudocode):
131/// ```ts
132/// const match = /^([A-Za-z]+):/.exec(value); // then compare match[1]
133/// ```
134fn fixup_kind(value: &[u8]) -> FixupKind {
135 let mut letters: usize = 0;
136 // `is_ascii_alphabetic` matches C `isalpha` in Git's locale-independent ctype table.
137 while letters < value.len() && value[letters].is_ascii_alphabetic() {
138 letters += 1;
139 }
140 if letters == 0 || letters == value.len() || value[letters] != b':' {
141 return FixupKind::Plain;
142 }
143 // `&value[..letters]` borrows the bytes before the colon.
144 let suboption: &[u8] = &value[..letters];
145 if suboption == b"amend" {
146 return FixupKind::Amend;
147 }
148 if suboption == b"reword" {
149 return FixupKind::Reword;
150 }
151 return FixupKind::UnknownSuboption;
152}
153
154/// What:
155/// Whether the options leave Git with a status format,
156/// which forces a dry run.
157/// Why:
158/// `--short`,
159/// `--porcelain` and `--long` write one shared variable,
160/// so only the last
161/// of them counts and a negated form clears it (commit.c:1752-1761).
162///
163/// In TS you'd write (pseudocode):
164/// ```ts
165/// let format = false; for (const o of occurrences) if (isFormat(o.id)) format = !o.negated;
166/// ```
167fn has_status_format(parsed: &ParsedOptions) -> bool {
168 let mut format: bool = false;
169 // `for occurrence in &parsed.occurrences` borrows each record in the order Git applied it.
170 for occurrence in &parsed.occurrences {
171 if occurrence.id == SHORT || occurrence.id == PORCELAIN || occurrence.id == LONG {
172 format = !occurrence.negated;
173 }
174 }
175 return format;
176}
177
178/// What:
179/// The value of an option's final use,
180/// unless that use negated it.
181/// Why:
182/// `--no-pathspec-from-file` after a value clears it,
183/// as Git's `OPTION_FILENAME` does.
184///
185/// In TS you'd write (pseudocode):
186/// ```ts
187/// const value = last === undefined || last.negated ? undefined : last.value;
188/// ```
189fn final_value(parsed: &ParsedOptions, id: u16) -> Option<OptionValue> {
190 let last: Option<Occurrence> = last_occurrence(parsed, id);
191 // `if let Some(found) = last` runs only when the option was written.
192 if let Some(found) = last {
193 if found.negated {
194 // `None` is the "absent" case of `Option`.
195 return None;
196 }
197 return found.value;
198 }
199 return None;
200}
201
202/// What:
203/// Parse the region after `commit`.
204/// `wrapper_flags` lists the caller's other
205/// wrapper-only spellings;
206/// `Result<A, B>` is "either success `A` or failure `B`".
207/// Why:
208/// A region Git itself would refuse yields the refusal,
209/// never a guessed fact set.
210///
211/// In TS you'd write (pseudocode):
212/// ```ts
213/// function parseCommitRegion(region: string[], wrapperFlags: string[]): CommitRegion; // throws
214/// ```
215pub fn parse_commit_region(
216 region: &[OsString],
217 wrapper_flags: &[&[u8]],
218) -> Result<CommitRegion, OptionError> {
219 // The command's own escape hatch is flag 0; the caller's flags follow.
220 let mut flags: Vec<&[u8]> = Vec::<&[u8]>::with_capacity(wrapper_flags.len() + 1);
221 flags.push(COMMIT_ESCAPE_HATCH.as_bytes());
222 for flag in wrapper_flags {
223 // `*flag` reads the `&[u8]` the loop variable points at.
224 flags.push(*flag);
225 }
226 // `?` returns Git's refusal to our caller, or unwraps the tokenized region.
227 let parsed: ParsedOptions =
228 parse_options(region, COMMIT_TABLE, DEFAULT_MODE, flags.as_slice())?;
229 // `mut` lets the default "never stated" be replaced when the option was written.
230 let mut only: Option<bool> = None;
231 if let Some(stated) = last_occurrence(&parsed, ONLY) {
232 // `Some(x)` is the "present" case of `Option`.
233 only = Some(!stated.negated);
234 }
235 let mut fixup: Option<FixupKind> = None;
236 if let Some(value) = final_value(&parsed, FIXUP) {
237 fixup = Some(fixup_kind(value_bytes(region, value)));
238 }
239 // `Ok(x)` is the success case of `Result`.
240 return Ok(CommitRegion {
241 all: is_enabled(&parsed, ALL),
242 only,
243 include: is_enabled(&parsed, INCLUDE),
244 interactive: is_enabled(&parsed, INTERACTIVE),
245 patch: is_enabled(&parsed, PATCH),
246 dry_run: is_enabled(&parsed, DRY_RUN)
247 || has_status_format(&parsed)
248 || is_enabled(&parsed, NULL),
249 amend: is_enabled(&parsed, AMEND),
250 allow_empty: is_enabled(&parsed, ALLOW_EMPTY),
251 fixup,
252 pathspec_from_file: final_value(&parsed, PATHSPEC_FROM_FILE),
253 pathspec_file_nul: is_enabled(&parsed, PATHSPEC_FILE_NUL),
254 pathspecs: positional_tokens(&parsed, region.len()),
255 wrapper: split_wrapper_flags(parsed.wrapper.as_slice()),
256 });
257}
258
259/// Ported incumbent cases and the readings where Git 2.56.0 differs from the incumbent.
260#[cfg(test)]
261#[path = "command_commit_tests.rs"]
262mod tests;
263
264/// Real Git 2.56.0 controls for the table and for each divergence.
265#[cfg(test)]
266#[path = "command_commit_git_tests.rs"]
267mod git_tests;