Skip to main content

git_policy_cli/
service_commit_args.rs

1//! What:
2//!  Which `git commit` command lines the resident service lands, and the message such a line stands for.
3//! Why:
4//!  The service lands the scoped commit an agent or a person types most (`git commit -m <message> -- <paths>`), with the
5//!  options that only shape the message or the identity: `-F <file>`, `--author`, `--date`, `--trailer`, `--cleanup`,
6//!  `--allow-empty-message`, `-s`, `--amend` and `--reset-author`. Every other option (editor, signing, templates) still
7//!  goes through the transaction until the service covers it, so this module answers "is this line one the service covers" before anything
8//!  is prepared, and builds the message with the clean-up Git applies when no editor opens.
9//!
10//! In TS you'd write (pseudocode):
11//! ```ts
12//! const parsed = parseServiceCommit(args); // undefined when the line needs native git commit
13//! ```
14
15use std::ffi::OsString;
16
17/// What a covered command line asked for.
18#[derive(Clone, Debug, Default, Eq, PartialEq)]
19pub struct ServiceCommitArgs {
20    /// The `-m` texts, in order; each is a paragraph.
21    pub messages: Vec<String>,
22    /// The `-F` file, read by the caller relative to the working directory; never standard input.
23    pub file: Option<OsString>,
24    /// Whether `--quiet` was given.
25    pub quiet: bool,
26    /// The `--author` text, always of the form `Name <email>`.
27    pub author: Option<String>,
28    /// The `--date` text, left to Git to parse.
29    pub date: Option<String>,
30    /// The `--trailer` texts, in order.
31    pub trailers: Vec<String>,
32    /// Whether `--cleanup=verbatim` keeps the message exactly as given.
33    pub verbatim: bool,
34    /// Whether an empty message is allowed.
35    pub allow_empty_message: bool,
36    /// Whether `-s` asks for a `Signed-off-by` trailer of the committer.
37    pub signoff: bool,
38    /// The commit whose message `-C` reuses, with its authorship.
39    pub reuse: Option<String>,
40    /// The commit `--fixup` or `--squash` names, and which of the two.
41    pub fixup: Option<(FixupKind, String)>,
42    /// Whether `--cleanup=strip` also removes comment lines.
43    pub strip: bool,
44    /// Whether `--no-edit` was given.
45    pub no_edit: bool,
46    /// Whether `--amend` replaces the last commit: its parents, its message (without a message option) and its author.
47    pub amend: bool,
48    /// Whether `--reset-author` makes the committer the author of an amend, with a new author date.
49    pub reset_author: bool,
50}
51
52/// Which message prefix `--fixup` and `--squash` make.
53#[derive(Clone, Copy, Debug, Eq, PartialEq)]
54pub enum FixupKind {
55    /// `--fixup=<commit>`: the message is `fixup! <subject>`. (`--squash` always opens an editor, so it is not covered.)
56    Fixup,
57}
58
59/// Short option letters that take no value and change nothing the service must do.
60const FLAG_LETTERS: [char; 7] = ['q', 'n', 'o', 'a', 's', 'i', 'p'];
61
62/// Long options that take no value and change nothing the service must do.
63const FLAG_WORDS: [&str; 16] = [
64    "--quiet", "--no-verify", "--only", "--no-status", "--no-post-rewrite", "--no-edit", "--no-gpg-sign", "--all", "--signoff", "--allow-empty",
65    "--amend", "--reset-author", "--include", "--patch", "--interactive", "--pathspec-file-nul",
66];
67
68/// Long options that take one value, with the field they fill.
69const VALUE_WORDS: [&str; 9] = ["--message", "--file", "--author", "--date", "--trailer", "--cleanup", "--reuse-message", "--fixup", "--pathspec-from-file"];
70
71/// The value of a long option given as `--name=value` or `--name value`; `index` moves past a separate value.
72fn long_value<'a>(token: &'a str, name: &str, arguments: &'a [OsString], index: &mut usize) -> Option<Option<&'a str>> {
73    if token == name {
74        let value: &str = arguments.get(*index)?.to_str()?;
75        *index += 1;
76        return Some(Some(value));
77    }
78    return token.strip_prefix(name).and_then(|rest| { return rest.strip_prefix('='); }).map(Some);
79}
80
81/// What:
82///  Reads the arguments after `commit` and says whether the service covers them.
83/// Why:
84///   Anything not listed here is refused, so a new Git option is never silently dropped.
85///
86/// In TS you'd write (pseudocode):
87/// ```ts
88/// parseServiceCommit(args)
89/// ```
90pub fn parse_service_commit(arguments: &[OsString]) -> Option<ServiceCommitArgs> {
91    let mut parsed: ServiceCommitArgs = ServiceCommitArgs::default();
92    let mut index: usize = 0;
93    while index < arguments.len() {
94        let token: &str = arguments[index].to_str()?;
95        index += 1;
96        if token == "--" {
97            break;
98        }
99        if !token.starts_with('-') || token == "-" {
100            // A pathspec given without the separator; the region already holds it.
101            continue;
102        }
103        if token.starts_with("--") {
104            if FLAG_WORDS.contains(&token) {
105                parsed.quiet |= token == "--quiet";
106                parsed.signoff |= token == "--signoff";
107                parsed.no_edit |= token == "--no-edit";
108                parsed.amend |= token == "--amend";
109                parsed.reset_author |= token == "--reset-author";
110            } else if token == "--allow-empty-message" {
111                parsed.allow_empty_message = true;
112            } else {
113                let mut matched: bool = false;
114                for name in VALUE_WORDS {
115                    let Some(Some(value)) = long_value(token, name, arguments, &mut index) else { continue };
116                    matched = true;
117                    match name {
118                        "--message" => parsed.messages.push(value.to_string()),
119                        "--file" => parsed.file = Some(OsString::from(value)),
120                        "--author" => parsed.author = Some(value.to_string()),
121                        "--date" => parsed.date = Some(value.to_string()),
122                        "--trailer" => parsed.trailers.push(value.to_string()),
123                        "--reuse-message" => parsed.reuse = Some(value.to_string()),
124                        // The private index already holds the paths the file names; the message does not depend on them.
125                        "--pathspec-from-file" => {}
126                        "--fixup" => {
127                            // `amend:` and `reword:` open an editor or change parents; only a plain commit name is covered.
128                            if value.contains(':') || value.is_empty() {
129                                return None;
130                            }
131                            parsed.fixup = Some((FixupKind::Fixup, value.to_string()));
132                        }
133                        _ => match value {
134                            "verbatim" => parsed.verbatim = true,
135                            "strip" => parsed.strip = true,
136                            "whitespace" | "default" => {
137                                parsed.verbatim = false;
138                                parsed.strip = false;
139                            }
140                            _ => return None,
141                        },
142                    }
143                    break;
144                }
145                if !matched {
146                    return None;
147                }
148            }
149        } else {
150            let letters: Vec<char> = token.chars().skip(1).collect();
151            let mut at: usize = 0;
152            while at < letters.len() {
153                let letter: char = letters[at];
154                at += 1;
155                if letter == 'm' || letter == 'F' || letter == 'C' {
156                    let inline: String = letters[at..].iter().collect();
157                    let value: String = if inline.is_empty() {
158                        index += 1;
159                        arguments.get(index - 1)?.to_str()?.to_string()
160                    } else {
161                        inline
162                    };
163                    match letter {
164                        'm' => parsed.messages.push(value),
165                        'F' => parsed.file = Some(OsString::from(value)),
166                        _ => parsed.reuse = Some(value),
167                    }
168                    at = letters.len();
169                } else if FLAG_LETTERS.contains(&letter) {
170                    parsed.quiet |= letter == 'q';
171                    parsed.signoff |= letter == 's';
172                } else {
173                    return None;
174                }
175            }
176        }
177    }
178    // Standard input is read by Git itself; a message from both places is Git's to refuse.
179    let sources: usize = usize::from(!parsed.messages.is_empty())
180        + usize::from(parsed.file.is_some())
181        + usize::from(parsed.reuse.is_some())
182        + usize::from(parsed.fixup.is_some());
183    // An amend with no message option and `--no-edit` keeps the message of the commit it replaces; with neither, an editor
184    //  opens, which is not covered.
185    let has_text: bool = sources == 1 || (parsed.amend && parsed.no_edit && sources == 0);
186    let author_ok: bool = parsed.author.as_deref().is_none_or(|author| { return author.contains('<') && author.trim_end().ends_with('>'); });
187    // `-C` takes the author and its date from the reused commit, and `--reset-author` is not covered.
188    let reuse_ok: bool = parsed.reuse.is_none() || (parsed.author.is_none() && parsed.date.is_none());
189    // `--reset-author` names its own author, so it is covered only for an amend that sets no author, no date and no reused commit.
190    let reset_ok: bool = !parsed.reset_author || (parsed.amend && parsed.reuse.is_none() && parsed.author.is_none() && parsed.date.is_none());
191    return if has_text && reuse_ok && reset_ok && author_ok && parsed.file.as_deref().is_none_or(|file| { return file != "-"; }) { Some(parsed) } else { None };
192}
193
194/// Long options with no value that never decide whether an editor opens.
195const NEUTRAL_LONG: [&str; 15] = [
196    "--quiet", "--no-verify", "--only", "--no-status", "--no-post-rewrite", "--no-gpg-sign", "--all", "--signoff", "--allow-empty",
197    "--amend", "--reset-author", "--allow-empty-message", "--verbose", "--include", "--pathspec-file-nul",
198];
199
200/// Long options with a value that never decide whether an editor opens.
201const NEUTRAL_VALUE_LONG: [&str; 7] = ["--author", "--date", "--trailer", "--cleanup", "--gpg-sign", "--template", "--pathspec-from-file"];
202
203/// Long options that take a value, whether attached with `=` or the next token.
204const VALUE_LONG: [&str; 11] = [
205    "--message", "--file", "--reuse-message", "--reedit-message", "--fixup", "--author", "--date", "--trailer", "--cleanup", "--template",
206    "--pathspec-from-file",
207];
208
209/// Short option letters whose value is the rest of the token, or the next token when nothing follows.
210const VALUE_LETTERS: [char; 5] = ['m', 'F', 'C', 'c', 't'];
211
212/// Short option letters whose value can only be attached.
213const ATTACHED_LETTERS: [char; 2] = ['S', 'u'];
214
215/// What:
216///  Whether the command line itself asks `git commit` to print no summary (`-q`, `--quiet`, in a cluster of short options).
217/// Why:
218///   The wrapper prints the summary line of a landed commit, so it must know whether the person asked for silence. Values of
219///  options are skipped, so `-m quick` is not a request for silence. A line this reading does not understand counts as not
220///  quiet: a summary printed without need costs a line, and one withheld hides the commit's name.
221///
222/// In TS you'd write (pseudocode):
223/// ```ts
224/// asksForQuiet(['-q', '-m', 'x'])
225/// ```
226pub fn asks_for_quiet(arguments: &[OsString]) -> bool {
227    let mut index: usize = 0;
228    while index < arguments.len() {
229        let Some(token) = arguments[index].to_str() else { return false };
230        index += 1;
231        if token == "--" {
232            break;
233        }
234        if let Some(long) = token.strip_prefix("--") {
235            let (name, attached): (&str, bool) = match long.split_once('=') {
236                Some((name, _)) => (name, true),
237                None => (long, false),
238            };
239            let dashed: String = format!("--{name}");
240            if dashed == "--quiet" {
241                return true;
242            }
243            if !attached && VALUE_LONG.contains(&dashed.as_str()) {
244                index += 1;
245            }
246        } else if token.starts_with('-') && token != "-" {
247            let letters: Vec<char> = token.chars().skip(1).collect();
248            for (at, letter) in letters.iter().enumerate() {
249                if *letter == 'q' {
250                    return true;
251                }
252                if ATTACHED_LETTERS.contains(letter) {
253                    break;
254                }
255                if VALUE_LETTERS.contains(letter) {
256                    if at + 1 == letters.len() {
257                        index += 1;
258                    }
259                    break;
260                }
261            }
262        }
263    }
264    return false;
265}
266
267/// What:
268///  Whether `git commit` with these arguments may open an editor.
269/// Why:
270///   The answer decides what happens to a commit Git built when the branch moved meanwhile: with no editor it is built
271///  again on the new tip, with an editor the typed message is saved for the person instead of being lost. It errs towards
272///  "may open": an option this reading does not know, or a spelling Git accepts as an abbreviation, counts as an editor,
273///  because a message saved without need costs nothing and a message lost costs the person's typing.
274///
275/// In TS you'd write (pseudocode):
276/// ```ts
277/// opensEditor(args)
278/// ```
279pub fn opens_editor(arguments: &[OsString]) -> bool {
280    let mut message_source: bool = false;
281    let mut edit: Option<bool> = None;
282    let mut index: usize = 0;
283    while index < arguments.len() {
284        let Some(token) = arguments[index].to_str() else { return true };
285        index += 1;
286        if token == "--" {
287            break;
288        }
289        if !token.starts_with('-') || token == "-" {
290            continue;
291        }
292        if let Some(long) = token.strip_prefix("--") {
293            let (name, value): (&str, Option<&str>) = match long.split_once('=') {
294                Some((name, value)) => (name, Some(value)),
295                None => (long, None),
296            };
297            let dashed: String = format!("--{name}");
298            let takes_value: bool = ["--message", "--file", "--reuse-message", "--reedit-message", "--fixup", "--squash"].contains(&dashed.as_str())
299                || NEUTRAL_VALUE_LONG.contains(&dashed.as_str());
300            // A separate value is the next token, except for the options whose value may be attached only.
301            let detached: Option<&str> = if takes_value && value.is_none() && !["--gpg-sign", "--squash"].contains(&dashed.as_str()) {
302                index += 1;
303                arguments.get(index - 1).and_then(|next| { return next.to_str(); })
304            } else {
305                None
306            };
307            let given: Option<&str> = value.or(detached);
308            match dashed.as_str() {
309                "--edit" => edit = Some(true),
310                "--no-edit" => edit = Some(false),
311                "--message" | "--file" | "--reuse-message" => message_source = true,
312                "--reedit-message" | "--squash" => return true,
313                "--fixup" => {
314                    if given.is_none_or(|text| { return text.contains(':'); }) {
315                        return true;
316                    }
317                    message_source = true;
318                }
319                other if NEUTRAL_LONG.contains(&other) || NEUTRAL_VALUE_LONG.contains(&other) => {}
320                _ => return true,
321            }
322        } else {
323            let letters: Vec<char> = token.chars().skip(1).collect();
324            let mut at: usize = 0;
325            while at < letters.len() {
326                let letter: char = letters[at];
327                at += 1;
328                match letter {
329                    'e' => edit = Some(true),
330                    'c' => return true,
331                    'm' | 'F' | 'C' | 't' => {
332                        message_source |= letter != 't';
333                        if at == letters.len() {
334                            index += 1;
335                        }
336                        at = letters.len();
337                    }
338                    'S' => at = letters.len(),
339                    'q' | 'n' | 'o' | 'a' | 's' | 'v' | 'i' => {}
340                    _ => return true,
341                }
342            }
343        }
344    }
345    return match edit {
346        Some(wanted) => wanted,
347        None => !message_source,
348    };
349}
350
351/// What:
352///  The commit message Git makes of the given texts when no editor opens.
353/// Why:
354///   Each text is a paragraph; Git strips trailing whitespace from every line, drops leading and trailing blank lines,
355///  squeezes runs of blank lines into one and ends the message with a newline. An empty result is no message.
356///
357/// In TS you'd write (pseudocode):
358/// ```ts
359/// cleanupMessage(['subject', 'body'])
360/// ```
361pub fn cleanup_message(messages: &[String]) -> Option<String> {
362    let joined: String = messages.join("\n\n");
363    let mut lines: Vec<&str> = Vec::new();
364    let mut previous_blank: bool = true;
365    for line in joined.split('\n') {
366        let trimmed: &str = line.trim_end();
367        let blank: bool = trimmed.is_empty();
368        if blank && previous_blank {
369            continue;
370        }
371        lines.push(trimmed);
372        previous_blank = blank;
373    }
374    while lines.last().is_some_and(|line| { return line.is_empty(); }) {
375        lines.pop();
376    }
377    if lines.is_empty() {
378        return None;
379    }
380    return Some(format!("{}\n", lines.join("\n")));
381}
382
383/// Long options that name where the message comes from, or edit it, or add to it.
384const MESSAGE_VALUE_LONG: [&str; 5] = ["--message", "--file", "--template", "--trailer", "--cleanup"];
385
386/// Long options without a value that open the editor or add a sign-off.
387const MESSAGE_FLAG_LONG: [&str; 4] = ["--edit", "--no-edit", "--signoff", "--no-signoff"];
388
389/// Long options that take the author, or the parents, or the whole commit from another commit.
390const BORROWING_LONG: [&str; 5] = ["--reedit-message", "--reuse-message", "--squash", "--fixup", "--amend"];
391
392/// What:
393///  The arguments of a commit Git built, rewritten so that the message the person already typed is used as it is and no
394///  editor opens.
395/// Why:
396///   After the editor has run the message is fixed, so a commit built on a tip that has since moved is built again from
397///  `-F <file>` with the message kept verbatim. A line that takes its author or its parents from another commit (`-c`, `-C`,
398///  `--fixup`, `--squash`, `--amend`) cannot be rebuilt that way, and an abbreviated option that may be one of the dropped
399///  ones is not guessed at: both give nothing, and the caller saves the message and stops.
400///
401/// In TS you'd write (pseudocode):
402/// ```ts
403/// withFixedMessage(args, subcommandIndex, file) // undefined when the line cannot be rebuilt from the message alone
404/// ```
405pub fn with_fixed_message(arguments: &[OsString], subcommand_index: usize, file: &std::path::Path) -> Option<Vec<OsString>> {
406    let text: Vec<String> = arguments.iter().map(|token| { return token.to_string_lossy().into_owned(); }).collect();
407    let region: Vec<String> = super::commit_args::normalise_commit_args(text.get(subcommand_index + 1..)?);
408    let split: usize = region.iter().position(|token| { return token == "--"; }).unwrap_or(region.len());
409    let (options, tail): (&[String], &[String]) = region.split_at(split);
410    let mut kept: Vec<OsString> = arguments[..=subcommand_index].to_vec();
411    let mut index: usize = 0;
412    while index < options.len() {
413        let token: &str = options[index].as_str();
414        index += 1;
415        if let Some(long) = token.strip_prefix("--") {
416            let (name, attached): (String, bool) = match long.split_once('=') {
417                Some((name, _)) => (format!("--{name}"), true),
418                None => (format!("--{long}"), false),
419            };
420            let all: Vec<&str> = MESSAGE_VALUE_LONG.iter().chain(&MESSAGE_FLAG_LONG).chain(&BORROWING_LONG).copied().collect();
421            if BORROWING_LONG.contains(&name.as_str()) || (!all.contains(&name.as_str()) && all.iter().any(|known| { return known.starts_with(name.as_str()); })) {
422                return None;
423            }
424            if MESSAGE_VALUE_LONG.contains(&name.as_str()) {
425                if !attached {
426                    index += 1;
427                }
428                continue;
429            }
430            if MESSAGE_FLAG_LONG.contains(&name.as_str()) {
431                continue;
432            }
433            kept.push(OsString::from(token));
434            continue;
435        }
436        if token.starts_with('-') && token.len() > 1 {
437            let mut cluster: String = String::from("-");
438            let mut value_next: bool = false;
439            let letters: Vec<char> = token.chars().skip(1).collect();
440            for (at, letter) in letters.iter().enumerate() {
441                match letter {
442                    'c' | 'C' => return None,
443                    'm' | 'F' | 't' => value_next = true,
444                    'e' | 's' => {}
445                    'S' => {
446                        cluster.extend(letters[at..].iter());
447                        break;
448                    }
449                    other => cluster.push(*other),
450                }
451            }
452            if cluster != "-" {
453                kept.push(OsString::from(cluster));
454            }
455            if value_next {
456                index += 1;
457            }
458            continue;
459        }
460        kept.push(OsString::from(token));
461    }
462    kept.push(OsString::from("-F"));
463    kept.push(file.as_os_str().to_os_string());
464    kept.push(OsString::from("--cleanup=verbatim"));
465    kept.push(OsString::from("--no-edit"));
466    kept.extend(tail.iter().map(OsString::from));
467    return Some(kept);
468}
469
470/// Argument controls stay out of the release executable.
471#[cfg(test)]
472#[path = "service_commit_args_tests.rs"]
473mod tests;