use similar::{ChangeTag, TextDiff};
use crate::diff::{
diff_trees_with_renames, unified_diff_histogram_with_prefix_and_funcname, zero_oid, DiffEntry,
DiffStatus,
};
use crate::error::Result;
use crate::hash::ChecksumHasher;
use crate::merge_file;
use crate::objects::{CommitRef, HashAlgo, ObjectId, ObjectKind};
use crate::odb::Odb;
const PATCH_ID_DIGEST_MAX: usize = 32;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PatchIdMode {
Unstable,
Stable,
Verbatim,
}
pub fn compute_patch_ids_from_text(
algo: HashAlgo,
input: &[u8],
mode: PatchIdMode,
) -> Vec<(ObjectId, ObjectId)> {
let stable = mode != PatchIdMode::Unstable;
let verbatim = mode == PatchIdMode::Verbatim;
let digest_len = algo.len();
let mut results: Vec<(ObjectId, ObjectId)> = Vec::new();
let mut ctx = ChecksumHasher::new(algo);
let mut result = [0u8; PATCH_ID_DIGEST_MAX];
let mut patchlen: usize = 0;
let mut before: i32 = -1;
let mut after: i32 = -1;
let mut diff_is_binary = false;
let mut pre_oid_str = String::new();
let mut post_oid_str = String::new();
let mut current_commit: Option<ObjectId> = None;
let mut implicit_commit = true;
let lines = split_lines_with_nl(input);
let mut i = 0;
while i < lines.len() {
let raw = lines[i];
i += 1;
let line = std::str::from_utf8(raw).unwrap_or("");
let oid_candidate: Option<&str> = if let Some(rest) = line.strip_prefix("commit ") {
Some(rest)
} else if let Some(rest) = line.strip_prefix("From ") {
Some(rest)
} else {
None
};
if let Some(candidate) = oid_candidate {
if let Some(oid) = try_parse_oid_prefix(candidate, algo) {
text_flush_one_hunk(&mut result[..digest_len], digest_len, &mut ctx, algo);
if patchlen > 0 {
if let Some(coid) = current_commit.take() {
if let Ok(pid) = ObjectId::from_bytes(&result[..digest_len]) {
results.push((pid, coid));
}
}
}
result = [0u8; PATCH_ID_DIGEST_MAX];
ctx = ChecksumHasher::new(algo);
patchlen = 0;
before = -1;
after = -1;
diff_is_binary = false;
pre_oid_str.clear();
post_oid_str.clear();
current_commit = Some(oid);
implicit_commit = false;
continue;
}
}
if line.starts_with("\\ ") && line.len() > 12 {
if verbatim {
ctx.update(raw);
patchlen += raw.len();
}
continue;
}
if patchlen == 0 && !line.starts_with("diff ") {
continue;
}
if implicit_commit && line.starts_with("diff ") && current_commit.is_none() {
current_commit = Some(ObjectId::zero());
implicit_commit = false;
}
if before == -1 {
if line.starts_with("GIT binary patch") || line.starts_with("Binary files") {
diff_is_binary = true;
before = 0;
let pre = pre_oid_str.clone();
let post = post_oid_str.clone();
ctx.update(pre.as_bytes());
ctx.update(post.as_bytes());
patchlen += pre.len() + post.len();
if stable {
text_flush_one_hunk(&mut result[..digest_len], digest_len, &mut ctx, algo);
}
continue;
} else if let Some(rest) = line.strip_prefix("index ") {
if let Some(dd) = rest.find("..") {
pre_oid_str = rest[..dd].to_owned();
let tail = &rest[dd + 2..];
let end = tail
.find(|c: char| c.is_ascii_whitespace())
.unwrap_or_else(|| {
tail.trim_end_matches('\n').trim_end_matches('\r').len()
});
post_oid_str = tail[..end].to_owned();
}
continue;
} else if line.starts_with("--- ") {
before = 1;
after = 1;
} else if !line.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) {
text_flush_one_hunk(&mut result[..digest_len], digest_len, &mut ctx, algo);
if patchlen > 0 {
if let Some(coid) = current_commit.take() {
if let Ok(pid) = ObjectId::from_bytes(&result[..digest_len]) {
results.push((pid, coid));
}
}
}
result = [0u8; PATCH_ID_DIGEST_MAX];
ctx = ChecksumHasher::new(algo);
patchlen = 0;
before = -1;
after = -1;
diff_is_binary = false;
continue;
}
}
if diff_is_binary {
if line.starts_with("diff ") {
diff_is_binary = false;
before = -1;
i -= 1; }
continue;
}
if before == 0 && after == 0 {
if line.starts_with("@@ -") {
let (b, a) = scan_hunk_header(line);
before = b;
after = a;
continue;
}
if !line.starts_with("diff ") {
continue;
}
if stable {
text_flush_one_hunk(&mut result[..digest_len], digest_len, &mut ctx, algo);
}
before = -1;
after = -1;
i -= 1;
continue;
}
let first = raw.first().copied().unwrap_or(b' ');
if first == b'-' || first == b' ' {
before -= 1;
}
if first == b'+' || first == b' ' {
after -= 1;
}
let hashed = if verbatim {
ctx.update(raw);
raw.len()
} else {
hash_without_whitespace(&mut ctx, raw)
};
patchlen += hashed;
}
text_flush_one_hunk(&mut result[..digest_len], digest_len, &mut ctx, algo);
if patchlen > 0 {
if let Some(coid) = current_commit {
if let Ok(pid) = ObjectId::from_bytes(&result[..digest_len]) {
results.push((pid, coid));
}
}
}
results
}
fn text_flush_one_hunk(
result: &mut [u8],
digest_len: usize,
ctx: &mut ChecksumHasher,
algo: HashAlgo,
) {
let mut hash_buf = [0u8; PATCH_ID_DIGEST_MAX];
let old = std::mem::replace(ctx, ChecksumHasher::new(algo));
let _ = old.finalize_into(&mut hash_buf);
let mut carry: u16 = 0;
for i in 0..digest_len {
carry = carry + result[i] as u16 + hash_buf[i] as u16;
result[i] = carry as u8;
carry >>= 8;
}
}
fn hash_without_whitespace(ctx: &mut ChecksumHasher, raw: &[u8]) -> usize {
let mut count = 0;
for &b in raw {
if !b.is_ascii_whitespace() {
ctx.update(&[b]);
count += 1;
}
}
count
}
fn try_parse_oid_prefix(s: &str, algo: HashAlgo) -> Option<ObjectId> {
let s = s.trim_end_matches('\n').trim_end_matches('\r');
let hex_len = algo.len() * 2;
if s.len() < hex_len {
return None;
}
let hex = &s[..hex_len];
if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
if s.len() > hex_len && !s.as_bytes()[hex_len].is_ascii_whitespace() {
return None;
}
let mut bytes = [0u8; PATCH_ID_DIGEST_MAX];
for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
let hi = hex_val(chunk[0])?;
let lo = hex_val(chunk[1])?;
bytes[i] = (hi << 4) | lo;
}
ObjectId::from_bytes(&bytes[..algo.len()]).ok()
}
fn hex_val(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
fn scan_hunk_header(line: &str) -> (i32, i32) {
let rest = match line.strip_prefix("@@ -") {
Some(r) => r,
None => return (1, 1),
};
let before = parse_hunk_count(rest);
let after = rest
.find(" +")
.and_then(|p| parse_hunk_count_opt(&rest[p + 2..]))
.unwrap_or(1);
(before, after)
}
fn parse_hunk_count(s: &str) -> i32 {
let after_start = s.trim_start_matches(|c: char| c.is_ascii_digit());
if let Some(rest) = after_start.strip_prefix(',') {
rest.split(|c: char| !c.is_ascii_digit())
.next()
.and_then(|n| n.parse().ok())
.unwrap_or(1)
} else {
1
}
}
fn parse_hunk_count_opt(s: &str) -> Option<i32> {
Some(parse_hunk_count(s))
}
fn split_lines_with_nl(input: &[u8]) -> Vec<&[u8]> {
let mut lines = Vec::new();
let mut start = 0;
for (i, &b) in input.iter().enumerate() {
if b == b'\n' {
lines.push(&input[start..=i]);
start = i + 1;
}
}
if start < input.len() {
lines.push(&input[start..]);
}
lines
}
pub fn compute_patch_id(
algo: HashAlgo,
odb: &Odb,
commit_oid: &ObjectId,
mode: PatchIdMode,
) -> Result<Option<ObjectId>> {
compute_patch_id_filtered(algo, odb, commit_oid, &[], mode)
}
pub fn compute_patch_id_for_paths(
algo: HashAlgo,
odb: &Odb,
commit_oid: &ObjectId,
paths: &[String],
mode: PatchIdMode,
) -> Result<Option<ObjectId>> {
compute_patch_id_filtered(algo, odb, commit_oid, paths, mode)
}
fn compute_patch_id_filtered(
algo: HashAlgo,
odb: &Odb,
commit_oid: &ObjectId,
paths: &[String],
mode: PatchIdMode,
) -> Result<Option<ObjectId>> {
let per_file_flush = mode != PatchIdMode::Unstable;
let digest_len = algo.len();
let obj = odb.read(commit_oid)?;
if obj.kind != ObjectKind::Commit {
return Ok(None);
}
let commit = CommitRef::parse(&obj.data)?;
if commit.parents().len() > 1 {
return Ok(None);
}
let parent_tree_oid = if commit.parents().is_empty() {
None
} else {
let parent_obj = odb.read(&commit.parents()[0])?;
let parent_commit = CommitRef::parse(&parent_obj.data)?;
Some(parent_commit.tree())
};
let mut diffs =
diff_trees_with_renames(odb, parent_tree_oid.as_ref(), Some(&commit.tree()), "", 50)?;
if !paths.is_empty() {
diffs.retain(|entry| diff_entry_matches_paths(entry, paths));
}
if diffs.is_empty() {
return Ok(None);
}
diffs.sort_by(|a, b| a.path().cmp(b.path()));
if mode == PatchIdMode::Verbatim {
let patch = patch_id_build_git_show_patch(odb, commit_oid, &diffs)?;
let ids = compute_patch_ids_from_text(algo, &patch, mode);
return Ok(ids.into_iter().next().map(|(patch_id, _)| patch_id));
}
if diffs.len() == 1 {
let entry = &diffs[0];
if entry.old_oid != entry.new_oid {
let old_bytes = read_blob(odb, &entry.old_oid)?;
let new_bytes = read_blob(odb, &entry.new_oid)?;
if merge_file::is_binary(&old_bytes) || merge_file::is_binary(&new_bytes) {
let mut patch = Vec::new();
patch_id_write_line(&mut patch, &format!("commit {}", commit_oid.to_hex()));
write_patch_id_binary_entry(&mut patch, entry)?;
let ids = compute_patch_ids_from_text(algo, &patch, mode);
return Ok(ids.into_iter().next().map(|(patch_id, _)| patch_id));
}
}
}
let mut result = [0u8; PATCH_ID_DIGEST_MAX];
let mut rolling_ctx = ChecksumHasher::new(algo);
for entry in &diffs {
if per_file_flush {
let mut ctx = ChecksumHasher::new(algo);
patch_id_hash_diff_entry(odb, entry, &mut ctx)?;
text_flush_one_hunk(&mut result[..digest_len], digest_len, &mut ctx, algo);
} else {
patch_id_hash_diff_entry(odb, entry, &mut rolling_ctx)?;
}
}
if !per_file_flush {
text_flush_one_hunk(
&mut result[..digest_len],
digest_len,
&mut rolling_ctx,
algo,
);
}
if result.iter().take(digest_len).all(|&b| b == 0) {
return Ok(None);
}
ObjectId::from_bytes(&result[..digest_len]).map(Some)
}
fn diff_entry_matches_paths(entry: &DiffEntry, paths: &[String]) -> bool {
entry
.old_path
.as_deref()
.into_iter()
.chain(entry.new_path.as_deref())
.any(|path| crate::pathspec::matches_pathspec_list(path, paths))
}
fn parse_mode_u32(mode: &str) -> u32 {
u32::from_str_radix(mode.trim(), 8).unwrap_or(0)
}
fn patch_id_write_line(out: &mut Vec<u8>, line: &str) {
out.extend_from_slice(line.as_bytes());
out.push(b'\n');
}
fn write_patch_id_binary_entry(out: &mut Vec<u8>, entry: &DiffEntry) -> Result<()> {
let old_path = entry
.old_path
.as_deref()
.or(entry.new_path.as_deref())
.unwrap_or("");
let new_path = entry
.new_path
.as_deref()
.or(entry.old_path.as_deref())
.unwrap_or("");
patch_id_write_line(out, &format!("diff --git a/{old_path} b/{new_path}"));
let old_mode = parse_mode_u32(&entry.old_mode);
let new_mode = parse_mode_u32(&entry.new_mode);
if old_mode == 0 {
patch_id_write_line(out, &format!("new file mode {}", entry.new_mode.trim()));
} else if new_mode == 0 {
patch_id_write_line(out, &format!("deleted file mode {}", entry.old_mode.trim()));
}
patch_id_write_line(
out,
&format!(
"index {}..{}",
patch_id_index_oid(&entry.old_oid),
patch_id_index_oid(&entry.new_oid),
),
);
let old_side = entry
.old_path
.as_ref()
.map(|p| format!("a/{p}"))
.unwrap_or_else(|| "/dev/null".to_owned());
let new_side = entry
.new_path
.as_ref()
.map(|p| format!("b/{p}"))
.unwrap_or_else(|| "/dev/null".to_owned());
patch_id_write_line(
out,
&format!("Binary files {old_side} and {new_side} differ"),
);
Ok(())
}
fn patch_id_index_oid(oid: &ObjectId) -> String {
if *oid == zero_oid() {
return "0000000".to_owned();
}
oid.to_hex().chars().take(7).collect()
}
fn patch_id_add_string(ctx: &mut ChecksumHasher, s: &[u8]) {
ctx.update(s);
}
fn patch_id_add_mode(ctx: &mut ChecksumHasher, mode: u32) {
let text = format!("{mode:06o}");
ctx.update(text.as_bytes());
}
fn patch_id_build_git_show_patch(
odb: &Odb,
commit_oid: &ObjectId,
diffs: &[DiffEntry],
) -> Result<Vec<u8>> {
let mut patch = Vec::new();
patch_id_write_line(&mut patch, &format!("commit {}", commit_oid.to_hex()));
for entry in diffs {
patch_id_append_git_show_entry(odb, entry, &mut patch)?;
}
Ok(patch)
}
fn patch_id_append_git_show_entry(odb: &Odb, entry: &DiffEntry, out: &mut Vec<u8>) -> Result<()> {
let git_old = entry
.old_path
.as_deref()
.or(entry.new_path.as_deref())
.unwrap_or("dev/null");
let git_new = entry
.new_path
.as_deref()
.or(entry.old_path.as_deref())
.unwrap_or("dev/null");
patch_id_write_line(out, &format!("diff --git a/{git_old} b/{git_new}"));
let old_mode = parse_mode_u32(&entry.old_mode);
let new_mode = parse_mode_u32(&entry.new_mode);
if old_mode == 0 {
patch_id_write_line(out, &format!("new file mode {}", entry.new_mode.trim()));
} else if new_mode == 0 {
patch_id_write_line(out, &format!("deleted file mode {}", entry.old_mode.trim()));
}
if entry.status == DiffStatus::Renamed {
let score = entry.score.unwrap_or(100);
patch_id_write_line(out, &format!("similarity index {score}%"));
if let Some(path) = &entry.old_path {
patch_id_write_line(out, &format!("rename from {path}"));
}
if let Some(path) = &entry.new_path {
patch_id_write_line(out, &format!("rename to {path}"));
}
} else if old_mode != 0 && new_mode != 0 && old_mode != new_mode {
patch_id_write_line(out, &format!("old mode {}", entry.old_mode.trim()));
patch_id_write_line(out, &format!("new mode {}", entry.new_mode.trim()));
}
let index_mode = if new_mode != 0 {
entry.new_mode.trim()
} else {
entry.old_mode.trim()
};
patch_id_write_line(
out,
&format!(
"index {}..{} {}",
patch_id_index_oid(&entry.old_oid),
patch_id_index_oid(&entry.new_oid),
index_mode,
),
);
if entry.old_oid == entry.new_oid {
return Ok(());
}
let old_bytes = read_blob(odb, &entry.old_oid)?;
let new_bytes = read_blob(odb, &entry.new_oid)?;
if merge_file::is_binary(&old_bytes) || merge_file::is_binary(&new_bytes) {
let old_side = if old_mode == 0 {
"/dev/null".to_owned()
} else {
format!("a/{git_old}")
};
let new_side = if new_mode == 0 {
"/dev/null".to_owned()
} else {
format!("b/{git_new}")
};
patch_id_write_line(
out,
&format!("Binary files {old_side} and {new_side} differ"),
);
return Ok(());
}
let old_str = std::str::from_utf8(&old_bytes).unwrap_or("");
let new_str = std::str::from_utf8(&new_bytes).unwrap_or("");
let old_path = if old_mode == 0 {
"/dev/null".to_owned()
} else {
format!("a/{git_old}")
};
let new_path = if new_mode == 0 {
"/dev/null".to_owned()
} else {
format!("b/{git_new}")
};
let unified = unified_diff_histogram_with_prefix_and_funcname(
old_str, new_str, &old_path, &new_path, 3, 0, "", "", None, false, false,
);
out.extend_from_slice(unified.as_bytes());
Ok(())
}
fn patch_id_hash_diff_entry(odb: &Odb, entry: &DiffEntry, ctx: &mut ChecksumHasher) -> Result<()> {
if entry.status == DiffStatus::Renamed {
patch_id_hash_rename_entry_into_ctx(odb, entry, ctx)
} else {
patch_id_hash_file_entry_into_ctx(odb, entry, ctx)
}
}
fn patch_id_hash_file_entry_into_ctx(
odb: &Odb,
entry: &DiffEntry,
ctx: &mut ChecksumHasher,
) -> Result<()> {
let old_path = entry
.old_path
.as_deref()
.or(entry.new_path.as_deref())
.unwrap_or("");
let new_path = entry
.new_path
.as_deref()
.or(entry.old_path.as_deref())
.unwrap_or("");
let mut old_path_buf = old_path.as_bytes().to_vec();
let mut new_path_buf = new_path.as_bytes().to_vec();
let len1 = remove_space_bytes(&mut old_path_buf);
let len2 = remove_space_bytes(&mut new_path_buf);
let old_mode = parse_mode_u32(&entry.old_mode);
let new_mode = parse_mode_u32(&entry.new_mode);
patch_id_add_string(ctx, b"diff--git");
patch_id_add_string(ctx, b"a/");
ctx.update(&old_path_buf[..len1]);
patch_id_add_string(ctx, b"b/");
ctx.update(&new_path_buf[..len2]);
if old_mode == 0 {
patch_id_add_string(ctx, b"newfilemode");
patch_id_add_mode(ctx, new_mode);
} else if new_mode == 0 {
patch_id_add_string(ctx, b"deletedfilemode");
patch_id_add_mode(ctx, old_mode);
} else if old_mode != new_mode {
patch_id_add_string(ctx, b"oldmode");
patch_id_add_mode(ctx, old_mode);
patch_id_add_string(ctx, b"newmode");
patch_id_add_mode(ctx, new_mode);
}
if entry.old_oid == entry.new_oid {
return Ok(());
}
let old_bytes = read_blob(odb, &entry.old_oid)?;
let new_bytes = read_blob(odb, &entry.new_oid)?;
if merge_file::is_binary(&old_bytes) || merge_file::is_binary(&new_bytes) {
let a = patch_id_index_oid(&entry.old_oid);
let b = patch_id_index_oid(&entry.new_oid);
ctx.update(a.as_bytes());
ctx.update(b.as_bytes());
} else {
let old_str = std::str::from_utf8(&old_bytes).unwrap_or("");
let new_str = std::str::from_utf8(&new_bytes).unwrap_or("");
if old_mode == 0 {
patch_id_add_string(ctx, b"---/dev/null");
patch_id_add_string(ctx, b"+++b/");
ctx.update(&new_path_buf[..len2]);
} else if new_mode == 0 {
patch_id_add_string(ctx, b"---a/");
ctx.update(&old_path_buf[..len1]);
patch_id_add_string(ctx, b"+++/dev/null");
} else {
patch_id_add_string(ctx, b"---a/");
ctx.update(&old_path_buf[..len1]);
patch_id_add_string(ctx, b"+++b/");
ctx.update(&new_path_buf[..len2]);
}
patch_id_hash_text_hunks(ctx, old_str, new_str);
}
Ok(())
}
fn patch_id_hash_rename_entry_into_ctx(
odb: &Odb,
entry: &DiffEntry,
ctx: &mut ChecksumHasher,
) -> Result<()> {
let old_path = entry.old_path.as_deref().unwrap_or("");
let new_path = entry.new_path.as_deref().unwrap_or("");
let mut old_path_buf = old_path.as_bytes().to_vec();
let mut new_path_buf = new_path.as_bytes().to_vec();
let len1 = remove_space_bytes(&mut old_path_buf);
let len2 = remove_space_bytes(&mut new_path_buf);
patch_id_add_string(ctx, b"diff--git");
patch_id_add_string(ctx, b"a/");
ctx.update(&old_path_buf[..len1]);
patch_id_add_string(ctx, b"b/");
ctx.update(&new_path_buf[..len2]);
let score = entry.score.unwrap_or(100);
patch_id_add_string(ctx, b"similarityindex");
ctx.update(format!("{score}%").as_bytes());
patch_id_add_string(ctx, b"renamefrom");
ctx.update(&old_path_buf[..len1]);
patch_id_add_string(ctx, b"renameto");
ctx.update(&new_path_buf[..len2]);
let old_mode = parse_mode_u32(&entry.old_mode);
let new_mode = parse_mode_u32(&entry.new_mode);
if old_mode != new_mode {
patch_id_add_string(ctx, b"oldmode");
patch_id_add_mode(ctx, old_mode);
patch_id_add_string(ctx, b"newmode");
patch_id_add_mode(ctx, new_mode);
}
if entry.old_oid != entry.new_oid {
let old_bytes = read_blob(odb, &entry.old_oid)?;
let new_bytes = read_blob(odb, &entry.new_oid)?;
if merge_file::is_binary(&old_bytes) || merge_file::is_binary(&new_bytes) {
let a = entry.old_oid.to_hex();
let b = entry.new_oid.to_hex();
ctx.update(a.as_bytes());
ctx.update(b.as_bytes());
} else {
let old_str = std::str::from_utf8(&old_bytes).unwrap_or("");
let new_str = std::str::from_utf8(&new_bytes).unwrap_or("");
patch_id_add_string(ctx, b"---a/");
ctx.update(&old_path_buf[..len1]);
patch_id_add_string(ctx, b"+++b/");
ctx.update(&new_path_buf[..len2]);
patch_id_hash_text_hunks(ctx, old_str, new_str);
}
}
Ok(())
}
fn patch_id_hash_text_hunks(ctx: &mut ChecksumHasher, old_str: &str, new_str: &str) {
let diff = TextDiff::from_lines(old_str, new_str);
for change in diff.iter_all_changes() {
let prefix = match change.tag() {
ChangeTag::Equal => b' ',
ChangeTag::Delete => b'-',
ChangeTag::Insert => b'+',
};
let text = change.as_str().unwrap_or("");
for piece in text.split_inclusive('\n') {
let line_body = piece.strip_suffix('\n').unwrap_or(piece);
let mut line_buf = Vec::with_capacity(1 + line_body.len() + 1);
line_buf.push(prefix);
line_buf.extend_from_slice(line_body.as_bytes());
line_buf.push(b'\n');
let n = remove_space_bytes(&mut line_buf);
ctx.update(&line_buf[..n]);
}
}
}
fn remove_space_bytes(buf: &mut [u8]) -> usize {
let mut dst = 0usize;
let mut src = 0usize;
while src < buf.len() {
let c = buf[src];
src += 1;
if !c.is_ascii_whitespace() {
buf[dst] = c;
dst += 1;
}
}
dst
}
fn read_blob(odb: &Odb, oid: &ObjectId) -> Result<Vec<u8>> {
if *oid == zero_oid() {
return Ok(Vec::new());
}
let obj = odb.read(oid)?;
Ok(obj.data)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::environment::RepositoryOptions;
use std::process::Command;
use tempfile::TempDir;
fn git_in(dir: &std::path::Path, args: &[&str]) -> String {
let out = Command::new("git")
.current_dir(dir)
.args(args)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.env("GIT_AUTHOR_NAME", "Test")
.env("GIT_AUTHOR_EMAIL", "t@example.com")
.env("GIT_COMMITTER_NAME", "Test")
.env("GIT_COMMITTER_EMAIL", "t@example.com")
.output()
.expect("spawn git");
assert!(
out.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).into_owned()
}
#[test]
fn verbatim_built_patch_text_matches_git_show_patch_id() {
let dir = TempDir::new().expect("tempdir");
git_in(dir.path(), &["init", "-q", "--initial-branch=main", "."]);
std::fs::write(dir.path().join("f.txt"), b"one\n").unwrap();
git_in(dir.path(), &["add", "f.txt"]);
git_in(dir.path(), &["commit", "-q", "-m", "base"]);
std::fs::write(dir.path().join("f.txt"), b"one \ntwo\n").unwrap();
git_in(dir.path(), &["add", "f.txt"]);
git_in(dir.path(), &["commit", "-q", "-m", "change"]);
let commit: ObjectId = git_in(dir.path(), &["rev-parse", "HEAD"])
.trim()
.parse()
.unwrap();
let git_show = git_in(dir.path(), &["show", "-p", "HEAD"]);
let repo = crate::repo::Repository::open(
&dir.path().join(".git"),
&RepositoryOptions::empty().with_work_tree(dir.path()),
)
.unwrap();
let grit_verbatim =
compute_patch_id(HashAlgo::Sha1, &repo.odb(), &commit, PatchIdMode::Verbatim)
.expect("verbatim")
.expect("some");
let from_git_show =
compute_patch_ids_from_text(HashAlgo::Sha1, git_show.as_bytes(), PatchIdMode::Verbatim)
.into_iter()
.find(|(_, c)| *c == commit)
.expect("commit in git show stream")
.0;
assert_eq!(
grit_verbatim, from_git_show,
"ODB verbatim should match parsing git show -p"
);
}
#[test]
fn stable_patch_ids_match_git_patch_id_on_log_p() {
let dir = TempDir::new().expect("tempdir");
git_in(dir.path(), &["init", "-q", "--initial-branch=main", "."]);
std::fs::write(dir.path().join("f.txt"), b"one\n").unwrap();
git_in(dir.path(), &["add", "f.txt"]);
git_in(dir.path(), &["commit", "-q", "-m", "first"]);
std::fs::write(dir.path().join("f.txt"), b"one\ntwo\n").unwrap();
git_in(dir.path(), &["add", "f.txt"]);
git_in(dir.path(), &["commit", "-q", "-m", "second"]);
std::fs::write(dir.path().join("g.txt"), b"new\n").unwrap();
git_in(dir.path(), &["add", "g.txt"]);
git_in(dir.path(), &["commit", "-q", "-m", "third"]);
let log_p = git_in(dir.path(), &["log", "-p", "--reverse"]);
let grit_ids =
compute_patch_ids_from_text(HashAlgo::Sha1, log_p.as_bytes(), PatchIdMode::Stable);
use std::io::Write;
let mut child = Command::new("git")
.current_dir(dir.path())
.args(["patch-id", "--stable"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("spawn git patch-id");
child
.stdin
.take()
.expect("stdin")
.write_all(log_p.as_bytes())
.expect("write log");
let git_out = child.wait_with_output().expect("wait");
assert!(
git_out.status.success(),
"git patch-id --stable failed: {}",
String::from_utf8_lossy(&git_out.stderr)
);
let git_lines: Vec<(ObjectId, ObjectId)> = String::from_utf8_lossy(&git_out.stdout)
.lines()
.filter(|l| !l.is_empty())
.map(|line| {
let mut parts = line.split_whitespace();
let patch_hex = parts.next().expect("patch id");
let commit_hex = parts.next().expect("commit id");
let patch = ObjectId::from_hex(patch_hex).expect("patch hex");
let commit = ObjectId::from_hex(commit_hex).expect("commit hex");
(patch, commit)
})
.collect();
assert_eq!(grit_ids.len(), git_lines.len(), "patch-id count mismatch");
for ((g_patch, g_commit), (git_patch, git_commit)) in grit_ids.iter().zip(git_lines.iter())
{
assert_eq!(g_patch, git_patch, "patch-id mismatch");
assert_eq!(g_commit, git_commit, "commit-id mismatch");
}
}
fn git_patch_ids_from_log(dir: &std::path::Path, mode_flag: &str) -> Vec<(ObjectId, ObjectId)> {
let log_p = git_in(dir, &["log", "-p", "--reverse"]);
use std::io::Write;
let mut child = Command::new("git")
.current_dir(dir)
.args(["patch-id", mode_flag])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("spawn git patch-id");
child
.stdin
.take()
.expect("stdin")
.write_all(log_p.as_bytes())
.expect("write log");
let out = child.wait_with_output().expect("wait");
assert!(
out.status.success(),
"git patch-id failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout)
.lines()
.filter(|l| !l.is_empty())
.map(|line| {
let mut parts = line.split_whitespace();
let patch_hex = parts.next().expect("patch id");
let commit_hex = parts.next().expect("commit id");
(
ObjectId::from_hex(patch_hex).expect("patch hex"),
ObjectId::from_hex(commit_hex).expect("commit hex"),
)
})
.collect()
}
#[test]
fn t4204_patch_id_modes_match_git() {
let dir = TempDir::new().expect("tempdir");
git_in(dir.path(), &["init", "-q", "--initial-branch=main", "."]);
std::fs::write(dir.path().join("f.txt"), b"one\n").unwrap();
git_in(dir.path(), &["add", "f.txt"]);
git_in(dir.path(), &["commit", "-q", "-m", "first"]);
std::fs::write(dir.path().join("f.txt"), b"one\ntwo\n").unwrap();
git_in(dir.path(), &["add", "f.txt"]);
git_in(dir.path(), &["commit", "-q", "-m", "second"]);
std::fs::write(dir.path().join("g.bin"), &[0u8, 1, 2, 3]).unwrap();
git_in(dir.path(), &["add", "g.bin"]);
git_in(dir.path(), &["commit", "-q", "-m", "binary"]);
std::fs::write(dir.path().join("h.txt"), b"a\n").unwrap();
git_in(dir.path(), &["add", "h.txt"]);
git_in(dir.path(), &["commit", "-q", "-m", "add-h"]);
git_in(dir.path(), &["mv", "h.txt", "i.txt"]);
git_in(dir.path(), &["commit", "-q", "-m", "rename"]);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(dir.path().join("f.txt"))
.expect("f.txt")
.permissions();
perms.set_mode(0o100755);
std::fs::set_permissions(dir.path().join("f.txt"), perms).expect("chmod f.txt");
}
git_in(dir.path(), &["add", "f.txt"]);
git_in(dir.path(), &["commit", "-q", "-m", "mode-only"]);
let log_p = git_in(dir.path(), &["log", "-p", "--reverse"]);
for (mode, flag) in [
(PatchIdMode::Stable, "--stable"),
(PatchIdMode::Unstable, "--unstable"),
(PatchIdMode::Verbatim, "--verbatim"),
] {
let grit_ids = compute_patch_ids_from_text(HashAlgo::Sha1, log_p.as_bytes(), mode);
let git_ids = git_patch_ids_from_log(dir.path(), flag);
assert_eq!(grit_ids.len(), git_ids.len(), "patch-id count for {flag:?}");
for ((g_patch, g_commit), (git_patch, git_commit)) in
grit_ids.iter().zip(git_ids.iter())
{
assert_eq!(g_patch, git_patch, "patch-id {flag}");
assert_eq!(g_commit, git_commit, "commit-id {flag}");
}
}
let repo = crate::repo::Repository::open(
&dir.path().join(".git"),
&RepositoryOptions::empty().with_work_tree(dir.path()),
)
.unwrap();
let git_stable = git_patch_ids_from_log(dir.path(), "--stable");
let assert_odb_matches_git = |commit_hex: &str, label: &str| {
let commit: ObjectId = commit_hex.trim().parse().expect("commit oid");
let grit = compute_patch_id(HashAlgo::Sha1, &repo.odb(), &commit, PatchIdMode::Stable)
.expect("patch")
.expect("some");
let git_patch = git_stable
.iter()
.find(|(_, c)| c == &commit)
.expect(label)
.0;
assert_eq!(grit, git_patch, "{label}");
};
let second = git_in(
dir.path(),
&["rev-list", "--max-count=1", "--skip=4", "HEAD"],
);
assert_odb_matches_git(&second, "git patch-id for second commit");
let binary = git_in(
dir.path(),
&["rev-list", "--max-count=1", "--skip=3", "HEAD"],
);
assert_odb_matches_git(&binary, "git patch-id for binary commit");
let rename = git_in(
dir.path(),
&["rev-list", "--max-count=1", "--skip=1", "HEAD"],
);
assert_odb_matches_git(&rename, "git patch-id for rename commit");
let mode_only = git_in(dir.path(), &["rev-parse", "HEAD"]);
assert_odb_matches_git(&mode_only, "git patch-id for mode-only commit");
let add_h = git_in(
dir.path(),
&["rev-list", "--max-count=1", "--skip=2", "HEAD"],
);
let add_h: ObjectId = add_h.trim().parse().unwrap();
let filtered = compute_patch_id_for_paths(
HashAlgo::Sha1,
&repo.odb(),
&add_h,
&[String::from("h.txt")],
PatchIdMode::Stable,
)
.expect("filtered h.txt")
.expect("some h.txt");
let git_h = git_stable
.iter()
.find(|(_, c)| c == &add_h)
.expect("git patch-id for add-h")
.0;
assert_eq!(
filtered, git_h,
"ODB path-filter h.txt vs full commit patch-id"
);
assert!(
compute_patch_id_for_paths(
HashAlgo::Sha1,
&repo.odb(),
&add_h,
&[String::from("f.txt")],
PatchIdMode::Stable,
)
.expect("filtered f.txt")
.is_none(),
"path filter excluding all changes must be None"
);
git_in(dir.path(), &["checkout", "-q", "HEAD~4"]);
git_in(dir.path(), &["checkout", "-q", "-b", "side"]);
std::fs::write(dir.path().join("side.txt"), b"s\n").unwrap();
git_in(dir.path(), &["add", "side.txt"]);
git_in(dir.path(), &["commit", "-q", "-m", "side"]);
git_in(dir.path(), &["checkout", "-q", "main"]);
git_in(dir.path(), &["merge", "-q", "--no-ff", "side"]);
let merge_tip: ObjectId = git_in(dir.path(), &["rev-parse", "HEAD"])
.trim()
.parse()
.unwrap();
assert!(
compute_patch_id(HashAlgo::Sha1, &repo.odb(), &merge_tip, PatchIdMode::Stable)
.expect("merge")
.is_none()
);
}
}