Skip to main content

git_policy_cli/
commit_replay_options.rs

1//! What:
2//!  The commit options a replay repeats outside native `git commit`:
3//!  whether hooks were bypassed with `--no-verify`, and the key a signed commit is re-signed with.
4//! Why:
5//!  A replay rebuilds the prepared commit on a moved parent, so it must sign the same way and must know whether
6//!  `pre-commit` is allowed to run again (`src/policy-engine/commit-replay-options.ts`).
7//!
8//! In TS you'd write (pseudocode):
9//! ```ts
10//! replayOptions(['-n', '-Sabc', '-m', 'x']); // { noVerify: true, signingKey: 'abc' }
11//! ```
12
13use super::commit_args::normalise_commit_args;
14
15/// The token that ends the options and starts the pathspecs.
16const PATHSPEC_SEPARATOR: &str = "--";
17
18/// Short commit options that consume the next token.
19const VALUE_LETTERS: [char; 5] = ['m', 'F', 'C', 'c', 't'];
20
21/// Short commit options whose value, when present, is attached (`-S<key>`, `-u<mode>`).
22const ATTACHED_VALUE_LETTERS: [char; 2] = ['S', 'u'];
23
24/// Long commit options that consume the next token when written without `=`.
25const LONG_VALUE_OPTIONS: [&str; 12] = [
26    "--message",
27    "--file",
28    "--reuse-message",
29    "--reedit-message",
30    "--squash",
31    "--fixup",
32    "--author",
33    "--date",
34    "--cleanup",
35    "--trailer",
36    "--template",
37    "--pathspec-from-file",
38];
39
40/// What:
41///  The replay-relevant options of one commit invocation.
42/// Why:
43///   They are the only commit options a replay repeats itself.
44///
45/// In TS you'd write (pseudocode):
46/// ```ts
47/// type ReplayOptions = { noVerify: boolean; signingKey?: string };
48/// ```
49#[derive(Clone, Debug, Default, Eq, PartialEq)]
50pub struct ReplayOptions {
51    /// Whether `pre-commit` and `commit-msg` were bypassed.
52    pub no_verify: bool,
53    /// The key ID given to `-S` or `--gpg-sign`, absent for the configured key.
54    pub signing_key: Option<String>,
55}
56
57/// What:
58///  Option state while scanning.
59/// Why:
60///   Each token either changes the decisions, expects a value next, or ends the options at `--`.
61///
62/// In TS you'd write (pseudocode):
63/// ```ts
64/// type ScanState = { noVerify; signingKey; valueNext; done };
65/// ```
66#[derive(Clone, Debug, Default)]
67struct ScanState {
68    /// The latest `--verify` or `--no-verify` decision.
69    no_verify: bool,
70    /// The latest signing key, empty for the configured key.
71    signing_key: String,
72    /// Whether the next token is an option value.
73    value_next: bool,
74    /// Whether the pathspec separator was passed.
75    done: bool,
76}
77
78/// What:
79///  Apply one short-option cluster such as `-nS` or `-Skey`.
80/// Why:
81///   Boolean letters before a value letter can include `n`, and an attached value after `S` is the key.
82///
83/// In TS you'd write (pseudocode):
84/// ```ts
85/// scanCluster({ state, token });
86/// ```
87fn scan_cluster(state: &ScanState, token: &str) -> ScanState {
88    let letters: Vec<char> = token.chars().skip(1).collect();
89    let value_index: Option<usize> = letters.iter().position(|letter| {
90        return VALUE_LETTERS.contains(letter) || ATTACHED_VALUE_LETTERS.contains(letter);
91    });
92    let flags: &[char] = match value_index {
93        Some(index) => &letters[..index],
94        None => letters.as_slice(),
95    };
96    let value_letter: Option<char> = value_index.map(|index| { return letters[index]; });
97    let attached: String = match value_index {
98        Some(index) => letters[index + 1..].iter().collect(),
99        None => String::new(),
100    };
101    return ScanState {
102        no_verify: state.no_verify || flags.contains(&'n'),
103        signing_key: if value_letter == Some('S') { attached.clone() } else { state.signing_key.clone() },
104        value_next: value_letter.is_some_and(|letter| { return VALUE_LETTERS.contains(&letter); }) && attached.is_empty(),
105        done: state.done,
106    };
107}
108
109/// What:
110///  Apply one token.
111/// Why:
112///   Long flags set the decisions directly, value options consume the next token, and clusters scan letter by letter.
113///
114/// In TS you'd write (pseudocode):
115/// ```ts
116/// scanToken({ state, token });
117/// ```
118fn scan_token(state: ScanState, token: &str) -> ScanState {
119    if state.done {
120        return state;
121    }
122    if state.value_next {
123        return ScanState {
124            value_next: false,
125            ..state
126        };
127    }
128    if token == PATHSPEC_SEPARATOR {
129        return ScanState { done: true, ..state };
130    }
131    if token == "--no-verify" || token == "--verify" {
132        return ScanState {
133            no_verify: token == "--no-verify",
134            ..state
135        };
136    }
137    if token == "--gpg-sign" || token.starts_with("--gpg-sign=") {
138        return ScanState {
139            signing_key: String::from(token.get("--gpg-sign=".len()..).unwrap_or("")),
140            ..state
141        };
142    }
143    if LONG_VALUE_OPTIONS.contains(&token) {
144        return ScanState {
145            value_next: true,
146            ..state
147        };
148    }
149    // A lone dash is a cluster without letters, which changes nothing.
150    if token.starts_with('-') && !token.starts_with("--") {
151        return scan_cluster(&state, token);
152    }
153    return state;
154}
155
156/// What:
157///  Read the replay-relevant options from private commit arguments.
158/// Why:
159///   Native preparation ran with these arguments, so they decide what a replay repeats.
160///
161/// In TS you'd write (pseudocode):
162/// ```ts
163/// replayOptions(commitArgs);
164/// ```
165pub fn replay_options(commit_args: &[String]) -> ReplayOptions {
166    let mut state: ScanState = ScanState::default();
167    for token in normalise_commit_args(commit_args) {
168        state = scan_token(state, token.as_str());
169    }
170    return ReplayOptions {
171        no_verify: state.no_verify,
172        signing_key: if state.signing_key.is_empty() { None } else { Some(state.signing_key) },
173    };
174}
175
176/// Replay option controls stay out of the release executable.
177#[cfg(test)]
178#[path = "commit_replay_options_tests.rs"]
179mod tests;