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;