Skip to main content

grit_lib/
fmt_merge_msg.rs

1//! Merge commit message formatter — `git fmt-merge-msg` logic.
2//!
3//! Reads FETCH_HEAD-style lines and produces a human-readable merge commit
4//! message of the form:
5//!
6//! ```text
7//! Merge branch 'foo' of https://example.com/repo
8//! ```
9//!
10//! or for multiple branches:
11//!
12//! ```text
13//! Merge branches 'a', 'b' and 'c'
14//! ```
15//!
16//! # Input format
17//!
18//! Each line is one of:
19//!
20//! ```text
21//! <sha1>TAB<desc>TABbranch '<name>' of <url>
22//! <sha1>TABnot-for-mergeTabranch '<name>' of <url>
23//! ```
24//!
25//! Lines with `not-for-merge` are skipped.
26
27/// Options for [`fmt_merge_msg`].
28#[derive(Debug, Clone, Default)]
29pub struct FmtMergeMsgOptions {
30    /// Override the first line of the message with this text.
31    /// When set, the branch-name title is suppressed.
32    pub message: Option<String>,
33    /// Override the target branch name shown in `into <branch>`.
34    pub into_name: Option<String>,
35}
36
37/// Format a merge commit message from FETCH_HEAD-style input.
38///
39/// `input` should contain lines in FETCH_HEAD format.  Returns the formatted
40/// message including a trailing newline, or an empty string when there is
41/// nothing to merge.
42pub 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    // Ensure trailing newline.
55    if !output.is_empty() && !output.ends_with('\n') {
56        output.push('\n');
57    }
58
59    output
60}
61
62/// A parsed FETCH_HEAD entry that is for-merge.
63#[derive(Debug, Clone)]
64struct FetchEntry {
65    /// The description field (everything after the first two TABs, or after
66    /// the first TAB for simplified formats).
67    description: String,
68}
69
70/// Parse FETCH_HEAD lines and return only for-merge entries.
71fn parse_fetch_head(input: &str) -> Vec<FetchEntry> {
72    let mut entries = Vec::new();
73
74    for line in input.lines() {
75        // Find the first TAB.
76        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        // Skip not-for-merge lines.
84        if rest.starts_with("not-for-merge") {
85            continue;
86        }
87
88        // For for-merge lines the real FETCH_HEAD format is:
89        //   <sha1>\t\t<description>
90        // The second field is empty (an empty flag field), so we have two
91        // consecutive TABs.  Skip the second TAB if present.
92        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/// Categorize an entry description.
107#[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/// Per-source-repository merge data.
155#[derive(Debug, Default)]
156struct SrcData {
157    branches: Vec<String>,
158    tags: Vec<String>,
159    remote_branches: Vec<String>,
160    generics: Vec<String>,
161}
162
163/// Build a `Merge …` title line from for-merge entries.
164fn build_title(entries: &[FetchEntry], into_name: Option<&str>) -> String {
165    // Preserve insertion order for sources while still allowing fast lookup.
166    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        // Safety: we just inserted `src` if it was missing, so get_mut always succeeds.
188        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    // Append "into <branch>" unless the destination is suppressed.
248    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
258/// Git's default suppress-dest patterns (`main`, `master`).
259fn is_suppressed_dest(dest: &str) -> bool {
260    dest == "main" || dest == "master"
261}
262
263/// Append `singular'<n>'` or `plural'<a>', '<b>' and '<c>'` to `out`.
264fn 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        // Local merge (no URL) — two TABs.
310        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}