use std::collections::BTreeSet;
use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};
use crate::repo_scope::{is_supported, Tree};
type R<T> = Result<T, Box<dyn std::error::Error>>;
fn git(dir: &Path, args: &[&str]) -> R<String> {
let out = Command::new("git").args(args).current_dir(dir).output()?;
if !out.status.success() {
return Err(format!(
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr).trim()
)
.into());
}
Ok(String::from_utf8_lossy(&out.stdout).to_string())
}
fn read_paths_at_rev(dir: &Path, rev: &str, paths: &[String]) -> R<Tree> {
let mut tree = Tree::new();
if paths.is_empty() {
return Ok(tree);
}
let mut child = Command::new("git")
.args(["cat-file", "--batch"])
.current_dir(dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()?;
{
let mut stdin = child.stdin.take().ok_or("cat-file stdin")?;
let mut buf = String::new();
for p in paths {
buf.push_str(rev);
buf.push(':');
buf.push_str(p);
buf.push('\n');
}
stdin.write_all(buf.as_bytes())?;
}
let out = child.wait_with_output()?;
if !out.status.success() {
return Err("git cat-file --batch failed".into());
}
let bytes = &out.stdout;
let mut pos = 0usize;
for p in paths {
let Some(nl) = bytes[pos..].iter().position(|&b| b == b'\n') else {
break;
};
let header = String::from_utf8_lossy(&bytes[pos..pos + nl]).to_string();
pos += nl + 1;
let fields: Vec<&str> = header.rsplitn(3, ' ').collect();
if fields.len() == 3 {
if let Ok(size) = fields[0].parse::<usize>() {
let content = &bytes[pos..pos + size];
if let Ok(s) = std::str::from_utf8(content) {
tree.insert(p.clone(), s.to_string());
}
pos += size + 1; continue;
}
}
}
Ok(tree)
}
pub(crate) fn rev_exists(dir: &Path, rev: &str) -> bool {
Command::new("git")
.args(["rev-parse", "--verify", "--quiet", rev])
.current_dir(dir)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
pub(crate) fn resolve_revs(
dir: &Path,
base: Option<&str>,
ours: Option<&str>,
theirs: Option<&str>,
) -> R<(String, String, String)> {
if !rev_exists(dir, "HEAD") {
return Err(
"not a git repository (or no commits yet) — use the directory mode instead".into(),
);
}
let ours = ours.unwrap_or("HEAD").to_string();
let theirs = match theirs {
Some(t) => t.to_string(),
None if rev_exists(dir, "MERGE_HEAD") => "MERGE_HEAD".to_string(),
None => {
return Err("no --theirs given and no merge in progress (MERGE_HEAD absent)".into())
}
};
let base = match base {
Some(b) => b.to_string(),
None => git(dir, &["merge-base", &ours, &theirs])?
.trim()
.to_string(),
};
Ok((base, ours, theirs))
}
pub fn trees(
dir: &Path,
base: Option<&str>,
ours: Option<&str>,
theirs: Option<&str>,
) -> R<(Tree, Tree, Tree)> {
let (base, ours, theirs) = resolve_revs(dir, base, ours, theirs)?;
Ok((
read_rev_tree(dir, &base)?,
read_rev_tree(dir, &ours)?,
read_rev_tree(dir, &theirs)?,
))
}
pub struct MergeScope {
pub base: Tree,
pub ours: Tree,
pub theirs: Tree,
pub work: Tree,
pub subjects: Vec<String>,
pub scope: String,
}
pub fn merge_scope(dir: &Path) -> R<Option<MergeScope>> {
if !rev_exists(dir, "HEAD") {
return Ok(None);
}
let (ours_rev, theirs_rev, moment) = if rev_exists(dir, "MERGE_HEAD") {
("HEAD".to_string(), "MERGE_HEAD".to_string(), "in progress")
} else if rev_exists(dir, "HEAD^2") {
("HEAD^1".to_string(), "HEAD^2".to_string(), "just committed")
} else {
return Ok(None);
};
let base_rev = git(dir, &["merge-base", &ours_rev, &theirs_rev])?
.trim()
.to_string();
let mut subjects: BTreeSet<String> = BTreeSet::new();
for side in [&ours_rev, &theirs_rev] {
let list = git(dir, &["diff", "--name-only", "-z", &base_rev, side])?;
subjects.extend(
list.split('\0')
.filter(|p| !p.is_empty() && is_supported(p))
.map(str::to_string),
);
}
if let Ok(list) = git(dir, &["diff", "--name-only", "--diff-filter=U", "-z"]) {
subjects.extend(
list.split('\0')
.filter(|p| !p.is_empty() && is_supported(p))
.map(str::to_string),
);
}
let subjects: Vec<String> = subjects.into_iter().collect();
let base = read_paths_at_rev(dir, &base_rev, &subjects)?;
let ours = read_paths_at_rev(dir, &ours_rev, &subjects)?;
let theirs = read_paths_at_rev(dir, &theirs_rev, &subjects)?;
let work = read_worktree(dir)?;
let scope = format!(
"working tree vs the three merge stages of {ours_rev} × {theirs_rev} \
(base {}, merge {moment}) — {} file(s) either side changed",
&base_rev[..base_rev.len().min(8)],
subjects.len()
);
Ok(Some(MergeScope {
base,
ours,
theirs,
work,
subjects,
scope,
}))
}
pub(crate) fn read_worktree(dir: &Path) -> R<Tree> {
let listing = git(dir, &["ls-files", "-z"])?;
let mut tree = Tree::new();
for rel in listing.split('\0').filter(|p| !p.is_empty()) {
if !is_supported(rel) {
continue;
}
if let Ok(content) = std::fs::read_to_string(dir.join(rel)) {
tree.insert(rel.to_string(), content);
}
}
Ok(tree)
}
pub fn file_stages(dir: &Path, path: &str) -> R<(String, String, String)> {
let stage = |n: u8| -> Option<String> {
let out = Command::new("git")
.args(["show", &format!(":{n}:{path}")])
.current_dir(dir)
.output()
.ok()?;
out.status
.success()
.then(|| String::from_utf8_lossy(&out.stdout).to_string())
};
if let (Some(b), Some(o), Some(t)) = (stage(1), stage(2), stage(3)) {
return Ok((b, o, t));
}
let scope = merge_scope(dir)?.ok_or(
"no merge in progress and HEAD is not a merge commit — there is no three-way \
context to explain this file against",
)?;
let get = |t: &Tree| t.get(path).cloned().unwrap_or_default();
if !scope.ours.contains_key(path) && !scope.theirs.contains_key(path) {
return Err(format!("`{path}` is in neither side of this merge").into());
}
Ok((get(&scope.base), get(&scope.ours), get(&scope.theirs)))
}
pub(crate) fn read_rev_tree(dir: &Path, rev: &str) -> R<Tree> {
let listing = git(dir, &["ls-tree", "-r", "--name-only", "-z", rev])?;
let mut tree = Tree::new();
for path in listing.split('\0').filter(|p| !p.is_empty()) {
if !is_supported(path) {
continue;
}
let out = Command::new("git")
.args(["show", &format!("{rev}:{path}")])
.current_dir(dir)
.output()?;
if !out.status.success() {
continue;
}
if let Ok(content) = String::from_utf8(out.stdout) {
tree.insert(path.to_string(), content);
}
}
Ok(tree)
}