steid

@jamesgill /

1//! Parsing what `git diff --numstat` and `git merge-tree` write.
2
3use crate::{
4 application::port::{GitQueryError, MergePreview},
5 domain::ObjectId,
6};
7
8/// Reads what `git merge-tree --write-tree` wrote.
9///
10/// The output is the merged tree's id on the first line, then — when there were
11/// conflicts — an `ls-files -u`-shaped block of `<mode> <object> <stage> TAB <path>`
12/// lines, a blank line, and git's own informational messages. The blank line is what
13/// ends the block, which is why the messages below it are never mistaken for paths.
14///
15/// `conflicted` comes from the exit status rather than from whether the block is empty:
16/// a clean merge can carry informational messages too, and reading the shape of the
17/// output to decide what the exit code already said would be two answers to one
18/// question.
19///
20/// A path appears once however many stages of it conflicted — three stages of the same
21/// file is one file to whoever has to resolve it.
22pub(super) fn parse_merge_tree(
23 stdout: &[u8],
24 conflicted: bool,
25) -> Result<MergePreview, GitQueryError> {
26 let text = String::from_utf8_lossy(stdout);
27 let mut lines = text.lines();
28
29 let tree = lines
30 .next()
31 .map(str::trim)
32 .filter(|line| !line.is_empty())
33 .ok_or_else(|| GitQueryError::new("git merge-tree named no tree"))?;
34
35 if !conflicted {
36 return Ok(MergePreview::Clean {
37 tree: ObjectId::new(tree).map_err(|error| {
38 GitQueryError::new(format!("git merge-tree named a bad tree: {error}"))
39 })?,
40 });
41 }
42
43 let mut paths: Vec<String> = Vec::new();
44
45 for line in lines {
46 if line.is_empty() {
47 break;
48 }
49
50 // The tab is git's own separator between the three fixed fields and the path,
51 // and it is what makes a filename containing spaces survive. A line without one
52 // is not a conflicted-file line; skipping it is safer than guessing where the
53 // path starts.
54 let Some((_, path)) = line.split_once('\t') else {
55 continue;
56 };
57
58 let path = path.to_owned();
59
60 if !paths.contains(&path) {
61 paths.push(path);
62 }
63 }
64
65 Ok(MergePreview::Conflicted { paths })
66}
67
68/// Splits `--numstat -p` output into its two halves.
69///
70/// git writes every `--numstat` line first and then the patch, so the boundary is the
71/// first `diff --git ` line — a marker no numstat line can produce, since one always
72/// begins with a count or the `-` that stands for a binary file. The blank line git
73/// puts between the two is not relied on.
74///
75/// A patch that is entirely absent — a commit that changed nothing, or a read cut short
76/// before the patch began — leaves the second half empty, which is a state the caller
77/// already has to handle.
78pub(super) fn split_numstat(stdout: &[u8]) -> (&[u8], &[u8]) {
79 const MARKER: &[u8] = b"diff --git ";
80
81 if stdout.starts_with(MARKER) {
82 return (&[], stdout);
83 }
84
85 for (index, byte) in stdout.iter().enumerate() {
86 if *byte == b'\n' && stdout[index + 1..].starts_with(MARKER) {
87 return stdout.split_at(index + 1);
88 }
89 }
90
91 (stdout, &[])
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97
98 // Pure, so these are tested against bytes written by hand rather than against a
99 // fixture repository: the point is the format, and a fixture would only prove
100 // that git and this parser agree about the easy cases. The round trip through a
101 // real repository is covered by the fixture tests in `git_query_tests.rs`.
102
103 #[test]
104 fn the_counts_and_the_patch_are_split_at_the_first_file_header() {
105 let (numstat, patch) = split_numstat(b"1\t1\ta.txt\n\ndiff --git a/a.txt b/a.txt\n@@\n");
106
107 assert_eq!(numstat, b"1\t1\ta.txt\n\n");
108 assert!(patch.starts_with(b"diff --git "));
109 }
110
111 #[test]
112 fn a_diff_with_nothing_in_it_splits_into_two_empties() {
113 let (numstat, patch) = split_numstat(b"");
114
115 assert!(numstat.is_empty());
116 assert!(patch.is_empty());
117 }
118
119 #[test]
120 fn a_conflict_block_stops_at_the_blank_line() {
121 // git writes its informational messages after a blank line, and they contain
122 // tabs of their own — so a parser that read to the end would report prose as
123 // filenames.
124 let stdout = concat!(
125 "0123456789012345678901234567890123456789\n",
126 "100644 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 1\tshared.txt\n",
127 "100644 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 2\tshared.txt\n",
128 "100644 cccccccccccccccccccccccccccccccccccccccc 3\tshared.txt\n",
129 "100644 dddddddddddddddddddddddddddddddddddddddd 1\tsrc/with space.rs\n",
130 "\n",
131 "Auto-merging shared.txt\n",
132 "CONFLICT (content): Merge conflict in\tshared.txt\n",
133 );
134
135 assert_eq!(
136 parse_merge_tree(stdout.as_bytes(), true).expect("should parse"),
137 MergePreview::Conflicted {
138 paths: vec!["shared.txt".to_owned(), "src/with space.rs".to_owned()],
139 }
140 );
141 }
142
143 #[test]
144 fn a_clean_merge_reads_only_its_tree() {
145 // A clean merge can still carry informational messages, which is why the exit
146 // status decides and not the shape of what was written.
147 let stdout = "0123456789012345678901234567890123456789\n\nAuto-merging other.txt\n";
148
149 assert_eq!(
150 parse_merge_tree(stdout.as_bytes(), false).expect("should parse"),
151 MergePreview::Clean {
152 tree: ObjectId::from_trusted("0123456789012345678901234567890123456789"),
153 }
154 );
155 }
156
157 #[test]
158 fn output_with_no_tree_is_a_fault_not_an_empty_merge() {
159 assert!(parse_merge_tree(b"", false).is_err());
160 assert!(parse_merge_tree(b"\n", true).is_err());
161 }
162}