use std::cell::RefCell;
use std::collections::{BTreeMap, BTreeSet};
use anyhow::{Context, Result};
use git2::{Delta, DiffFindOptions, DiffOptions, Repository};
#[derive(Debug, Clone, Copy)]
struct Hunk {
old_start: u32,
old_lines: u32,
new_lines: u32,
}
#[derive(Debug, Clone)]
pub struct FileDiff {
pub old_path: Option<String>,
pub new_path: String,
pub is_new: bool,
pub is_deleted: bool,
pub is_rename: bool,
pub added: BTreeSet<u32>,
pub removed: BTreeSet<u32>,
context: BTreeMap<u32, u32>,
hunks: Vec<Hunk>,
}
impl FileDiff {
pub fn new(
new_path: impl Into<String>,
old_path: Option<String>,
is_new: bool,
is_rename: bool,
added: BTreeSet<u32>,
removed: BTreeSet<u32>,
) -> Self {
Self {
old_path,
new_path: new_path.into(),
is_new,
is_deleted: false,
is_rename,
added,
removed,
context: BTreeMap::new(),
hunks: Vec::new(),
}
}
pub fn map_base_to_head(&self, old_line: u32) -> Option<u32> {
if let Some(&new_line) = self.context.get(&old_line) {
return Some(new_line);
}
if self
.hunks
.iter()
.any(|h| old_line >= h.old_start && old_line < h.old_start + h.old_lines)
{
return None;
}
let delta: i64 = self
.hunks
.iter()
.filter(|h| h.old_start + h.old_lines <= old_line)
.map(|h| i64::from(h.new_lines) - i64::from(h.old_lines))
.sum();
let mapped = i64::from(old_line) + delta;
u32::try_from(mapped).ok().filter(|&n| n >= 1)
}
}
#[derive(Debug, Clone, Default)]
pub struct DiffModel {
pub files: BTreeMap<String, FileDiff>,
}
impl DiffModel {
pub fn between(repo: &Repository, base_ref: &str, head_ref: Option<&str>) -> Result<Self> {
let base_tree = repo
.revparse_single(base_ref)
.with_context(|| format!("could not resolve base ref `{base_ref}`"))?
.peel_to_tree()
.with_context(|| format!("base ref `{base_ref}` is not a tree-ish"))?;
let head_ref = head_ref.unwrap_or("HEAD");
let head_tree = repo
.revparse_single(head_ref)
.with_context(|| format!("could not resolve head ref `{head_ref}`"))?
.peel_to_tree()
.with_context(|| format!("head ref `{head_ref}` is not a tree-ish"))?;
let mut opts = DiffOptions::new();
opts.context_lines(3).patience(true).indent_heuristic(true);
let mut diff = repo
.diff_tree_to_tree(Some(&base_tree), Some(&head_tree), Some(&mut opts))
.context("failed to diff base against head")?;
let mut find = DiffFindOptions::new();
find.renames(true).copies(true);
diff.find_similar(Some(&mut find))
.context("failed to run rename detection")?;
Self::from_diff(&diff)
}
pub fn from_diff(diff: &git2::Diff) -> Result<Self> {
let builders: RefCell<BTreeMap<String, FileDiff>> = RefCell::new(BTreeMap::new());
let new_path_of = |delta: &git2::DiffDelta| -> Option<String> {
delta
.new_file()
.path()
.and_then(|p| p.to_str())
.map(str::to_string)
};
diff.foreach(
&mut |delta, _progress| {
if let Some(new_path) = new_path_of(&delta) {
let old_path = delta
.old_file()
.path()
.and_then(|p| p.to_str())
.map(str::to_string);
let status = delta.status();
builders
.borrow_mut()
.entry(new_path.clone())
.or_insert(FileDiff {
old_path: old_path.filter(|_| status != Delta::Added),
new_path,
is_new: status == Delta::Added,
is_deleted: status == Delta::Deleted,
is_rename: status == Delta::Renamed || status == Delta::Copied,
added: BTreeSet::new(),
removed: BTreeSet::new(),
context: BTreeMap::new(),
hunks: Vec::new(),
});
}
true
},
None,
Some(&mut |delta, hunk| {
if let Some(new_path) = new_path_of(&delta) {
if let Some(file) = builders.borrow_mut().get_mut(&new_path) {
file.hunks.push(Hunk {
old_start: hunk.old_start(),
old_lines: hunk.old_lines(),
new_lines: hunk.new_lines(),
});
}
}
true
}),
Some(&mut |delta, _hunk, line| {
if let Some(new_path) = new_path_of(&delta) {
if let Some(file) = builders.borrow_mut().get_mut(&new_path) {
match line.origin() {
'+' => {
if let Some(n) = line.new_lineno() {
file.added.insert(n);
}
}
'-' => {
if let Some(n) = line.old_lineno() {
file.removed.insert(n);
}
}
' ' => {
if let (Some(o), Some(n)) = (line.old_lineno(), line.new_lineno()) {
file.context.insert(o, n);
}
}
_ => {}
}
}
}
true
}),
)
.context("failed to walk diff")?;
let mut files = builders.into_inner();
for file in files.values_mut() {
file.hunks.sort_by_key(|h| h.old_start);
}
Ok(Self { files })
}
}
const DEFAULT_BASE_CANDIDATES: [&str; 5] = [
"refs/remotes/origin/HEAD",
"origin/main",
"main",
"origin/master",
"master",
];
pub fn default_base_ref(repo: &Repository) -> Result<String> {
let head = repo
.head()
.context("could not resolve HEAD")?
.peel_to_commit()
.context("HEAD is not a commit")?
.id();
let (name, branch) = DEFAULT_BASE_CANDIDATES
.iter()
.find_map(|name| {
let commit = repo.revparse_single(name).ok()?.peel_to_commit().ok()?;
Some((name, commit.id()))
})
.with_context(|| {
format!(
"could not resolve a default base ref (tried {}); pass --base-ref",
DEFAULT_BASE_CANDIDATES
.map(|name| format!("`{name}`"))
.join(", ")
)
})?;
let base = repo
.merge_base(branch, head)
.with_context(|| format!("could not compute merge-base of `{name}` and HEAD"))?;
Ok(base.to_string())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn file_diff(hunks: Vec<Hunk>, context: &[(u32, u32)]) -> FileDiff {
FileDiff {
old_path: Some("f".into()),
new_path: "f".into(),
is_new: false,
is_deleted: false,
is_rename: false,
added: BTreeSet::new(),
removed: BTreeSet::new(),
context: context.iter().copied().collect(),
hunks,
}
}
#[test]
fn lines_before_first_hunk_map_identically() {
let fd = file_diff(
vec![Hunk {
old_start: 10,
old_lines: 2,
new_lines: 5,
}],
&[],
);
assert_eq!(fd.map_base_to_head(1), Some(1));
assert_eq!(fd.map_base_to_head(9), Some(9));
}
#[test]
fn lines_after_hunk_shift_by_net_delta() {
let fd = file_diff(
vec![Hunk {
old_start: 10,
old_lines: 2,
new_lines: 5,
}],
&[],
);
assert_eq!(fd.map_base_to_head(12), Some(15));
assert_eq!(fd.map_base_to_head(20), Some(23));
}
#[test]
fn modified_lines_inside_hunk_have_no_mapping() {
let fd = file_diff(
vec![Hunk {
old_start: 10,
old_lines: 2,
new_lines: 5,
}],
&[],
);
assert_eq!(fd.map_base_to_head(10), None);
assert_eq!(fd.map_base_to_head(11), None);
}
#[test]
fn context_pairs_take_precedence() {
let fd = file_diff(
vec![Hunk {
old_start: 10,
old_lines: 4,
new_lines: 4,
}],
&[(10, 10), (13, 13)],
);
assert_eq!(fd.map_base_to_head(10), Some(10));
assert_eq!(fd.map_base_to_head(13), Some(13));
assert_eq!(fd.map_base_to_head(11), None);
}
fn commit(
repo: &Repository,
path: &std::path::Path,
files: &[(&str, &str)],
parent: Option<git2::Oid>,
) -> git2::Oid {
let mut index = repo.index().unwrap();
index.clear().unwrap();
for (name, content) in files {
std::fs::write(path.join(name), content).unwrap();
index.add_path(std::path::Path::new(name)).unwrap();
}
index.write().unwrap();
let tree = repo.find_tree(index.write_tree().unwrap()).unwrap();
let sig = git2::Signature::now("T", "t@e.com").unwrap();
let parent = parent.map(|id| repo.find_commit(id).unwrap());
let parents: Vec<&git2::Commit> = parent.as_ref().into_iter().collect();
repo.commit(Some("HEAD"), &sig, &sig, "c", &tree, &parents)
.unwrap()
}
#[test]
fn default_base_ref_is_merge_base_with_main() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path();
let repo = Repository::init(path).unwrap();
let first = commit(&repo, path, &[("a.rs", "1\n")], None);
repo.branch("main", &repo.find_commit(first).unwrap(), false)
.unwrap();
commit(&repo, path, &[("a.rs", "1\n2\n")], Some(first));
let base = default_base_ref(&repo).unwrap();
assert_eq!(base, first.to_string());
}
fn repo_with_branch_point(dir: &std::path::Path) -> (Repository, git2::Oid) {
let mut opts = git2::RepositoryInitOptions::new();
opts.initial_head("trunk");
let repo = Repository::init_opts(dir, &opts).unwrap();
let first = commit(&repo, dir, &[("a.rs", "1\n")], None);
commit(&repo, dir, &[("a.rs", "1\n2\n")], Some(first));
(repo, first)
}
fn set_ref(repo: &Repository, reference: &str, target: git2::Oid) {
repo.reference(reference, target, true, "test").unwrap();
}
#[test]
fn default_base_ref_falls_back_to_master() {
let dir = tempfile::tempdir().unwrap();
let (repo, first) = repo_with_branch_point(dir.path());
repo.branch("master", &repo.find_commit(first).unwrap(), false)
.unwrap();
assert_eq!(default_base_ref(&repo).unwrap(), first.to_string());
}
#[test]
fn default_base_ref_follows_origin_head_to_a_non_main_branch() {
let dir = tempfile::tempdir().unwrap();
let (repo, first) = repo_with_branch_point(dir.path());
set_ref(&repo, "refs/remotes/origin/develop", first);
repo.reference_symbolic(
"refs/remotes/origin/HEAD",
"refs/remotes/origin/develop",
true,
"test",
)
.unwrap();
assert_eq!(default_base_ref(&repo).unwrap(), first.to_string());
}
#[test]
fn default_base_ref_prefers_origin_head_over_main() {
let dir = tempfile::tempdir().unwrap();
let (repo, first) = repo_with_branch_point(dir.path());
let head = repo.head().unwrap().peel_to_commit().unwrap().id();
set_ref(&repo, "refs/heads/main", head);
set_ref(&repo, "refs/remotes/origin/develop", first);
repo.reference_symbolic(
"refs/remotes/origin/HEAD",
"refs/remotes/origin/develop",
true,
"test",
)
.unwrap();
assert_eq!(default_base_ref(&repo).unwrap(), first.to_string());
}
#[test]
fn default_base_ref_skips_a_dangling_origin_head() {
let dir = tempfile::tempdir().unwrap();
let (repo, first) = repo_with_branch_point(dir.path());
repo.branch("main", &repo.find_commit(first).unwrap(), false)
.unwrap();
repo.reference_symbolic(
"refs/remotes/origin/HEAD",
"refs/remotes/origin/gone",
true,
"test",
)
.unwrap();
assert_eq!(default_base_ref(&repo).unwrap(), first.to_string());
}
#[test]
fn default_base_ref_prefers_main_names_over_master_names() {
let dir = tempfile::tempdir().unwrap();
let (repo, first) = repo_with_branch_point(dir.path());
let head = repo.head().unwrap().peel_to_commit().unwrap().id();
set_ref(&repo, "refs/remotes/origin/master", head);
set_ref(&repo, "refs/heads/master", head);
set_ref(&repo, "refs/heads/main", head);
set_ref(&repo, "refs/remotes/origin/main", first);
assert_eq!(default_base_ref(&repo).unwrap(), first.to_string());
repo.find_reference("refs/remotes/origin/main")
.unwrap()
.delete()
.unwrap();
set_ref(&repo, "refs/heads/main", first);
assert_eq!(default_base_ref(&repo).unwrap(), first.to_string());
}
#[test]
fn default_base_ref_error_names_every_ref_tried() {
let dir = tempfile::tempdir().unwrap();
let (repo, _) = repo_with_branch_point(dir.path());
let message = format!("{:#}", default_base_ref(&repo).unwrap_err());
for name in DEFAULT_BASE_CANDIDATES {
assert!(message.contains(&format!("`{name}`")), "{message}");
}
assert!(message.contains("--base-ref"), "{message}");
}
#[test]
fn between_records_removed_lines() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path();
let repo = Repository::init(path).unwrap();
let first = commit(&repo, path, &[("a.rs", "a\nb\nc\n")], None);
commit(&repo, path, &[("a.rs", "a\nc\n")], Some(first)); let diff = DiffModel::between(&repo, &first.to_string(), Some("HEAD")).unwrap();
let fd = diff.files.get("a.rs").unwrap();
assert!(fd.removed.contains(&2), "removed old line 2 (b)");
}
#[test]
fn between_errors_on_bad_base_ref() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path();
let repo = Repository::init(path).unwrap();
commit(&repo, path, &[("a.rs", "1\n")], None);
assert!(DiffModel::between(&repo, "does-not-exist", Some("HEAD")).is_err());
}
}