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