1use std::collections::BTreeSet;
8use std::io::Write;
9use std::path::Path;
10use std::process::{Command, Stdio};
11
12use crate::repo_scope::{is_supported, Tree};
13
14type R<T> = Result<T, Box<dyn std::error::Error>>;
15
16fn git(dir: &Path, args: &[&str]) -> R<String> {
17 let out = Command::new("git").args(args).current_dir(dir).output()?;
18 if !out.status.success() {
19 return Err(format!(
20 "git {} failed: {}",
21 args.join(" "),
22 String::from_utf8_lossy(&out.stderr).trim()
23 )
24 .into());
25 }
26 Ok(String::from_utf8_lossy(&out.stdout).to_string())
27}
28
29fn read_paths_at_rev(dir: &Path, rev: &str, paths: &[String]) -> R<Tree> {
40 let mut tree = Tree::new();
41 if paths.is_empty() {
42 return Ok(tree);
43 }
44 let mut child = Command::new("git")
45 .args(["cat-file", "--batch"])
46 .current_dir(dir)
47 .stdin(Stdio::piped())
48 .stdout(Stdio::piped())
49 .stderr(Stdio::null())
50 .spawn()?;
51
52 {
55 let mut stdin = child.stdin.take().ok_or("cat-file stdin")?;
56 let mut buf = String::new();
57 for p in paths {
58 buf.push_str(rev);
59 buf.push(':');
60 buf.push_str(p);
61 buf.push('\n');
62 }
63 stdin.write_all(buf.as_bytes())?;
64 }
66 let out = child.wait_with_output()?;
67 if !out.status.success() {
68 return Err("git cat-file --batch failed".into());
69 }
70
71 let bytes = &out.stdout;
73 let mut pos = 0usize;
74 for p in paths {
75 let Some(nl) = bytes[pos..].iter().position(|&b| b == b'\n') else {
77 break;
78 };
79 let header = String::from_utf8_lossy(&bytes[pos..pos + nl]).to_string();
80 pos += nl + 1;
81 let fields: Vec<&str> = header.rsplitn(3, ' ').collect();
82 if fields.len() == 3 {
85 if let Ok(size) = fields[0].parse::<usize>() {
86 let content = &bytes[pos..pos + size];
87 if let Ok(s) = std::str::from_utf8(content) {
88 tree.insert(p.clone(), s.to_string());
89 }
90 pos += size + 1; continue;
92 }
93 }
94 }
96 Ok(tree)
97}
98
99pub(crate) fn rev_exists(dir: &Path, rev: &str) -> bool {
100 Command::new("git")
101 .args(["rev-parse", "--verify", "--quiet", rev])
102 .current_dir(dir)
103 .output()
104 .map(|o| o.status.success())
105 .unwrap_or(false)
106}
107
108pub(crate) fn resolve_revs(
114 dir: &Path,
115 base: Option<&str>,
116 ours: Option<&str>,
117 theirs: Option<&str>,
118) -> R<(String, String, String)> {
119 if !rev_exists(dir, "HEAD") {
120 return Err(
121 "not a git repository (or no commits yet) — use the directory mode instead".into(),
122 );
123 }
124 let ours = ours.unwrap_or("HEAD").to_string();
125 let theirs = match theirs {
126 Some(t) => t.to_string(),
127 None if rev_exists(dir, "MERGE_HEAD") => "MERGE_HEAD".to_string(),
128 None => {
129 return Err("no --theirs given and no merge in progress (MERGE_HEAD absent)".into())
130 }
131 };
132 let base = match base {
133 Some(b) => b.to_string(),
134 None => git(dir, &["merge-base", &ours, &theirs])?
135 .trim()
136 .to_string(),
137 };
138 Ok((base, ours, theirs))
139}
140
141pub fn trees(
142 dir: &Path,
143 base: Option<&str>,
144 ours: Option<&str>,
145 theirs: Option<&str>,
146) -> R<(Tree, Tree, Tree)> {
147 let (base, ours, theirs) = resolve_revs(dir, base, ours, theirs)?;
148 Ok((
149 read_rev_tree(dir, &base)?,
150 read_rev_tree(dir, &ours)?,
151 read_rev_tree(dir, &theirs)?,
152 ))
153}
154
155pub struct MergeScope {
159 pub base: Tree,
160 pub ours: Tree,
161 pub theirs: Tree,
162 pub work: Tree,
164 pub subjects: Vec<String>,
166 pub scope: String,
167}
168
169pub fn merge_scope(dir: &Path) -> R<Option<MergeScope>> {
183 if !rev_exists(dir, "HEAD") {
184 return Ok(None);
185 }
186 let (ours_rev, theirs_rev, moment) = if rev_exists(dir, "MERGE_HEAD") {
187 ("HEAD".to_string(), "MERGE_HEAD".to_string(), "in progress")
188 } else if rev_exists(dir, "HEAD^2") {
189 ("HEAD^1".to_string(), "HEAD^2".to_string(), "just committed")
190 } else {
191 return Ok(None);
192 };
193 let base_rev = git(dir, &["merge-base", &ours_rev, &theirs_rev])?
194 .trim()
195 .to_string();
196
197 let mut subjects: BTreeSet<String> = BTreeSet::new();
210 for side in [&ours_rev, &theirs_rev] {
211 let list = git(dir, &["diff", "--name-only", "-z", &base_rev, side])?;
212 subjects.extend(
213 list.split('\0')
214 .filter(|p| !p.is_empty() && is_supported(p))
215 .map(str::to_string),
216 );
217 }
218 if let Ok(list) = git(dir, &["diff", "--name-only", "--diff-filter=U", "-z"]) {
221 subjects.extend(
222 list.split('\0')
223 .filter(|p| !p.is_empty() && is_supported(p))
224 .map(str::to_string),
225 );
226 }
227 let subjects: Vec<String> = subjects.into_iter().collect();
228
229 let base = read_paths_at_rev(dir, &base_rev, &subjects)?;
239 let ours = read_paths_at_rev(dir, &ours_rev, &subjects)?;
240 let theirs = read_paths_at_rev(dir, &theirs_rev, &subjects)?;
241 let work = read_worktree(dir)?;
242 let scope = format!(
243 "working tree vs the three merge stages of {ours_rev} × {theirs_rev} \
244 (base {}, merge {moment}) — {} file(s) either side changed",
245 &base_rev[..base_rev.len().min(8)],
246 subjects.len()
247 );
248 Ok(Some(MergeScope {
249 base,
250 ours,
251 theirs,
252 work,
253 subjects,
254 scope,
255 }))
256}
257
258pub(crate) fn read_worktree(dir: &Path) -> R<Tree> {
260 let listing = git(dir, &["ls-files", "-z"])?;
261 let mut tree = Tree::new();
262 for rel in listing.split('\0').filter(|p| !p.is_empty()) {
263 if !is_supported(rel) {
264 continue;
265 }
266 if let Ok(content) = std::fs::read_to_string(dir.join(rel)) {
267 tree.insert(rel.to_string(), content);
268 }
269 }
270 Ok(tree)
271}
272
273pub fn file_stages(dir: &Path, path: &str) -> R<(String, String, String)> {
282 let stage = |n: u8| -> Option<String> {
283 let out = Command::new("git")
284 .args(["show", &format!(":{n}:{path}")])
285 .current_dir(dir)
286 .output()
287 .ok()?;
288 out.status
289 .success()
290 .then(|| String::from_utf8_lossy(&out.stdout).to_string())
291 };
292 if let (Some(b), Some(o), Some(t)) = (stage(1), stage(2), stage(3)) {
293 return Ok((b, o, t));
294 }
295 let scope = merge_scope(dir)?.ok_or(
296 "no merge in progress and HEAD is not a merge commit — there is no three-way \
297 context to explain this file against",
298 )?;
299 let get = |t: &Tree| t.get(path).cloned().unwrap_or_default();
300 if !scope.ours.contains_key(path) && !scope.theirs.contains_key(path) {
301 return Err(format!("`{path}` is in neither side of this merge").into());
302 }
303 Ok((get(&scope.base), get(&scope.ours), get(&scope.theirs)))
304}
305
306pub(crate) fn read_rev_tree(dir: &Path, rev: &str) -> R<Tree> {
310 let listing = git(dir, &["ls-tree", "-r", "--name-only", "-z", rev])?;
311 let mut tree = Tree::new();
312 for path in listing.split('\0').filter(|p| !p.is_empty()) {
313 if !is_supported(path) {
314 continue;
315 }
316 let out = Command::new("git")
317 .args(["show", &format!("{rev}:{path}")])
318 .current_dir(dir)
319 .output()?;
320 if !out.status.success() {
321 continue;
322 }
323 if let Ok(content) = String::from_utf8(out.stdout) {
324 tree.insert(path.to_string(), content);
325 }
326 }
327 Ok(tree)
328}