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`. Every other option (amend, editor, signing, templates, sign-off) still goes through the
7//!  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}
47
48/// Which message prefix `--fixup` and `--squash` make.
49#[derive(Clone, Copy, Debug, Eq, PartialEq)]
50pub enum FixupKind {
51    /// `--fixup=<commit>`: the message is `fixup! <subject>`. (`--squash` always opens an editor, so it is not covered.)
52    Fixup,
53}
54
55/// Short option letters that take no value and change nothing the service must do.
56const FLAG_LETTERS: [char; 5] = ['q', 'n', 'o', 'a', 's'];
57
58/// Long options that take no value and change nothing the service must do.
59const FLAG_WORDS: [&str; 10] = [
60    "--quiet", "--no-verify", "--only", "--no-status", "--no-post-rewrite", "--no-edit", "--no-gpg-sign", "--all", "--signoff", "--allow-empty",
61];
62
63/// Long options that take one value, with the field they fill.
64const VALUE_WORDS: [&str; 8] = ["--message", "--file", "--author", "--date", "--trailer", "--cleanup", "--reuse-message", "--fixup"];
65
66/// The value of a long option given as `--name=value` or `--name value`; `index` moves past a separate value.
67fn long_value<'a>(token: &'a str, name: &str, arguments: &'a [OsString], index: &mut usize) -> Option<Option<&'a str>> {
68    if token == name {
69        let value: &str = arguments.get(*index)?.to_str()?;
70        *index += 1;
71        return Some(Some(value));
72    }
73    return token.strip_prefix(name).and_then(|rest| { return rest.strip_prefix('='); }).map(Some);
74}
75
76/// What:
77///  Reads the arguments after `commit` and says whether the service covers them.
78/// Why:
79///   Anything not listed here is refused, so a new Git option is never silently dropped.
80///
81/// In TS you'd write (pseudocode):
82/// ```ts
83/// parseServiceCommit(args)
84/// ```
85pub fn parse_service_commit(arguments: &[OsString]) -> Option<ServiceCommitArgs> {
86    let mut parsed: ServiceCommitArgs = ServiceCommitArgs::default();
87    let mut index: usize = 0;
88    while index < arguments.len() {
89        let token: &str = arguments[index].to_str()?;
90        index += 1;
91        if token == "--" {
92            break;
93        }
94        if !token.starts_with('-') || token == "-" {
95            // A pathspec given without the separator; the region already holds it.
96            continue;
97        }
98        if token.starts_with("--") {
99            if FLAG_WORDS.contains(&token) {
100                parsed.quiet |= token == "--quiet";
101                parsed.signoff |= token == "--signoff";
102                parsed.no_edit |= token == "--no-edit";
103            } else if token == "--allow-empty-message" {
104                parsed.allow_empty_message = true;
105            } else {
106                let mut matched: bool = false;
107                for name in VALUE_WORDS {
108                    let Some(Some(value)) = long_value(token, name, arguments, &mut index) else { continue };
109                    matched = true;
110                    match name {
111                        "--message" => parsed.messages.push(value.to_string()),
112                        "--file" => parsed.file = Some(OsString::from(value)),
113                        "--author" => parsed.author = Some(value.to_string()),
114                        "--date" => parsed.date = Some(value.to_string()),
115                        "--trailer" => parsed.trailers.push(value.to_string()),
116                        "--reuse-message" => parsed.reuse = Some(value.to_string()),
117                        "--fixup" => {
118                            // `amend:` and `reword:` open an editor or change parents; only a plain commit name is covered.
119                            if value.contains(':') || value.is_empty() {
120                                return None;
121                            }
122                            parsed.fixup = Some((FixupKind::Fixup, value.to_string()));
123                        }
124                        _ => match value {
125                            "verbatim" => parsed.verbatim = true,
126                            "strip" => parsed.strip = true,
127                            "whitespace" | "default" => {
128                                parsed.verbatim = false;
129                                parsed.strip = false;
130                            }
131                            _ => return None,
132                        },
133                    }
134                    break;
135                }
136                if !matched {
137                    return None;
138                }
139            }
140        } else {
141            let letters: Vec<char> = token.chars().skip(1).collect();
142            let mut at: usize = 0;
143            while at < letters.len() {
144                let letter: char = letters[at];
145                at += 1;
146                if letter == 'm' || letter == 'F' || letter == 'C' {
147                    let inline: String = letters[at..].iter().collect();
148                    let value: String = if inline.is_empty() {
149                        index += 1;
150                        arguments.get(index - 1)?.to_str()?.to_string()
151                    } else {
152                        inline
153                    };
154                    match letter {
155                        'm' => parsed.messages.push(value),
156                        'F' => parsed.file = Some(OsString::from(value)),
157                        _ => parsed.reuse = Some(value),
158                    }
159                    at = letters.len();
160                } else if FLAG_LETTERS.contains(&letter) {
161                    parsed.quiet |= letter == 'q';
162                    parsed.signoff |= letter == 's';
163                } else {
164                    return None;
165                }
166            }
167        }
168    }
169    // Standard input is read by Git itself; a message from both places is Git's to refuse.
170    let sources: usize = usize::from(!parsed.messages.is_empty())
171        + usize::from(parsed.file.is_some())
172        + usize::from(parsed.reuse.is_some())
173        + usize::from(parsed.fixup.is_some());
174    let has_text: bool = sources == 1;
175    let author_ok: bool = parsed.author.as_deref().is_none_or(|author| { return author.contains('<') && author.trim_end().ends_with('>'); });
176    // `-C` takes the author and its date from the reused commit, and `--reset-author` is not covered.
177    let reuse_ok: bool = parsed.reuse.is_none() || (parsed.author.is_none() && parsed.date.is_none());
178    return if has_text && reuse_ok && author_ok && parsed.file.as_deref().is_none_or(|file| { return file != "-"; }) { Some(parsed) } else { None };
179}
180
181/// What:
182///  The commit message Git makes of the given texts when no editor opens.
183/// Why:
184///   Each text is a paragraph; Git strips trailing whitespace from every line, drops leading and trailing blank lines,
185///  squeezes runs of blank lines into one and ends the message with a newline. An empty result is no message.
186///
187/// In TS you'd write (pseudocode):
188/// ```ts
189/// cleanupMessage(['subject', 'body'])
190/// ```
191pub fn cleanup_message(messages: &[String]) -> Option<String> {
192    let joined: String = messages.join("\n\n");
193    let mut lines: Vec<&str> = Vec::new();
194    let mut previous_blank: bool = true;
195    for line in joined.split('\n') {
196        let trimmed: &str = line.trim_end();
197        let blank: bool = trimmed.is_empty();
198        if blank && previous_blank {
199            continue;
200        }
201        lines.push(trimmed);
202        previous_blank = blank;
203    }
204    while lines.last().is_some_and(|line| { return line.is_empty(); }) {
205        lines.pop();
206    }
207    if lines.is_empty() {
208        return None;
209    }
210    return Some(format!("{}\n", lines.join("\n")));
211}
212
213/// Argument controls stay out of the release executable.
214#[cfg(test)]
215#[path = "service_commit_args_tests.rs"]
216mod tests;