1use std::collections::BTreeSet;
8use std::path::Path;
9use std::process::Command;
10
11use crate::repo_scope::{is_supported, Tree};
12
13type R<T> = Result<T, Box<dyn std::error::Error>>;
14
15fn git(dir: &Path, args: &[&str]) -> R<String> {
16 let out = Command::new("git").args(args).current_dir(dir).output()?;
17 if !out.status.success() {
18 return Err(format!(
19 "git {} failed: {}",
20 args.join(" "),
21 String::from_utf8_lossy(&out.stderr).trim()
22 )
23 .into());
24 }
25 Ok(String::from_utf8_lossy(&out.stdout).to_string())
26}
27
28pub(crate) fn rev_exists(dir: &Path, rev: &str) -> bool {
29 Command::new("git")
30 .args(["rev-parse", "--verify", "--quiet", rev])
31 .current_dir(dir)
32 .output()
33 .map(|o| o.status.success())
34 .unwrap_or(false)
35}
36
37pub(crate) fn resolve_revs(
43 dir: &Path,
44 base: Option<&str>,
45 ours: Option<&str>,
46 theirs: Option<&str>,
47) -> R<(String, String, String)> {
48 if !rev_exists(dir, "HEAD") {
49 return Err(
50 "not a git repository (or no commits yet) — use the directory mode instead".into(),
51 );
52 }
53 let ours = ours.unwrap_or("HEAD").to_string();
54 let theirs = match theirs {
55 Some(t) => t.to_string(),
56 None if rev_exists(dir, "MERGE_HEAD") => "MERGE_HEAD".to_string(),
57 None => {
58 return Err("no --theirs given and no merge in progress (MERGE_HEAD absent)".into())
59 }
60 };
61 let base = match base {
62 Some(b) => b.to_string(),
63 None => git(dir, &["merge-base", &ours, &theirs])?
64 .trim()
65 .to_string(),
66 };
67 Ok((base, ours, theirs))
68}
69
70pub fn trees(
71 dir: &Path,
72 base: Option<&str>,
73 ours: Option<&str>,
74 theirs: Option<&str>,
75) -> R<(Tree, Tree, Tree)> {
76 let (base, ours, theirs) = resolve_revs(dir, base, ours, theirs)?;
77 Ok((
78 read_rev_tree(dir, &base)?,
79 read_rev_tree(dir, &ours)?,
80 read_rev_tree(dir, &theirs)?,
81 ))
82}
83
84pub struct MergeScope {
88 pub base: Tree,
89 pub ours: Tree,
90 pub theirs: Tree,
91 pub work: Tree,
93 pub subjects: Vec<String>,
95 pub scope: String,
96}
97
98pub fn merge_scope(dir: &Path) -> R<Option<MergeScope>> {
112 if !rev_exists(dir, "HEAD") {
113 return Ok(None);
114 }
115 let (ours_rev, theirs_rev, moment) = if rev_exists(dir, "MERGE_HEAD") {
116 ("HEAD".to_string(), "MERGE_HEAD".to_string(), "in progress")
117 } else if rev_exists(dir, "HEAD^2") {
118 ("HEAD^1".to_string(), "HEAD^2".to_string(), "just committed")
119 } else {
120 return Ok(None);
121 };
122 let base_rev = git(dir, &["merge-base", &ours_rev, &theirs_rev])?
123 .trim()
124 .to_string();
125 let base = read_rev_tree(dir, &base_rev)?;
126 let ours = read_rev_tree(dir, &ours_rev)?;
127 let theirs = read_rev_tree(dir, &theirs_rev)?;
128 let work = read_worktree(dir)?;
129
130 let mut subjects: BTreeSet<String> = base
137 .keys()
138 .chain(ours.keys())
139 .chain(theirs.keys())
140 .filter(|p| ours.get(*p) != base.get(*p) || theirs.get(*p) != base.get(*p))
141 .cloned()
142 .collect();
143 if let Ok(list) = git(dir, &["diff", "--name-only", "--diff-filter=U", "-z"]) {
146 subjects.extend(
147 list.split('\0')
148 .filter(|p| !p.is_empty() && is_supported(p))
149 .map(str::to_string),
150 );
151 }
152 let subjects: Vec<String> = subjects.into_iter().collect();
153 let scope = format!(
154 "working tree vs the three merge stages of {ours_rev} × {theirs_rev} \
155 (base {}, merge {moment}) — {} file(s) either side changed",
156 &base_rev[..base_rev.len().min(8)],
157 subjects.len()
158 );
159 Ok(Some(MergeScope {
160 base,
161 ours,
162 theirs,
163 work,
164 subjects,
165 scope,
166 }))
167}
168
169pub(crate) fn read_worktree(dir: &Path) -> R<Tree> {
171 let listing = git(dir, &["ls-files", "-z"])?;
172 let mut tree = Tree::new();
173 for rel in listing.split('\0').filter(|p| !p.is_empty()) {
174 if !is_supported(rel) {
175 continue;
176 }
177 if let Ok(content) = std::fs::read_to_string(dir.join(rel)) {
178 tree.insert(rel.to_string(), content);
179 }
180 }
181 Ok(tree)
182}
183
184pub fn file_stages(dir: &Path, path: &str) -> R<(String, String, String)> {
193 let stage = |n: u8| -> Option<String> {
194 let out = Command::new("git")
195 .args(["show", &format!(":{n}:{path}")])
196 .current_dir(dir)
197 .output()
198 .ok()?;
199 out.status
200 .success()
201 .then(|| String::from_utf8_lossy(&out.stdout).to_string())
202 };
203 if let (Some(b), Some(o), Some(t)) = (stage(1), stage(2), stage(3)) {
204 return Ok((b, o, t));
205 }
206 let scope = merge_scope(dir)?.ok_or(
207 "no merge in progress and HEAD is not a merge commit — there is no three-way \
208 context to explain this file against",
209 )?;
210 let get = |t: &Tree| t.get(path).cloned().unwrap_or_default();
211 if !scope.ours.contains_key(path) && !scope.theirs.contains_key(path) {
212 return Err(format!("`{path}` is in neither side of this merge").into());
213 }
214 Ok((get(&scope.base), get(&scope.ours), get(&scope.theirs)))
215}
216
217pub(crate) fn read_rev_tree(dir: &Path, rev: &str) -> R<Tree> {
221 let listing = git(dir, &["ls-tree", "-r", "--name-only", "-z", rev])?;
222 let mut tree = Tree::new();
223 for path in listing.split('\0').filter(|p| !p.is_empty()) {
224 if !is_supported(path) {
225 continue;
226 }
227 let out = Command::new("git")
228 .args(["show", &format!("{rev}:{path}")])
229 .current_dir(dir)
230 .output()?;
231 if !out.status.success() {
232 continue;
233 }
234 if let Ok(content) = String::from_utf8(out.stdout) {
235 tree.insert(path.to_string(), content);
236 }
237 }
238 Ok(tree)
239}