Skip to main content

git_policy_cli/
commit_replay_options.rs

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