1#[derive(Debug, Clone, Default)]
29pub struct FmtMergeMsgOptions {
30 pub message: Option<String>,
33 pub into_name: Option<String>,
35}
36
37pub fn fmt_merge_msg(input: &str, opts: &FmtMergeMsgOptions) -> String {
43 let entries = parse_fetch_head(input);
44
45 let mut output = String::new();
46
47 if let Some(ref msg) = opts.message {
48 output.push_str(msg);
49 } else if !entries.is_empty() {
50 let title = build_title(&entries, opts.into_name.as_deref());
51 output.push_str(&title);
52 }
53
54 if !output.is_empty() && !output.ends_with('\n') {
56 output.push('\n');
57 }
58
59 output
60}
61
62#[derive(Debug, Clone)]
64struct FetchEntry {
65 description: String,
68}
69
70fn parse_fetch_head(input: &str) -> Vec<FetchEntry> {
72 let mut entries = Vec::new();
73
74 for line in input.lines() {
75 let first_tab = match line.find('\t') {
77 Some(p) => p,
78 None => continue,
79 };
80
81 let rest = &line[first_tab + 1..];
82
83 if rest.starts_with("not-for-merge") {
85 continue;
86 }
87
88 let desc = rest.strip_prefix('\t').unwrap_or(rest);
93
94 if desc.is_empty() {
95 continue;
96 }
97
98 entries.push(FetchEntry {
99 description: desc.to_owned(),
100 });
101 }
102
103 entries
104}
105
106#[derive(Debug, Clone)]
108enum MergeKind {
109 Branch { name: String, url: Option<String> },
110 Tag { name: String, url: Option<String> },
111 RemoteTracking { name: String, url: Option<String> },
112 Generic(String),
113}
114
115impl MergeKind {
116 fn from_description(desc: &str) -> Self {
117 if let Some(rest) = desc.strip_prefix("branch '") {
118 parse_quoted_name_and_url(rest, KindTag::Branch)
119 } else if let Some(rest) = desc.strip_prefix("tag '") {
120 parse_quoted_name_and_url(rest, KindTag::Tag)
121 } else if let Some(rest) = desc.strip_prefix("remote-tracking branch '") {
122 parse_quoted_name_and_url(rest, KindTag::RemoteTracking)
123 } else {
124 MergeKind::Generic(desc.to_owned())
125 }
126 }
127}
128
129enum KindTag {
130 Branch,
131 Tag,
132 RemoteTracking,
133}
134
135fn parse_quoted_name_and_url(rest: &str, tag: KindTag) -> MergeKind {
136 let close = match rest.find('\'') {
137 Some(p) => p,
138 None => return MergeKind::Generic(rest.to_owned()),
139 };
140 let name = rest[..close].to_owned();
141 let after = &rest[close + 1..];
142 let url = after
143 .strip_prefix(" of ")
144 .filter(|s| !s.is_empty())
145 .map(|s| s.to_owned());
146
147 match tag {
148 KindTag::Branch => MergeKind::Branch { name, url },
149 KindTag::Tag => MergeKind::Tag { name, url },
150 KindTag::RemoteTracking => MergeKind::RemoteTracking { name, url },
151 }
152}
153
154#[derive(Debug, Default)]
156struct SrcData {
157 branches: Vec<String>,
158 tags: Vec<String>,
159 remote_branches: Vec<String>,
160 generics: Vec<String>,
161}
162
163fn build_title(entries: &[FetchEntry], into_name: Option<&str>) -> String {
165 let mut src_order: Vec<String> = Vec::new();
167 let mut src_map: std::collections::HashMap<String, SrcData> = std::collections::HashMap::new();
168
169 for entry in entries {
170 let kind = MergeKind::from_description(&entry.description);
171
172 let (src, cat, name): (String, &str, String) = match kind {
173 MergeKind::Branch { name, url } => {
174 (url.unwrap_or_else(|| ".".to_owned()), "branch", name)
175 }
176 MergeKind::Tag { name, url } => (url.unwrap_or_else(|| ".".to_owned()), "tag", name),
177 MergeKind::RemoteTracking { name, url } => {
178 (url.unwrap_or_else(|| ".".to_owned()), "remote", name)
179 }
180 MergeKind::Generic(desc) => (".".to_owned(), "generic", desc),
181 };
182
183 if !src_map.contains_key(&src) {
184 src_order.push(src.clone());
185 src_map.insert(src.clone(), SrcData::default());
186 }
187 let Some(data) = src_map.get_mut(&src) else {
189 continue;
190 };
191 match cat {
192 "branch" => data.branches.push(name),
193 "tag" => data.tags.push(name),
194 "remote" => data.remote_branches.push(name),
195 _ => data.generics.push(name),
196 }
197 }
198
199 if src_order.is_empty() {
200 return String::new();
201 }
202
203 let mut out = String::from("Merge ");
204 let mut first_src = true;
205
206 for src in &src_order {
207 let data = &src_map[src];
208
209 if !first_src {
210 out.push_str("; ");
211 }
212 first_src = false;
213
214 let mut subsep = "";
215
216 if !data.branches.is_empty() {
217 out.push_str(subsep);
218 subsep = ", ";
219 append_joined("branch ", "branches ", &data.branches, &mut out);
220 }
221 if !data.remote_branches.is_empty() {
222 out.push_str(subsep);
223 subsep = ", ";
224 append_joined(
225 "remote-tracking branch ",
226 "remote-tracking branches ",
227 &data.remote_branches,
228 &mut out,
229 );
230 }
231 if !data.tags.is_empty() {
232 out.push_str(subsep);
233 subsep = ", ";
234 append_joined("tag ", "tags ", &data.tags, &mut out);
235 }
236 if !data.generics.is_empty() {
237 out.push_str(subsep);
238 append_joined("commit ", "commits ", &data.generics, &mut out);
239 }
240
241 if src != "." {
242 out.push_str(" of ");
243 out.push_str(src);
244 }
245 }
246
247 if let Some(name) = into_name {
249 if !is_suppressed_dest(name) {
250 out.push_str(" into ");
251 out.push_str(name);
252 }
253 }
254
255 out
256}
257
258fn is_suppressed_dest(dest: &str) -> bool {
260 dest == "main" || dest == "master"
261}
262
263fn append_joined(singular: &str, plural: &str, names: &[String], out: &mut String) {
265 match names.len() {
266 0 => {}
267 1 => {
268 out.push_str(singular);
269 out.push('\'');
270 out.push_str(&names[0]);
271 out.push('\'');
272 }
273 n => {
274 out.push_str(plural);
275 for (i, name) in names[..(n - 1)].iter().enumerate() {
276 if i > 0 {
277 out.push_str(", ");
278 }
279 out.push('\'');
280 out.push_str(name);
281 out.push('\'');
282 }
283 out.push_str(" and '");
284 out.push_str(&names[n - 1]);
285 out.push('\'');
286 }
287 }
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293
294 #[test]
295 fn empty_input() {
296 let out = fmt_merge_msg("", &FmtMergeMsgOptions::default());
297 assert!(out.is_empty());
298 }
299
300 #[test]
301 fn not_for_merge_skipped() {
302 let input = "abc123\tnot-for-merge\tbranch 'old' of https://x.com\n";
303 let out = fmt_merge_msg(input, &FmtMergeMsgOptions::default());
304 assert!(out.is_empty(), "got: {out:?}");
305 }
306
307 #[test]
308 fn single_branch_local() {
309 let input = "abc123\t\tbranch 'feature'\n";
311 let out = fmt_merge_msg(input, &FmtMergeMsgOptions::default());
312 assert_eq!(out.trim_end(), "Merge branch 'feature'");
313 }
314
315 #[test]
316 fn single_branch_remote() {
317 let input = "abc123\t\tbranch 'main' of https://example.com/repo\n";
318 let out = fmt_merge_msg(input, &FmtMergeMsgOptions::default());
319 assert!(out.contains("branch 'main'"), "got: {out:?}");
320 assert!(out.contains("of https://example.com/repo"), "got: {out:?}");
321 }
322
323 #[test]
324 fn multiple_branches() {
325 let input = "a1\t\tbranch 'foo'\nb2\t\tbranch 'bar'\n";
326 let out = fmt_merge_msg(input, &FmtMergeMsgOptions::default());
327 assert!(out.contains("branches"), "got: {out:?}");
328 assert!(out.contains("'foo'"), "got: {out:?}");
329 assert!(out.contains("'bar'"), "got: {out:?}");
330 }
331
332 #[test]
333 fn custom_message() {
334 let input = "abc123\t\tbranch 'foo'\n";
335 let opts = FmtMergeMsgOptions {
336 message: Some("Custom".to_owned()),
337 into_name: None,
338 };
339 let out = fmt_merge_msg(input, &opts);
340 assert!(out.starts_with("Custom"), "got: {out:?}");
341 }
342
343 #[test]
344 fn into_name_suppressed_for_main() {
345 let input = "abc123\t\tbranch 'feature'\n";
346 let opts = FmtMergeMsgOptions {
347 message: None,
348 into_name: Some("main".to_owned()),
349 };
350 let out = fmt_merge_msg(input, &opts);
351 assert!(!out.contains("into main"), "got: {out:?}");
352 }
353
354 #[test]
355 fn into_name_shown_for_other() {
356 let input = "abc123\t\tbranch 'feature'\n";
357 let opts = FmtMergeMsgOptions {
358 message: None,
359 into_name: Some("develop".to_owned()),
360 };
361 let out = fmt_merge_msg(input, &opts);
362 assert!(out.contains("into develop"), "got: {out:?}");
363 }
364
365 #[test]
366 fn append_joined_two() {
367 let mut s = String::new();
368 append_joined(
369 "branch ",
370 "branches ",
371 &["foo".to_owned(), "bar".to_owned()],
372 &mut s,
373 );
374 assert_eq!(s, "branches 'foo' and 'bar'");
375 }
376
377 #[test]
378 fn append_joined_three() {
379 let mut s = String::new();
380 append_joined(
381 "branch ",
382 "branches ",
383 &["a".to_owned(), "b".to_owned(), "c".to_owned()],
384 &mut s,
385 );
386 assert_eq!(s, "branches 'a', 'b' and 'c'");
387 }
388}