use std::fs;
use std::os::unix::fs::MetadataExt;
use std::path::Path;
use crate::error::{Error, Result};
use crate::index::{Index, IndexEntry};
use crate::objects::{parse_tree, ObjectId, ObjectKind, TreeEntry};
use crate::odb::Odb;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffStatus {
Added,
Deleted,
Modified,
Renamed,
Copied,
TypeChanged,
Unmerged,
}
impl DiffStatus {
#[must_use]
pub fn letter(&self) -> char {
match self {
Self::Added => 'A',
Self::Deleted => 'D',
Self::Modified => 'M',
Self::Renamed => 'R',
Self::Copied => 'C',
Self::TypeChanged => 'T',
Self::Unmerged => 'U',
}
}
}
#[derive(Debug, Clone)]
pub struct DiffEntry {
pub status: DiffStatus,
pub old_path: Option<String>,
pub new_path: Option<String>,
pub old_mode: String,
pub new_mode: String,
pub old_oid: ObjectId,
pub new_oid: ObjectId,
pub score: Option<u32>,
}
impl DiffEntry {
#[must_use]
pub fn path(&self) -> &str {
self.new_path
.as_deref()
.or(self.old_path.as_deref())
.unwrap_or("")
}
}
pub const ZERO_OID: &str = "0000000000000000000000000000000000000000";
#[must_use]
pub fn zero_oid() -> ObjectId {
ObjectId::from_bytes(&[0u8; 20]).unwrap_or_else(|_| {
panic!("internal error: failed to create zero OID");
})
}
pub fn diff_trees(
odb: &Odb,
old_tree_oid: Option<&ObjectId>,
new_tree_oid: Option<&ObjectId>,
prefix: &str,
) -> Result<Vec<DiffEntry>> {
let old_entries = match old_tree_oid {
Some(oid) => read_tree(odb, oid)?,
None => Vec::new(),
};
let new_entries = match new_tree_oid {
Some(oid) => read_tree(odb, oid)?,
None => Vec::new(),
};
let mut result = Vec::new();
diff_tree_entries(odb, &old_entries, &new_entries, prefix, &mut result)?;
Ok(result)
}
fn read_tree(odb: &Odb, oid: &ObjectId) -> Result<Vec<TreeEntry>> {
let obj = odb.read(oid)?;
if obj.kind != ObjectKind::Tree {
return Err(Error::CorruptObject(format!(
"expected tree, got {}",
obj.kind.as_str()
)));
}
parse_tree(&obj.data)
}
fn diff_tree_entries(
odb: &Odb,
old: &[TreeEntry],
new: &[TreeEntry],
prefix: &str,
result: &mut Vec<DiffEntry>,
) -> Result<()> {
let mut oi = 0;
let mut ni = 0;
while oi < old.len() || ni < new.len() {
match (old.get(oi), new.get(ni)) {
(Some(o), Some(n)) => {
let cmp = crate::objects::tree_entry_cmp(
&o.name,
is_tree_mode(o.mode),
&n.name,
is_tree_mode(n.mode),
);
match cmp {
std::cmp::Ordering::Less => {
emit_deleted(odb, o, prefix, result)?;
oi += 1;
}
std::cmp::Ordering::Greater => {
emit_added(odb, n, prefix, result)?;
ni += 1;
}
std::cmp::Ordering::Equal => {
if o.oid != n.oid || o.mode != n.mode {
let name_str = String::from_utf8_lossy(&o.name);
let path = format_path(prefix, &name_str);
if is_tree_mode(o.mode) && is_tree_mode(n.mode) {
let nested = diff_trees(odb, Some(&o.oid), Some(&n.oid), &path)?;
result.extend(nested);
} else if is_tree_mode(o.mode) && !is_tree_mode(n.mode) {
emit_deleted(odb, o, prefix, result)?;
emit_added(odb, n, prefix, result)?;
} else if !is_tree_mode(o.mode) && is_tree_mode(n.mode) {
emit_deleted(odb, o, prefix, result)?;
emit_added(odb, n, prefix, result)?;
} else {
result.push(DiffEntry {
status: if o.mode != n.mode && o.oid == n.oid {
DiffStatus::TypeChanged
} else {
DiffStatus::Modified
},
old_path: Some(path.clone()),
new_path: Some(path),
old_mode: format_mode(o.mode),
new_mode: format_mode(n.mode),
old_oid: o.oid,
new_oid: n.oid,
score: None,
});
}
}
oi += 1;
ni += 1;
}
}
}
(Some(o), None) => {
emit_deleted(odb, o, prefix, result)?;
oi += 1;
}
(None, Some(n)) => {
emit_added(odb, n, prefix, result)?;
ni += 1;
}
(None, None) => break,
}
}
Ok(())
}
fn emit_deleted(
odb: &Odb,
entry: &TreeEntry,
prefix: &str,
result: &mut Vec<DiffEntry>,
) -> Result<()> {
let name_str = String::from_utf8_lossy(&entry.name);
let path = format_path(prefix, &name_str);
if is_tree_mode(entry.mode) {
let nested = diff_trees(odb, Some(&entry.oid), None, &path)?;
result.extend(nested);
} else {
result.push(DiffEntry {
status: DiffStatus::Deleted,
old_path: Some(path.clone()),
new_path: None,
old_mode: format_mode(entry.mode),
new_mode: "000000".to_owned(),
old_oid: entry.oid,
new_oid: zero_oid(),
score: None,
});
}
Ok(())
}
fn emit_added(
odb: &Odb,
entry: &TreeEntry,
prefix: &str,
result: &mut Vec<DiffEntry>,
) -> Result<()> {
let name_str = String::from_utf8_lossy(&entry.name);
let path = format_path(prefix, &name_str);
if is_tree_mode(entry.mode) {
let nested = diff_trees(odb, None, Some(&entry.oid), &path)?;
result.extend(nested);
} else {
result.push(DiffEntry {
status: DiffStatus::Added,
old_path: None,
new_path: Some(path),
old_mode: "000000".to_owned(),
new_mode: format_mode(entry.mode),
old_oid: zero_oid(),
new_oid: entry.oid,
score: None,
});
}
Ok(())
}
pub fn diff_index_to_tree(
odb: &Odb,
index: &Index,
tree_oid: Option<&ObjectId>,
) -> Result<Vec<DiffEntry>> {
let tree_entries = match tree_oid {
Some(oid) => flatten_tree(odb, oid, "")?,
None => Vec::new(),
};
let mut tree_map: std::collections::BTreeMap<&str, &FlatEntry> =
std::collections::BTreeMap::new();
for entry in &tree_entries {
tree_map.insert(&entry.path, entry);
}
let mut result = Vec::new();
for ie in &index.entries {
if ie.stage() != 0 {
continue;
}
let path = String::from_utf8_lossy(&ie.path).to_string();
match tree_map.remove(path.as_str()) {
Some(te) => {
if te.oid != ie.oid || te.mode != ie.mode {
result.push(DiffEntry {
status: DiffStatus::Modified,
old_path: Some(path.clone()),
new_path: Some(path),
old_mode: format_mode(te.mode),
new_mode: format_mode(ie.mode),
old_oid: te.oid,
new_oid: ie.oid,
score: None,
});
}
}
None => {
result.push(DiffEntry {
status: DiffStatus::Added,
old_path: None,
new_path: Some(path),
old_mode: "000000".to_owned(),
new_mode: format_mode(ie.mode),
old_oid: zero_oid(),
new_oid: ie.oid,
score: None,
});
}
}
}
for (path, te) in tree_map {
result.push(DiffEntry {
status: DiffStatus::Deleted,
old_path: Some(path.to_owned()),
new_path: None,
old_mode: format_mode(te.mode),
new_mode: "000000".to_owned(),
old_oid: te.oid,
new_oid: zero_oid(),
score: None,
});
}
result.sort_by(|a, b| a.path().cmp(b.path()));
Ok(result)
}
pub fn diff_index_to_worktree(
odb: &Odb,
index: &Index,
work_tree: &Path,
) -> Result<Vec<DiffEntry>> {
let mut result = Vec::new();
for ie in &index.entries {
if ie.stage() != 0 {
continue;
}
let path_str_ref = std::str::from_utf8(&ie.path).unwrap_or("");
let file_path = work_tree.join(path_str_ref);
match fs::symlink_metadata(&file_path) {
Ok(meta) => {
if stat_matches(ie, &meta) {
continue; }
let worktree_oid = hash_worktree_file(odb, &file_path, &meta)?;
let worktree_mode = mode_from_metadata(&meta);
if worktree_oid != ie.oid || worktree_mode != ie.mode {
let path_owned = path_str_ref.to_owned();
result.push(DiffEntry {
status: DiffStatus::Modified,
old_path: Some(path_owned.clone()),
new_path: Some(path_owned),
old_mode: format_mode(ie.mode),
new_mode: format_mode(worktree_mode),
old_oid: ie.oid,
new_oid: worktree_oid,
score: None,
});
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound
|| e.raw_os_error() == Some(20) => {
result.push(DiffEntry {
status: DiffStatus::Deleted,
old_path: Some(path_str_ref.to_owned()),
new_path: None,
old_mode: format_mode(ie.mode),
new_mode: "000000".to_owned(),
old_oid: ie.oid,
new_oid: zero_oid(),
score: None,
});
}
Err(e) => return Err(Error::Io(e)),
}
}
Ok(result)
}
pub fn stat_matches(ie: &IndexEntry, meta: &fs::Metadata) -> bool {
if meta.len() as u32 != ie.size {
return false;
}
if meta.mtime() as u32 != ie.mtime_sec {
return false;
}
if meta.mtime_nsec() as u32 != ie.mtime_nsec {
return false;
}
if meta.ctime() as u32 != ie.ctime_sec {
return false;
}
if meta.ctime_nsec() as u32 != ie.ctime_nsec {
return false;
}
if meta.ino() as u32 != ie.ino {
return false;
}
if meta.dev() as u32 != ie.dev {
return false;
}
true
}
fn hash_worktree_file(_odb: &Odb, path: &Path, meta: &fs::Metadata) -> Result<ObjectId> {
let data = if meta.file_type().is_symlink() {
let target = fs::read_link(path)?;
target.to_string_lossy().into_owned().into_bytes()
} else {
fs::read(path)?
};
Ok(Odb::hash_object_data(ObjectKind::Blob, &data))
}
fn mode_from_metadata(meta: &fs::Metadata) -> u32 {
if meta.file_type().is_symlink() {
0o120000
} else if meta.mode() & 0o111 != 0 {
0o100755
} else {
0o100644
}
}
pub fn diff_tree_to_worktree(
odb: &Odb,
tree_oid: Option<&ObjectId>,
work_tree: &Path,
index: &Index,
) -> Result<Vec<DiffEntry>> {
let tree_flat = match tree_oid {
Some(oid) => flatten_tree(odb, oid, "")?,
None => Vec::new(),
};
let tree_map: std::collections::BTreeMap<String, &FlatEntry> =
tree_flat.iter().map(|e| (e.path.clone(), e)).collect();
let mut index_entries: std::collections::BTreeMap<&[u8], &IndexEntry> =
std::collections::BTreeMap::new();
let mut index_paths: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for ie in &index.entries {
if ie.stage() != 0 {
continue;
}
let path = String::from_utf8_lossy(&ie.path).to_string();
index_entries.insert(&ie.path, ie);
index_paths.insert(path);
}
let mut all_paths: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
all_paths.extend(tree_map.keys().cloned());
all_paths.extend(index_paths.iter().cloned());
let mut result = Vec::new();
for path in &all_paths {
let tree_entry = tree_map.get(path.as_str());
let file_path = work_tree.join(path);
let wt_meta = match fs::symlink_metadata(&file_path) {
Ok(m) => Some(m),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => return Err(Error::Io(e)),
};
match (tree_entry, wt_meta) {
(Some(te), Some(ref meta)) => {
if let Some(ie) = index_entries.get(path.as_bytes()) {
if ie.oid == te.oid && ie.mode == te.mode && stat_matches(ie, meta) {
continue;
}
}
let wt_oid = hash_worktree_file(odb, &file_path, meta)?;
let wt_mode = mode_from_metadata(meta);
if wt_oid != te.oid || wt_mode != te.mode {
result.push(DiffEntry {
status: DiffStatus::Modified,
old_path: Some(path.clone()),
new_path: Some(path.clone()),
old_mode: format_mode(te.mode),
new_mode: format_mode(wt_mode),
old_oid: te.oid,
new_oid: wt_oid,
score: None,
});
}
}
(Some(te), None) => {
result.push(DiffEntry {
status: DiffStatus::Deleted,
old_path: Some(path.clone()),
new_path: None,
old_mode: format_mode(te.mode),
new_mode: "000000".to_owned(),
old_oid: te.oid,
new_oid: zero_oid(),
score: None,
});
}
(None, Some(ref meta)) => {
let wt_oid = hash_worktree_file(odb, &file_path, meta)?;
let wt_mode = mode_from_metadata(meta);
result.push(DiffEntry {
status: DiffStatus::Added,
old_path: None,
new_path: Some(path.clone()),
old_mode: "000000".to_owned(),
new_mode: format_mode(wt_mode),
old_oid: zero_oid(),
new_oid: wt_oid,
score: None,
});
}
(None, None) => {
}
}
}
result.sort_by(|a, b| a.path().cmp(b.path()));
Ok(result)
}
pub fn detect_renames(odb: &Odb, entries: Vec<DiffEntry>, threshold: u32) -> Vec<DiffEntry> {
let mut deleted: Vec<DiffEntry> = Vec::new();
let mut added: Vec<DiffEntry> = Vec::new();
let mut others: Vec<DiffEntry> = Vec::new();
for entry in entries {
match entry.status {
DiffStatus::Deleted => deleted.push(entry),
DiffStatus::Added => added.push(entry),
_ => others.push(entry),
}
}
if deleted.is_empty() || added.is_empty() {
let mut result = others;
result.extend(deleted);
result.extend(added);
result.sort_by(|a, b| a.path().cmp(b.path()));
return result;
}
let deleted_contents: Vec<Option<Vec<u8>>> = deleted
.iter()
.map(|d| odb.read(&d.old_oid).ok().map(|obj| obj.data))
.collect();
let added_contents: Vec<Option<Vec<u8>>> = added
.iter()
.map(|a| odb.read(&a.new_oid).ok().map(|obj| obj.data))
.collect();
let mut scores: Vec<(u32, usize, usize)> = Vec::new();
for (di, del) in deleted.iter().enumerate() {
for (ai, add) in added.iter().enumerate() {
if del.old_oid == add.new_oid {
scores.push((100, di, ai));
continue;
}
let score = match (&deleted_contents[di], &added_contents[ai]) {
(Some(old_data), Some(new_data)) => {
compute_similarity(old_data, new_data)
}
_ => 0,
};
if score >= threshold {
scores.push((score, di, ai));
}
}
}
scores.sort_by(|a, b| {
let a_same = same_basename(&deleted[a.1], &added[a.2]);
let b_same = same_basename(&deleted[b.1], &added[b.2]);
b_same.cmp(&a_same).then_with(|| b.0.cmp(&a.0))
});
let mut used_deleted = vec![false; deleted.len()];
let mut used_added = vec![false; added.len()];
let mut renames: Vec<DiffEntry> = Vec::new();
for (score, di, ai) in &scores {
if used_deleted[*di] || used_added[*ai] {
continue;
}
used_deleted[*di] = true;
used_added[*ai] = true;
let del = &deleted[*di];
let add = &added[*ai];
renames.push(DiffEntry {
status: DiffStatus::Renamed,
old_path: del.old_path.clone(),
new_path: add.new_path.clone(),
old_mode: del.old_mode.clone(),
new_mode: add.new_mode.clone(),
old_oid: del.old_oid,
new_oid: add.new_oid,
score: Some(*score),
});
}
let mut result = others;
result.extend(renames);
for (i, entry) in deleted.into_iter().enumerate() {
if !used_deleted[i] {
result.push(entry);
}
}
for (i, entry) in added.into_iter().enumerate() {
if !used_added[i] {
result.push(entry);
}
}
result.sort_by(|a, b| a.path().cmp(b.path()));
result
}
pub fn detect_copies(
odb: &Odb,
entries: Vec<DiffEntry>,
threshold: u32,
find_copies_harder: bool,
source_tree_entries: &[(String, String, ObjectId)],
) -> Vec<DiffEntry> {
let entries = detect_renames(odb, entries, threshold);
let mut added: Vec<DiffEntry> = Vec::new();
let mut others: Vec<DiffEntry> = Vec::new();
for entry in entries {
match entry.status {
DiffStatus::Added => added.push(entry),
_ => others.push(entry),
}
}
if added.is_empty() {
return others;
}
let mut sources: Vec<(String, ObjectId)> = Vec::new();
for entry in &others {
if entry.status == DiffStatus::Modified {
if let Some(ref old_path) = entry.old_path {
sources.push((old_path.clone(), entry.old_oid));
}
}
}
if find_copies_harder {
for (path, _mode, oid) in source_tree_entries {
if !sources.iter().any(|(p, _)| p == path) {
sources.push((path.clone(), *oid));
}
}
}
if sources.is_empty() {
let mut result = others;
result.extend(added);
result.sort_by(|a, b| a.path().cmp(b.path()));
return result;
}
let source_contents: Vec<Option<Vec<u8>>> = sources
.iter()
.map(|(_, oid)| odb.read(oid).ok().map(|obj| obj.data))
.collect();
let added_contents: Vec<Option<Vec<u8>>> = added
.iter()
.map(|a| odb.read(&a.new_oid).ok().map(|obj| obj.data))
.collect();
let mut scores: Vec<(u32, usize, usize)> = Vec::new();
for (si, (_, src_oid)) in sources.iter().enumerate() {
for (ai, add) in added.iter().enumerate() {
if *src_oid == add.new_oid {
scores.push((100, si, ai));
continue;
}
let score = match (&source_contents[si], &added_contents[ai]) {
(Some(old_data), Some(new_data)) => compute_similarity(old_data, new_data),
_ => 0,
};
if score >= threshold {
scores.push((score, si, ai));
}
}
}
scores.sort_by(|a, b| b.0.cmp(&a.0));
let mut used_added = vec![false; added.len()];
let mut copies: Vec<DiffEntry> = Vec::new();
for (score, si, ai) in &scores {
if used_added[*ai] {
continue;
}
used_added[*ai] = true;
let (ref src_path, _) = sources[*si];
let add = &added[*ai];
let src_mode = source_tree_entries
.iter()
.find(|(p, _, _)| p == src_path)
.map(|(_, m, _)| m.clone())
.unwrap_or_else(|| add.old_mode.clone());
copies.push(DiffEntry {
status: DiffStatus::Copied,
old_path: Some(src_path.clone()),
new_path: add.new_path.clone(),
old_mode: src_mode,
new_mode: add.new_mode.clone(),
old_oid: sources[*si].1,
new_oid: add.new_oid,
score: Some(*score),
});
}
let mut result = others;
result.extend(copies);
for (i, entry) in added.into_iter().enumerate() {
if !used_added[i] {
result.push(entry);
}
}
result.sort_by(|a, b| a.path().cmp(b.path()));
result
}
pub fn format_rename_path(old: &str, new: &str) -> String {
let ob = old.as_bytes();
let nb = new.as_bytes();
let pfx = {
let mut last_sep = 0usize;
let min_len = ob.len().min(nb.len());
for i in 0..min_len {
if ob[i] != nb[i] {
break;
}
if ob[i] == b'/' {
last_sep = i + 1;
}
}
last_sep
};
let mut sfx = {
let mut last_sep = 0usize;
let min_len = ob.len().min(nb.len());
for i in 0..min_len {
let oi = ob.len() - 1 - i;
let ni = nb.len() - 1 - i;
if ob[oi] != nb[ni] {
break;
}
if ob[oi] == b'/' {
last_sep = i + 1;
}
}
last_sep
};
let mut sfx_at_old = ob.len() - sfx;
let mut sfx_at_new = nb.len() - sfx;
while pfx > sfx_at_old && pfx > sfx_at_new && sfx > 0 {
let suffix_bytes = &ob[sfx_at_old..];
let mut new_sfx = 0;
for i in 1..suffix_bytes.len() {
if suffix_bytes[i] == b'/' {
new_sfx = sfx - i;
break;
}
}
if new_sfx == 0 || new_sfx >= sfx {
sfx = 0;
sfx_at_old = ob.len();
sfx_at_new = nb.len();
break;
}
sfx = new_sfx;
sfx_at_old = ob.len() - sfx;
sfx_at_new = nb.len() - sfx;
}
let prefix = &old[..pfx];
let suffix = &old[sfx_at_old..];
let old_mid = if pfx <= sfx_at_old {
&old[pfx..sfx_at_old]
} else {
""
};
let new_mid = if pfx <= sfx_at_new {
&new[pfx..sfx_at_new]
} else {
""
};
if prefix.is_empty() && suffix.is_empty() {
return format!("{old} => {new}");
}
format!("{prefix}{{{old_mid} => {new_mid}}}{suffix}")
}
fn same_basename(del: &DiffEntry, add: &DiffEntry) -> bool {
let old = del.old_path.as_deref().unwrap_or("");
let new = add.new_path.as_deref().unwrap_or("");
let old_base = old.rsplit('/').next().unwrap_or(old);
let new_base = new.rsplit('/').next().unwrap_or(new);
old_base == new_base && !old_base.is_empty()
}
fn compute_similarity(old: &[u8], new: &[u8]) -> u32 {
let src_size = old.len();
let dst_size = new.len();
if src_size == 0 && dst_size == 0 {
return 100;
}
let total = src_size + dst_size;
if total == 0 {
return 100;
}
use similar::{ChangeTag, TextDiff};
let old_str = String::from_utf8_lossy(old);
let new_str = String::from_utf8_lossy(new);
let diff = TextDiff::from_lines(&old_str as &str, &new_str as &str);
let mut shared_bytes = 0usize;
for change in diff.iter_all_changes() {
if change.tag() == ChangeTag::Equal {
shared_bytes += change.value().len();
}
}
let max_size = src_size.max(dst_size);
let score = ((shared_bytes * 100) / max_size).min(100) as u32;
score
}
pub fn format_raw(entry: &DiffEntry) -> String {
let path = match entry.status {
DiffStatus::Renamed | DiffStatus::Copied => {
format!(
"{}\t{}",
entry.old_path.as_deref().unwrap_or(""),
entry.new_path.as_deref().unwrap_or("")
)
}
_ => entry.path().to_owned(),
};
let status_str = match (entry.status, entry.score) {
(DiffStatus::Renamed, Some(s)) => format!("R{:03}", s),
(DiffStatus::Copied, Some(s)) => format!("C{:03}", s),
_ => entry.status.letter().to_string(),
};
format!(
":{} {} {} {} {}\t{}",
entry.old_mode,
entry.new_mode,
entry.old_oid,
entry.new_oid,
status_str,
path
)
}
pub fn format_raw_abbrev(entry: &DiffEntry, abbrev_len: usize) -> String {
let old_hex = format!("{}", entry.old_oid);
let new_hex = format!("{}", entry.new_oid);
let old_abbrev = &old_hex[..abbrev_len.min(old_hex.len())];
let new_abbrev = &new_hex[..abbrev_len.min(new_hex.len())];
let path = entry.path();
format!(
":{} {} {}... {}... {}\t{}",
entry.old_mode,
entry.new_mode,
old_abbrev,
new_abbrev,
entry.status.letter(),
path
)
}
pub fn unified_diff(
old_content: &str,
new_content: &str,
old_path: &str,
new_path: &str,
context_lines: usize,
) -> String {
use similar::TextDiff;
let diff = TextDiff::from_lines(old_content, new_content);
let mut output = String::new();
if old_path == "/dev/null" {
output.push_str("--- /dev/null\n");
} else {
output.push_str(&format!("--- a/{old_path}\n"));
}
if new_path == "/dev/null" {
output.push_str("+++ /dev/null\n");
} else {
output.push_str(&format!("+++ b/{new_path}\n"));
}
let old_lines: Vec<&str> = old_content.lines().collect();
for hunk in diff
.unified_diff()
.context_radius(context_lines)
.iter_hunks()
{
let hunk_str = format!("{hunk}");
if let Some(first_newline) = hunk_str.find('\n') {
let header_line = &hunk_str[..first_newline];
let rest = &hunk_str[first_newline..];
if let Some(func_ctx) = extract_function_context(header_line, &old_lines) {
output.push_str(header_line);
output.push(' ');
output.push_str(&func_ctx);
output.push_str(rest);
} else {
output.push_str(&hunk_str);
}
} else {
output.push_str(&hunk_str);
}
}
output
}
fn extract_function_context(header: &str, old_lines: &[&str]) -> Option<String> {
let at_pos = header.find("-")?;
let rest = &header[at_pos + 1..];
let comma_or_space = rest.find(|c: char| c == ',' || c == ' ')?;
let start_str = &rest[..comma_or_space];
let start_line: usize = start_str.parse().ok()?;
if start_line <= 1 {
return None;
}
let search_end = (start_line - 1).min(old_lines.len());
for i in (0..search_end).rev() {
let line = old_lines[i];
if !line.is_empty() {
let first = line.as_bytes()[0];
if first != b' ' && first != b'\t' {
let truncated = if line.len() > 40 {
&line[..40]
} else {
line
};
return Some(truncated.to_owned());
}
}
}
None
}
pub fn format_stat_line(
path: &str,
insertions: usize,
deletions: usize,
max_path_len: usize,
) -> String {
let total = insertions + deletions;
let plus = "+".repeat(insertions.min(50));
let minus = "-".repeat(deletions.min(50));
format!(
" {:<width$} | {:>4} {}{}",
path,
total,
plus,
minus,
width = max_path_len
)
}
pub fn count_changes(old_content: &str, new_content: &str) -> (usize, usize) {
use similar::{ChangeTag, TextDiff};
let diff = TextDiff::from_lines(old_content, new_content);
let mut ins = 0;
let mut del = 0;
for change in diff.iter_all_changes() {
match change.tag() {
ChangeTag::Insert => ins += 1,
ChangeTag::Delete => del += 1,
ChangeTag::Equal => {}
}
}
(ins, del)
}
struct FlatEntry {
path: String,
mode: u32,
oid: ObjectId,
}
fn flatten_tree(odb: &Odb, tree_oid: &ObjectId, prefix: &str) -> Result<Vec<FlatEntry>> {
let entries = read_tree(odb, tree_oid)?;
let mut result = Vec::new();
for entry in entries {
let name_str = String::from_utf8_lossy(&entry.name);
let path = format_path(prefix, &name_str);
if is_tree_mode(entry.mode) {
let nested = flatten_tree(odb, &entry.oid, &path)?;
result.extend(nested);
} else {
result.push(FlatEntry {
path,
mode: entry.mode,
oid: entry.oid,
});
}
}
Ok(result)
}
fn is_tree_mode(mode: u32) -> bool {
mode == 0o040000
}
fn format_path(prefix: &str, name: &str) -> String {
if prefix.is_empty() {
name.to_owned()
} else {
format!("{prefix}/{name}")
}
}
fn format_mode(mode: u32) -> String {
format!("{mode:06o}")
}