Skip to main content

git_policy_cli/
merge_options.rs

1//! What:
2//!  The shape of a `git merge` command line that the wrapper can complete in isolation, or the reason it cannot.
3//! Why:
4//!  The isolated merge reproduces what native Git writes only for options whose effect is known exactly: the
5//!  message, the fast-forward choice, signing and the unrelated-histories switch.
6//!  Any other option (a strategy, `--squash`, `--no-commit`, `--ff-only`, `--edit`, an abbreviation Git would
7//!  accept) is not guessed: the command is forwarded to native Git unchanged, with the refusal it may give.
8//!
9//! In TS you'd write (pseudocode):
10//! ```ts
11//! function parseMergeShape(region: Uint8Array[]): { shape: MergeShape } | { unsupported: string };
12//! ```
13
14/// Owned operating-system text of raw bytes.
15use std::ffi::OsString;
16
17/// Whether the person asked for a merge commit even where a fast-forward is possible.
18///
19/// In TS you'd write (pseudocode):
20/// ```ts
21/// type FastForwardChoice = 'default' | 'allowed' | 'never';
22/// ```
23#[derive(Clone, Copy, Debug, Eq, PartialEq)]
24pub enum FastForwardChoice {
25    /// Neither `--ff` nor `--no-ff`; `merge.ff` decides.
26    Default,
27    /// `--ff`.
28    Allowed,
29    /// `--no-ff`.
30    Never,
31}
32
33/// How the merge commit is signed.
34///
35/// In TS you'd write (pseudocode):
36/// ```ts
37/// type Signing = 'default' | 'never' | { key?: string };
38/// ```
39#[derive(Clone, Debug, Eq, PartialEq)]
40pub enum Signing {
41    /// Neither option; `commit.gpgSign` decides.
42    Default,
43    /// `--no-gpg-sign`.
44    Never,
45    /// `-S`, `--gpg-sign` or either with a key.
46    Sign(Option<Vec<u8>>),
47}
48
49/// The options of a merge the wrapper can complete in isolation.
50///
51/// In TS you'd write (pseudocode):
52/// ```ts
53/// type MergeShape = { reference: Uint8Array; message?: Uint8Array; fastForward: FastForwardChoice; ... };
54/// ```
55#[derive(Clone, Debug, Eq, PartialEq)]
56pub struct MergeShape {
57    /// The one commit-ish being merged, exactly as typed.
58    pub reference: OsString,
59    /// The message of every `-m`, joined as Git joins them; absent when none was given.
60    pub message: Option<Vec<u8>>,
61    /// The fast-forward choice.
62    pub fast_forward: FastForwardChoice,
63    /// The signing choice.
64    pub signing: Signing,
65    /// Whether `--no-edit` was given; without it a terminal opens the editor.
66    pub no_edit: bool,
67    /// Whether `-q` or `--quiet` was given.
68    pub quiet: bool,
69    /// The diffstat choice (`--stat`, `--no-stat`, `-n`); absent when `merge.stat` decides.
70    pub stat: Option<bool>,
71    /// Whether `--allow-unrelated-histories` was given.
72    pub allow_unrelated_histories: bool,
73    /// The shortlog choice (`--log`, `--no-log`); absent when `merge.log` decides.
74    pub log: Option<bool>,
75}
76
77/// The reading of a merge command line.
78///
79/// In TS you'd write (pseudocode):
80/// ```ts
81/// type ParsedMerge = { kind: 'supported'; shape: MergeShape } | { kind: 'unsupported'; reason: string };
82/// ```
83#[derive(Clone, Debug, Eq, PartialEq)]
84pub enum ParsedMerge {
85    /// The command line is one the wrapper completes exactly.
86    Supported(MergeShape),
87    /// The command line is forwarded to native Git; the text names why.
88    Unsupported(String),
89}
90
91/// What:
92///  Add one `-m` value the way Git joins them: a blank line between values, each ending in a line feed.
93/// Why:
94///   `option_parse_message` in Git's `builtin/merge.c` adds a line feed before every value after the first and
95///  completes the last line.
96///
97/// In TS you'd write (pseudocode):
98/// ```ts
99/// function appendMessage(buffer: Uint8Array, value: Uint8Array): Uint8Array;
100/// ```
101fn append_message(buffer: &mut Vec<u8>, value: &[u8]) {
102    if !buffer.is_empty() {
103        buffer.push(b'\n');
104    }
105    buffer.extend_from_slice(value);
106    if buffer.last() != Some(&b'\n') {
107        buffer.push(b'\n');
108    }
109}
110
111/// What:
112///  Whether an option is accepted and has no effect on the merge commit.
113/// Why:
114///   These options change only what Git prints or which hooks it runs; the isolated merge runs no hook of the
115///  merge commit and prints its own result, so they need no handling.
116///
117/// In TS you'd write (pseudocode):
118/// ```ts
119/// const isIgnorable = (text: string) => IGNORED.includes(text);
120/// ```
121fn is_ignorable(text: &str) -> bool {
122    return matches!(
123        text,
124        "-v" | "--verbose" | "--no-verbose" | "--progress" | "--no-progress" | "--no-verify" | "--verify"
125            | "--summary" | "--no-summary" | "--no-signoff" | "--no-autostash" | "--no-verify-signatures"
126    );
127}
128
129/// What:
130///  Read the arguments after `merge` into a supported shape, or say why they are not one.
131/// Why:
132///   Only exact spellings are read; Git accepts unambiguous abbreviations and attached values in forms this reader
133///  does not model, and a form it does not model must reach native Git instead of being guessed.
134///
135/// In TS you'd write (pseudocode):
136/// ```ts
137/// function parseMergeShape(region: string[]): ParsedMerge;
138/// ```
139pub fn parse_merge_shape(region: &[OsString]) -> ParsedMerge {
140    let mut positional: Vec<OsString> = Vec::new();
141    let mut message: Vec<u8> = Vec::new();
142    let mut has_message: bool = false;
143    let mut shape: MergeShape = MergeShape {
144        reference: OsString::new(),
145        message: None,
146        fast_forward: FastForwardChoice::Default,
147        signing: Signing::Default,
148        no_edit: false,
149        quiet: false,
150        stat: None,
151        allow_unrelated_histories: false,
152        log: None,
153    };
154    let mut tokens = region.iter();
155    while let Some(token) = tokens.next() {
156        let bytes: &[u8] = token.as_encoded_bytes();
157        let Ok(text) = std::str::from_utf8(bytes) else {
158            if bytes.starts_with(b"-") {
159                return ParsedMerge::Unsupported(String::from("an option that is not UTF-8 text"));
160            }
161            positional.push(token.clone());
162            continue;
163        };
164        if !text.starts_with('-') || text == "-" {
165            positional.push(token.clone());
166            continue;
167        }
168        match text {
169            "--no-edit" => shape.no_edit = true,
170            "-q" | "--quiet" => shape.quiet = true,
171            "--ff" => shape.fast_forward = FastForwardChoice::Allowed,
172            "--no-ff" => shape.fast_forward = FastForwardChoice::Never,
173            "--stat" => shape.stat = Some(true),
174            "--no-stat" | "-n" => shape.stat = Some(false),
175            "--allow-unrelated-histories" => shape.allow_unrelated_histories = true,
176            "--log" => shape.log = Some(true),
177            "--no-log" => shape.log = Some(false),
178            "--no-gpg-sign" => shape.signing = Signing::Never,
179            "-S" | "--gpg-sign" => shape.signing = Signing::Sign(None),
180            "-m" | "--message" => {
181                let Some(value) = tokens.next() else {
182                    return ParsedMerge::Unsupported(String::from("-m without a value"));
183                };
184                append_message(&mut message, value.as_encoded_bytes());
185                has_message = true;
186            }
187            other if is_ignorable(other) => {}
188            other if other.starts_with("--message=") => {
189                append_message(&mut message, &bytes["--message=".len()..]);
190                has_message = true;
191            }
192            other if other.starts_with("--gpg-sign=") => {
193                shape.signing = Signing::Sign(Some(bytes["--gpg-sign=".len()..].to_vec()));
194            }
195            other if other.starts_with("-S") && !other.starts_with("--") => {
196                shape.signing = Signing::Sign(Some(bytes[2..].to_vec()));
197            }
198            other if other.starts_with("-m") && !other.starts_with("--") => {
199                append_message(&mut message, &bytes[2..]);
200                has_message = true;
201            }
202            other => return ParsedMerge::Unsupported(format!("the option {other}")),
203        }
204    }
205    if shape.log.is_some() && has_message {
206        return ParsedMerge::Unsupported(String::from("--log or --no-log together with -m"));
207    }
208    if positional.len() != 1 {
209        return ParsedMerge::Unsupported(format!("{} commits to merge, not exactly one", positional.len()));
210    }
211    shape.reference = positional.remove(0);
212    if shape.reference.as_encoded_bytes().is_empty() {
213        return ParsedMerge::Unsupported(String::from("an empty commit name"));
214    }
215    if has_message {
216        shape.message = Some(message);
217    }
218    return ParsedMerge::Supported(shape);
219}
220
221/// Option forms accepted, refused and passed through.
222#[cfg(test)]
223#[path = "merge_options_tests.rs"]
224mod tests;