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
//! Parsing [`FETCH_HEAD`](https://git-scm.com/docs/git-fetch#_discussion) lines.
/// Collect 40-character hex object IDs from `FETCH_HEAD` content for lines that are **for merge**.
///
/// Git marks merge candidates with either:
/// - `<oid>` + tab + tab + description (empty middle field; typical `branch '…' of …` lines), or
/// - `<oid>` + tab + description when there is no `not-for-merge` marker.
///
/// Lines containing `not-for-merge` after the first tab are skipped.
///
/// This matches the rules used by [`crate::fmt_merge_msg`] and fixes incorrect handling where
/// splitting on tab treated the empty middle field as a separate token.
#[must_use]
pub fn merge_object_ids_hex(input: &str) -> Vec<String> {
let mut out = Vec::new();
for line in input.lines() {
if line.trim().is_empty() {
continue;
}
let Some(first_tab) = line.find('\t') else {
continue;
};
let oid = &line[..first_tab];
if oid.len() != 40 || !oid.bytes().all(|b| b.is_ascii_hexdigit()) {
continue;
}
let rest = &line[first_tab + 1..];
if rest.starts_with("not-for-merge") {
continue;
}
let desc = rest.strip_prefix('\t').unwrap_or(rest);
if desc.is_empty() {
continue;
}
out.push(oid.to_owned());
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn double_tab_for_merge_branch() {
let input = "a".repeat(40);
let line = format!("{input}\t\tbranch 'main' of ../repo2\n");
let oids = merge_object_ids_hex(&line);
assert_eq!(oids, vec![input]);
}
#[test]
fn not_for_merge_skipped() {
let input = format!(
"{}\tnot-for-merge\tbranch 'other' of ../x\n",
"b".repeat(40)
);
assert!(merge_object_ids_hex(&input).is_empty());
}
#[test]
fn bare_url_line_for_merge() {
let oid = "c".repeat(40);
let line = format!("{oid}\t\t../repo2\n");
assert_eq!(merge_object_ids_hex(&line), vec![oid]);
}
}