use std::borrow::Cow;
use std::path::Path;
use crate::hash::Hash;
use crate::index::{Index, IndexError};
use crate::object::{EntryMode, Object, TreeEntry};
use crate::store::{MAX_TREE_DEPTH, ObjectSource, ObjectStore, StoreError};
use crate::worktree::{self, WorktreeError};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DiffKind {
Added,
Removed,
Modified,
ModeChanged,
Renamed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiffEntry {
pub path: String,
pub kind: DiffKind,
pub old_hash: Option<Hash>,
pub new_hash: Option<Hash>,
pub old_mode: Option<EntryMode>,
pub new_mode: Option<EntryMode>,
pub old_path: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DiffResult {
pub entries: Vec<DiffEntry>,
}
impl DiffResult {
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
}
pub fn detect_exact_renames(entries: &mut Vec<DiffEntry>) {
use std::collections::HashMap;
let mut removed: HashMap<Hash, Vec<usize>> = HashMap::new();
let mut added: HashMap<Hash, Vec<usize>> = HashMap::new();
for (i, e) in entries.iter().enumerate() {
match e.kind {
DiffKind::Removed => {
if let Some(h) = e.old_hash {
removed.entry(h).or_default().push(i);
}
}
DiffKind::Added => {
if let Some(h) = e.new_hash {
added.entry(h).or_default().push(i);
}
}
_ => {}
}
}
let mut consumed = vec![false; entries.len()];
let mut renames: Vec<DiffEntry> = Vec::new();
for (h, rem_idx) in &removed {
let Some(add_idx) = added.get(h) else {
continue;
};
let mut r = rem_idx.clone();
let mut a = add_idx.clone();
r.sort_by(|&x, &y| entries[x].path.cmp(&entries[y].path));
a.sort_by(|&x, &y| entries[x].path.cmp(&entries[y].path));
for (&ri, &ai) in r.iter().zip(a.iter()) {
renames.push(DiffEntry {
path: entries[ai].path.clone(),
kind: DiffKind::Renamed,
old_hash: Some(*h),
new_hash: Some(*h),
old_mode: entries[ri].old_mode,
new_mode: entries[ai].new_mode,
old_path: Some(entries[ri].path.clone()),
});
consumed[ri] = true;
consumed[ai] = true;
}
}
if renames.is_empty() {
return;
}
let mut out: Vec<DiffEntry> = Vec::with_capacity(entries.len());
for (i, e) in entries.drain(..).enumerate() {
if !consumed[i] {
out.push(e);
}
}
out.extend(renames);
out.sort_by(|a, b| a.path.cmp(&b.path));
*entries = out;
}
pub fn diff_trees<S: ObjectSource + ?Sized>(
store: &S,
old_hash: Option<Hash>,
new_hash: Option<Hash>,
) -> Result<DiffResult, StoreError> {
diff_trees_inner(store, old_hash, new_hash, false)
}
fn diff_trees_inner<S: ObjectSource + ?Sized>(
store: &S,
old_hash: Option<Hash>,
new_hash: Option<Hash>,
ignore_regular_executable_mode: bool,
) -> Result<DiffResult, StoreError> {
match (old_hash, new_hash) {
(None, None) => return Ok(DiffResult::default()),
(Some(a), Some(b)) if a == b => return Ok(DiffResult::default()),
_ => {}
}
let old_entries = load_entries(store, old_hash)?;
let new_entries = load_entries(store, new_hash)?;
let mut out: Vec<DiffEntry> = Vec::new();
diff_entries_recursive(
store,
&old_entries,
&new_entries,
"",
&mut out,
ignore_regular_executable_mode,
0,
)?;
out.sort_by(|a, b| a.path.cmp(&b.path));
Ok(DiffResult { entries: out })
}
fn diff_entries_recursive<S: ObjectSource + ?Sized>(
store: &S,
old_entries: &[TreeEntry],
new_entries: &[TreeEntry],
prefix: &str,
out: &mut Vec<DiffEntry>,
ignore_regular_executable_mode: bool,
depth: usize,
) -> Result<(), StoreError> {
if depth > MAX_TREE_DEPTH {
return Err(StoreError::TreeTooDeep);
}
let mut i = 0usize;
let mut j = 0usize;
while i < old_entries.len() && j < new_entries.len() {
let o = &old_entries[i];
let n = &new_entries[j];
match o.name.as_slice().cmp(n.name.as_slice()) {
std::cmp::Ordering::Less => {
add_removed_entries(store, o, prefix, out, depth)?;
i += 1;
}
std::cmp::Ordering::Greater => {
add_added_entries(store, n, prefix, out, depth)?;
j += 1;
}
std::cmp::Ordering::Equal => {
if o.mode == EntryMode::Tree && n.mode == EntryMode::Tree {
if o.object_hash != n.object_hash {
let sub_prefix = join_path(prefix, &o.name);
let old_sub = load_tree(store, o.object_hash)?;
let new_sub = load_tree(store, n.object_hash)?;
diff_entries_recursive(
store,
&old_sub,
&new_sub,
&sub_prefix,
out,
ignore_regular_executable_mode,
depth + 1,
)?;
}
} else if o.mode == EntryMode::Tree || n.mode == EntryMode::Tree {
add_removed_entries(store, o, prefix, out, depth)?;
add_added_entries(store, n, prefix, out, depth)?;
} else if o.mode != n.mode && o.object_hash == n.object_hash {
if !ignore_regular_executable_mode || !regular_executable_pair(o.mode, n.mode) {
out.push(DiffEntry {
path: join_path(prefix, &o.name),
kind: DiffKind::ModeChanged,
old_hash: Some(o.object_hash),
new_hash: Some(n.object_hash),
old_mode: Some(o.mode),
new_mode: Some(n.mode),
old_path: None,
});
}
} else if o.object_hash != n.object_hash || o.mode != n.mode {
out.push(DiffEntry {
path: join_path(prefix, &o.name),
kind: DiffKind::Modified,
old_hash: Some(o.object_hash),
new_hash: Some(n.object_hash),
old_mode: Some(o.mode),
new_mode: Some(n.mode),
old_path: None,
});
}
i += 1;
j += 1;
}
}
}
while i < old_entries.len() {
add_removed_entries(store, &old_entries[i], prefix, out, depth)?;
i += 1;
}
while j < new_entries.len() {
add_added_entries(store, &new_entries[j], prefix, out, depth)?;
j += 1;
}
Ok(())
}
fn regular_executable_pair(a: EntryMode, b: EntryMode) -> bool {
matches!(
(a, b),
(EntryMode::Blob, EntryMode::Executable) | (EntryMode::Executable, EntryMode::Blob)
)
}
fn add_removed_entries<S: ObjectSource + ?Sized>(
store: &S,
entry: &TreeEntry,
prefix: &str,
out: &mut Vec<DiffEntry>,
depth: usize,
) -> Result<(), StoreError> {
if depth > MAX_TREE_DEPTH {
return Err(StoreError::TreeTooDeep);
}
if entry.mode == EntryMode::Tree {
let sub_prefix = join_path(prefix, &entry.name);
let sub = load_tree(store, entry.object_hash)?;
for sub_entry in &sub {
add_removed_entries(store, sub_entry, &sub_prefix, out, depth + 1)?;
}
} else {
out.push(DiffEntry {
path: join_path(prefix, &entry.name),
kind: DiffKind::Removed,
old_hash: Some(entry.object_hash),
new_hash: None,
old_mode: Some(entry.mode),
new_mode: None,
old_path: None,
});
}
Ok(())
}
fn add_added_entries<S: ObjectSource + ?Sized>(
store: &S,
entry: &TreeEntry,
prefix: &str,
out: &mut Vec<DiffEntry>,
depth: usize,
) -> Result<(), StoreError> {
if depth > MAX_TREE_DEPTH {
return Err(StoreError::TreeTooDeep);
}
if entry.mode == EntryMode::Tree {
let sub_prefix = join_path(prefix, &entry.name);
let sub = load_tree(store, entry.object_hash)?;
for sub_entry in &sub {
add_added_entries(store, sub_entry, &sub_prefix, out, depth + 1)?;
}
} else {
out.push(DiffEntry {
path: join_path(prefix, &entry.name),
kind: DiffKind::Added,
old_hash: None,
new_hash: Some(entry.object_hash),
old_mode: None,
new_mode: Some(entry.mode),
old_path: None,
});
}
Ok(())
}
fn load_entries<S: ObjectSource + ?Sized>(
store: &S,
hash: Option<Hash>,
) -> Result<Vec<TreeEntry>, StoreError> {
match hash {
Some(h) => load_tree(store, h),
None => Ok(Vec::new()),
}
}
fn load_tree<S: ObjectSource + ?Sized>(store: &S, h: Hash) -> Result<Vec<TreeEntry>, StoreError> {
match store.read_object(&h)? {
Object::Tree(t) => Ok(t.entries),
other => Err(StoreError::Decode(
crate::object::MkitError::InvalidObjectType(other.object_type() as u8),
)),
}
}
fn join_path(prefix: &str, name: &[u8]) -> String {
let name_str = String::from_utf8_lossy(name);
if prefix.is_empty() {
name_str.into_owned()
} else {
let mut s = String::with_capacity(prefix.len() + 1 + name_str.len());
s.push_str(prefix);
s.push('/');
s.push_str(&name_str);
s
}
}
const PATCH_CONTEXT: usize = 3;
pub const DEFAULT_CONTEXT_LINES: usize = PATCH_CONTEXT;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum WhitespaceMode {
#[default]
Exact,
IgnoreSpaceChange,
IgnoreAllSpace,
}
fn ws_key(text: &[u8], mode: WhitespaceMode) -> Cow<'_, [u8]> {
match mode {
WhitespaceMode::Exact => Cow::Borrowed(text),
WhitespaceMode::IgnoreAllSpace => Cow::Owned(strip_all_ws(text)),
WhitespaceMode::IgnoreSpaceChange => Cow::Owned(collapse_ws(text)),
}
}
fn is_ws_byte(b: u8) -> bool {
b.is_ascii_whitespace() || b == 0x0B
}
fn strip_all_ws(line: &[u8]) -> Vec<u8> {
line.iter().copied().filter(|&b| !is_ws_byte(b)).collect()
}
fn collapse_ws(line: &[u8]) -> Vec<u8> {
let mut end = line.len();
while end > 0 && is_ws_byte(line[end - 1]) {
end -= 1;
}
let line = &line[..end];
let mut out = Vec::with_capacity(line.len());
let mut i = 0;
while i < line.len() {
if is_ws_byte(line[i]) {
out.push(b' ');
while i < line.len() && is_ws_byte(line[i]) {
i += 1;
}
} else {
out.push(line[i]);
i += 1;
}
}
out
}
#[must_use]
pub fn text_patch(old_bytes: &[u8], new_bytes: &[u8], old_path: &str, new_path: &str) -> String {
match unified_hunks(old_bytes, new_bytes) {
None => format!("Binary files a/{old_path} and b/{new_path} differ\n"),
Some(hunks) if hunks.is_empty() => String::new(),
Some(hunks) => {
format!(
"--- a/{old_path}\n+++ b/{new_path}\n{}",
String::from_utf8_lossy(&hunks)
)
}
}
}
#[must_use]
pub fn unified_hunks(old_bytes: &[u8], new_bytes: &[u8]) -> Option<Vec<u8>> {
unified_hunks_opts(old_bytes, new_bytes, PATCH_CONTEXT, WhitespaceMode::Exact)
}
#[must_use]
pub fn unified_hunks_opts(
old_bytes: &[u8],
new_bytes: &[u8],
context: usize,
mode: WhitespaceMode,
) -> Option<Vec<u8>> {
if is_binary(old_bytes) || is_binary(new_bytes) {
return None;
}
let old_lines = split_lines(old_bytes);
let new_lines = split_lines(new_bytes);
let ops = edit_script(&old_lines, &new_lines, mode);
let hunks = group_hunks(&ops, context);
let mut out = Vec::new();
for hunk in &hunks {
render_hunk(&mut out, hunk, &old_lines, &new_lines);
}
Some(out)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HunkLineKind {
Context,
Added,
Removed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HunkLine {
pub kind: HunkLineKind,
pub text: Vec<u8>,
pub has_newline: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PatchHunk {
pub old_start: usize,
pub old_len: usize,
pub new_start: usize,
pub new_len: usize,
pub lines: Vec<HunkLine>,
}
#[must_use]
pub fn enumerate_hunks(old_bytes: &[u8], new_bytes: &[u8]) -> Option<Vec<PatchHunk>> {
if is_binary(old_bytes) || is_binary(new_bytes) {
return None;
}
let old_lines = split_lines(old_bytes);
let new_lines = split_lines(new_bytes);
let ops = edit_script(&old_lines, &new_lines, WhitespaceMode::Exact);
let hunks = group_hunks(&ops, PATCH_CONTEXT);
Some(
hunks
.iter()
.map(|h| to_patch_hunk(h, &old_lines, &new_lines))
.collect(),
)
}
fn to_patch_hunk(hunk: &Hunk, old: &[DiffLine<'_>], new: &[DiffLine<'_>]) -> PatchHunk {
let lines = hunk
.ops
.iter()
.map(|op| {
let (kind, dl) = match *op {
DiffOp::Equal(oi, _) => (HunkLineKind::Context, &old[oi]),
DiffOp::Delete(oi) => (HunkLineKind::Removed, &old[oi]),
DiffOp::Insert(ni) => (HunkLineKind::Added, &new[ni]),
};
HunkLine {
kind,
text: dl.text.to_vec(),
has_newline: dl.has_newline,
}
})
.collect();
PatchHunk {
old_start: hunk.old_start,
old_len: hunk.old_len,
new_start: hunk.new_start,
new_len: hunk.new_len,
lines,
}
}
#[must_use]
pub fn apply_hunks_subset(base_bytes: &[u8], hunks: &[PatchHunk], selected: &[usize]) -> Vec<u8> {
let old_lines = split_lines(base_bytes);
let sel: std::collections::HashSet<usize> = selected.iter().copied().collect();
let mut out: Vec<u8> = Vec::with_capacity(base_bytes.len());
let mut cursor = 0usize; for (i, h) in hunks.iter().enumerate() {
let region_start = if h.old_len == 0 {
h.old_start
} else {
h.old_start - 1
}
.min(old_lines.len());
let region_end = (region_start + h.old_len).min(old_lines.len());
for dl in &old_lines[cursor..region_start] {
emit_raw_line(&mut out, dl.text, dl.has_newline);
}
if sel.contains(&i) {
for l in &h.lines {
if l.kind != HunkLineKind::Removed {
emit_raw_line(&mut out, &l.text, l.has_newline);
}
}
} else {
for dl in &old_lines[region_start..region_end] {
emit_raw_line(&mut out, dl.text, dl.has_newline);
}
}
cursor = region_end;
}
for dl in &old_lines[cursor..] {
emit_raw_line(&mut out, dl.text, dl.has_newline);
}
out
}
fn emit_raw_line(out: &mut Vec<u8>, text: &[u8], has_newline: bool) {
out.extend_from_slice(text);
if has_newline {
out.push(b'\n');
}
}
struct MergeRegion {
base_start: usize,
base_len: usize,
new: Vec<(Vec<u8>, bool)>,
}
#[must_use]
pub fn merge_blob_3way(base: &[u8], ours: &[u8], theirs: &[u8]) -> Option<Vec<u8>> {
if ours == theirs {
return Some(ours.to_vec());
}
if is_binary(base) || is_binary(ours) || is_binary(theirs) {
return None;
}
let base_lines = split_lines(base);
let ours_regions = changed_regions(&base_lines, &split_lines(ours));
let theirs_regions = changed_regions(&base_lines, &split_lines(theirs));
if regions_overlap(&ours_regions, &theirs_regions) {
return None;
}
Some(apply_merge_regions(
&base_lines,
&ours_regions,
&theirs_regions,
))
}
fn changed_regions(base: &[DiffLine<'_>], side: &[DiffLine<'_>]) -> Vec<MergeRegion> {
let mut regions: Vec<MergeRegion> = Vec::new();
let mut cur: Option<MergeRegion> = None;
let mut base_idx = 0usize; for op in edit_script(base, side, WhitespaceMode::Exact) {
match op {
DiffOp::Equal(bi, _) => {
if let Some(r) = cur.take() {
regions.push(r);
}
base_idx = bi + 1;
}
DiffOp::Delete(bi) => {
cur.get_or_insert(MergeRegion {
base_start: base_idx,
base_len: 0,
new: Vec::new(),
})
.base_len += 1;
base_idx = bi + 1;
}
DiffOp::Insert(si) => {
let line = &side[si];
cur.get_or_insert(MergeRegion {
base_start: base_idx,
base_len: 0,
new: Vec::new(),
})
.new
.push((line.text.to_vec(), line.has_newline));
}
}
}
if let Some(r) = cur.take() {
regions.push(r);
}
regions
}
fn regions_overlap(ours: &[MergeRegion], theirs: &[MergeRegion]) -> bool {
ours.iter().any(|a| {
theirs.iter().any(|b| {
let (a_s, a_e) = (a.base_start, a.base_start + a.base_len);
let (b_s, b_e) = (b.base_start, b.base_start + b.base_len);
match (a.base_len == 0, b.base_len == 0) {
(true, true) => a_s == b_s,
(true, false) => b_s < a_s && a_s < b_e,
(false, true) => a_s < b_s && b_s < a_e,
(false, false) => a_s < b_e && b_s < a_e,
}
})
})
}
fn apply_merge_regions(
base: &[DiffLine<'_>],
ours: &[MergeRegion],
theirs: &[MergeRegion],
) -> Vec<u8> {
let mut all: Vec<&MergeRegion> = ours.iter().chain(theirs.iter()).collect();
all.sort_by_key(|r| (r.base_start, r.base_len));
let mut out: Vec<u8> = Vec::new();
let mut cursor = 0usize;
for r in all {
for line in &base[cursor..r.base_start] {
emit_raw_line(&mut out, line.text, line.has_newline);
}
for (text, nl) in &r.new {
emit_raw_line(&mut out, text, *nl);
}
cursor = r.base_start + r.base_len;
}
for line in &base[cursor..] {
emit_raw_line(&mut out, line.text, line.has_newline);
}
out
}
#[must_use]
pub fn diff_line_counts(old_bytes: &[u8], new_bytes: &[u8]) -> Option<(usize, usize)> {
if is_binary(old_bytes) || is_binary(new_bytes) {
return None;
}
let old_lines = split_lines(old_bytes);
let new_lines = split_lines(new_bytes);
let mut added = 0;
let mut deleted = 0;
for op in edit_script(&old_lines, &new_lines, WhitespaceMode::Exact) {
match op {
DiffOp::Insert(_) => added += 1,
DiffOp::Delete(_) => deleted += 1,
DiffOp::Equal(_, _) => {}
}
}
Some((added, deleted))
}
pub const BINARY_SNIFF_LEN: usize = 8000;
#[must_use]
pub fn is_binary(bytes: &[u8]) -> bool {
bytes.iter().take(BINARY_SNIFF_LEN).any(|&b| b == 0)
}
struct DiffLine<'a> {
text: &'a [u8],
has_newline: bool,
}
fn split_lines(text: &[u8]) -> Vec<DiffLine<'_>> {
let mut lines = Vec::new();
let mut rest = text;
while !rest.is_empty() {
if let Some(idx) = rest.iter().position(|&b| b == b'\n') {
lines.push(DiffLine {
text: &rest[..idx],
has_newline: true,
});
rest = &rest[idx + 1..];
} else {
lines.push(DiffLine {
text: rest,
has_newline: false,
});
rest = b"";
}
}
lines
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DiffOp {
Equal(usize, usize),
Delete(usize),
Insert(usize),
}
fn edit_script(old: &[DiffLine<'_>], new: &[DiffLine<'_>], mode: WhitespaceMode) -> Vec<DiffOp> {
let (mut old_changed, mut new_changed) = myers_changed(old, new, mode);
compact_changes(old, &mut old_changed, mode);
compact_changes(new, &mut new_changed, mode);
script_from_flags(old, new, &old_changed, &new_changed)
}
#[allow(
clippy::cast_sign_loss,
clippy::cast_possible_wrap,
clippy::many_single_char_names
)]
fn myers_changed(
old: &[DiffLine<'_>],
new: &[DiffLine<'_>],
mode: WhitespaceMode,
) -> (Vec<bool>, Vec<bool>) {
let n = old.len();
let m = new.len();
let mut old_changed = vec![false; n];
let mut new_changed = vec![false; m];
if n == 0 {
new_changed.fill(true);
return (old_changed, new_changed);
}
if m == 0 {
old_changed.fill(true);
return (old_changed, new_changed);
}
let max = n + m;
let offset = max as isize; let mut v = vec![0isize; 2 * max + 1];
let mut trace: Vec<Vec<isize>> = Vec::new();
let idx = |k: isize| (k + offset) as usize;
let mut found = max as isize;
'outer: for d in 0..=max as isize {
trace.push(v.clone());
let mut k = -d;
while k <= d {
let mut x = if k == -d || (k != d && v[idx(k - 1)] < v[idx(k + 1)]) {
v[idx(k + 1)] } else {
v[idx(k - 1)] + 1 };
let mut y = x - k;
while (x as usize) < n
&& (y as usize) < m
&& lines_equal(&old[x as usize], &new[y as usize], mode)
{
x += 1;
y += 1;
}
v[idx(k)] = x;
if x as usize >= n && y as usize >= m {
found = d;
break 'outer;
}
k += 2;
}
}
let mut x = n as isize;
let mut y = m as isize;
for d in (0..=found).rev() {
let vd = &trace[d as usize];
let k = x - y;
let down = k == -d || (k != d && vd[idx(k - 1)] < vd[idx(k + 1)]);
let prev_k = if down { k + 1 } else { k - 1 };
let prev_x = vd[idx(prev_k)];
let prev_y = prev_x - prev_k;
while x > prev_x && y > prev_y {
x -= 1;
y -= 1;
}
if d > 0 {
if down {
new_changed[(y - 1) as usize] = true; y -= 1;
} else {
old_changed[(x - 1) as usize] = true; x -= 1;
}
}
}
(old_changed, new_changed)
}
fn compact_changes(lines: &[DiffLine<'_>], changed: &mut [bool], mode: WhitespaceMode) {
let n = lines.len();
let mut i = 0;
while i < n {
if !changed[i] {
i += 1;
continue;
}
let start = i;
let mut end = i;
while end < n && changed[end] {
end += 1;
}
let (mut s, mut e) = (start, end);
while e < n && lines_equal(&lines[s], &lines[e], mode) {
changed[s] = false;
changed[e] = true;
s += 1;
e += 1;
}
i = e;
}
}
fn script_from_flags(
old: &[DiffLine<'_>],
new: &[DiffLine<'_>],
old_changed: &[bool],
new_changed: &[bool],
) -> Vec<DiffOp> {
let (n, m) = (old.len(), new.len());
let mut ops = Vec::new();
let (mut i, mut j) = (0usize, 0usize);
while i < n || j < m {
if i < n && j < m && !old_changed[i] && !new_changed[j] {
ops.push(DiffOp::Equal(i, j));
i += 1;
j += 1;
} else {
while i < n && old_changed[i] {
ops.push(DiffOp::Delete(i));
i += 1;
}
while j < m && new_changed[j] {
ops.push(DiffOp::Insert(j));
j += 1;
}
}
}
ops
}
fn lines_equal(a: &DiffLine<'_>, b: &DiffLine<'_>, mode: WhitespaceMode) -> bool {
a.has_newline == b.has_newline && ws_key(a.text, mode) == ws_key(b.text, mode)
}
struct Hunk {
old_start: usize,
old_len: usize,
new_start: usize,
new_len: usize,
ops: Vec<DiffOp>,
}
fn group_hunks(ops: &[DiffOp], context: usize) -> Vec<Hunk> {
let change_positions: Vec<usize> = ops
.iter()
.enumerate()
.filter(|(_, op)| !matches!(op, DiffOp::Equal(_, _)))
.map(|(idx, _)| idx)
.collect();
if change_positions.is_empty() {
return Vec::new();
}
let mut ranges: Vec<(usize, usize)> = Vec::new();
for &pos in &change_positions {
let start = pos.saturating_sub(context);
let end = (pos + context + 1).min(ops.len());
match ranges.last_mut() {
Some(last) if start <= last.1 => last.1 = last.1.max(end),
_ => ranges.push((start, end)),
}
}
ranges
.into_iter()
.map(|(start, end)| build_hunk(&ops[start..end]))
.collect()
}
fn build_hunk(slice: &[DiffOp]) -> Hunk {
let mut old_start = None;
let mut new_start = None;
let mut old_len = 0usize;
let mut new_len = 0usize;
for op in slice {
match *op {
DiffOp::Equal(oi, ni) => {
old_start.get_or_insert(oi);
new_start.get_or_insert(ni);
old_len += 1;
new_len += 1;
}
DiffOp::Delete(oi) => {
old_start.get_or_insert(oi);
old_len += 1;
}
DiffOp::Insert(ni) => {
new_start.get_or_insert(ni);
new_len += 1;
}
}
}
Hunk {
old_start: old_start.map_or(0, |s| s + 1),
old_len,
new_start: new_start.map_or(0, |s| s + 1),
new_len,
ops: slice.to_vec(),
}
}
fn hunk_range(start: usize, len: usize) -> String {
if len == 1 {
start.to_string()
} else {
format!("{start},{len}")
}
}
fn render_hunk(out: &mut Vec<u8>, hunk: &Hunk, old: &[DiffLine<'_>], new: &[DiffLine<'_>]) {
let header = format!(
"@@ -{} +{} @@\n",
hunk_range(hunk.old_start, hunk.old_len),
hunk_range(hunk.new_start, hunk.new_len)
);
out.extend_from_slice(header.as_bytes());
for op in &hunk.ops {
match *op {
DiffOp::Equal(oi, _) => emit_line(out, b' ', &old[oi]),
DiffOp::Delete(oi) => emit_line(out, b'-', &old[oi]),
DiffOp::Insert(ni) => emit_line(out, b'+', &new[ni]),
}
}
}
fn emit_line(out: &mut Vec<u8>, prefix: u8, line: &DiffLine<'_>) {
out.push(prefix);
out.extend_from_slice(line.text);
out.push(b'\n');
if !line.has_newline {
out.extend_from_slice(b"\\ No newline at end of file\n");
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatusStaging {
Unstaged,
Staged,
PartiallyStaged,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StatusEntry {
pub diff: DiffEntry,
pub staging: StatusStaging,
}
#[derive(Debug, thiserror::Error)]
pub enum DiffError {
#[error(transparent)]
Store(#[from] StoreError),
#[error(transparent)]
Worktree(#[from] WorktreeError),
#[error(transparent)]
Index(#[from] IndexError),
}
#[allow(clippy::too_many_lines)]
pub fn status_diff(
store: &ObjectStore,
head_tree: Option<&Hash>,
worktree_root: &Path,
index: Option<&Index>,
) -> Result<Vec<StatusEntry>, DiffError> {
status_diff_observed(store, head_tree, worktree_root, index).map(|(entries, _)| entries)
}
#[allow(clippy::type_complexity)]
pub fn status_diff_observed(
store: &ObjectStore,
head_tree: Option<&Hash>,
worktree_root: &Path,
index: Option<&Index>,
) -> Result<(Vec<StatusEntry>, Vec<worktree::StatObservation>), DiffError> {
let head_seed;
let tracked = if let Some(i) = index {
Some(i)
} else if let Some(ht) = head_tree {
head_seed = crate::index::from_tree(store, *ht)?;
Some(&head_seed)
} else {
None
};
let snapshot = crate::store::EphemeralSink::new(store);
let mut observations = Vec::new();
let work_tree_hash = worktree::build_tree_filtered_observed(
&snapshot,
worktree_root,
tracked,
&mut observations,
)?;
let Some(idx) = index else {
let diff = diff_worktree_trees(&snapshot, head_tree.copied(), Some(work_tree_hash))?;
return Ok((
diff.entries
.into_iter()
.map(|d| StatusEntry {
diff: d,
staging: StatusStaging::Unstaged,
})
.collect(),
observations,
));
};
let index_tree = worktree::build_tree_from_index_with(store, &snapshot, idx, false)?;
let staged = diff_trees(&snapshot, head_tree.copied(), Some(index_tree))?;
let unstaged = diff_worktree_trees(&snapshot, Some(index_tree), Some(work_tree_hash))?;
let mut out: Vec<StatusEntry> =
Vec::with_capacity(staged.entries.len() + unstaged.entries.len());
for d in staged.entries {
out.push(StatusEntry {
diff: d,
staging: StatusStaging::Staged,
});
}
for d in unstaged.entries {
out.push(StatusEntry {
diff: d,
staging: StatusStaging::Unstaged,
});
}
out.sort_by(|a, b| {
a.diff.path.cmp(&b.diff.path).then_with(|| {
#[allow(clippy::match_same_arms)]
match (a.staging, b.staging) {
(StatusStaging::Staged, StatusStaging::Staged) => std::cmp::Ordering::Equal,
(StatusStaging::Staged, _) => std::cmp::Ordering::Less,
(_, StatusStaging::Staged) => std::cmp::Ordering::Greater,
_ => std::cmp::Ordering::Equal,
}
})
});
Ok((out, observations))
}
#[cfg(unix)]
fn diff_worktree_trees<S: ObjectSource + ?Sized>(
store: &S,
old_hash: Option<Hash>,
new_hash: Option<Hash>,
) -> Result<DiffResult, StoreError> {
diff_trees(store, old_hash, new_hash)
}
#[cfg(not(unix))]
fn diff_worktree_trees<S: ObjectSource + ?Sized>(
store: &S,
old_hash: Option<Hash>,
new_hash: Option<Hash>,
) -> Result<DiffResult, StoreError> {
diff_trees_inner(store, old_hash, new_hash, true)
}
#[cfg(test)]
#[allow(clippy::many_single_char_names)] mod tests {
use super::*;
use crate::object::{Blob, Tree};
use crate::serialize;
use tempfile::TempDir;
#[test]
fn diff_line_counts_counts_text_and_flags_binary() {
assert_eq!(
diff_line_counts(b"a\nb\nc\n", b"a\nB\nc\nd\ne\n"),
Some((3, 1))
);
assert_eq!(diff_line_counts(b"a\nb\n", b"a\x00b\n"), None);
assert_eq!(diff_line_counts(b"x\x00y", b"z"), None);
assert!(diff_line_counts(b"\xff\xfe\n", b"\xff\xfe\nmore\n").is_some());
}
fn h(b: &[u8]) -> Hash {
crate::hash::hash(b)
}
fn removed(path: &str, content: &[u8]) -> DiffEntry {
DiffEntry {
path: path.into(),
kind: DiffKind::Removed,
old_hash: Some(h(content)),
new_hash: None,
old_mode: Some(EntryMode::Blob),
new_mode: None,
old_path: None,
}
}
fn added(path: &str, content: &[u8]) -> DiffEntry {
DiffEntry {
path: path.into(),
kind: DiffKind::Added,
old_hash: None,
new_hash: Some(h(content)),
old_mode: None,
new_mode: Some(EntryMode::Blob),
old_path: None,
}
}
#[test]
fn detect_pairs_identical_content_into_one_rename() {
let mut es = vec![removed("old.txt", b"hello"), added("new.txt", b"hello")];
detect_exact_renames(&mut es);
assert_eq!(es.len(), 1);
assert_eq!(es[0].kind, DiffKind::Renamed);
assert_eq!(es[0].path, "new.txt");
assert_eq!(es[0].old_path.as_deref(), Some("old.txt"));
assert_eq!(es[0].old_hash, Some(h(b"hello")));
assert_eq!(es[0].new_hash, Some(h(b"hello")));
}
#[test]
fn detect_leaves_unrelated_delete_add_alone() {
let mut es = vec![removed("a", b"one"), added("b", b"two")];
let before = es.clone();
detect_exact_renames(&mut es);
assert_eq!(es, before);
}
#[test]
fn detect_ignores_modified_in_place() {
let mut es = vec![DiffEntry {
path: "f".into(),
kind: DiffKind::Modified,
old_hash: Some(h(b"x")),
new_hash: Some(h(b"y")),
old_mode: Some(EntryMode::Blob),
new_mode: Some(EntryMode::Blob),
old_path: None,
}];
let before = es.clone();
detect_exact_renames(&mut es);
assert_eq!(es, before);
}
#[test]
fn detect_pairs_duplicates_deterministically_by_path() {
let mut es = vec![
added("new_b", b"dup"),
removed("old_b", b"dup"),
added("new_a", b"dup"),
removed("old_a", b"dup"),
];
detect_exact_renames(&mut es);
assert_eq!(es.len(), 2);
assert_eq!(
(es[0].old_path.as_deref(), es[0].path.as_str()),
(Some("old_a"), "new_a")
);
assert_eq!(
(es[1].old_path.as_deref(), es[1].path.as_str()),
(Some("old_b"), "new_b")
);
}
#[test]
fn detect_leaves_unpaired_remainder() {
let mut es = vec![
removed("old_a", b"c"),
removed("old_b", b"c"),
added("new", b"c"),
];
detect_exact_renames(&mut es);
assert_eq!(es.len(), 2);
let r = es.iter().find(|e| e.kind == DiffKind::Renamed).unwrap();
assert_eq!(r.old_path.as_deref(), Some("old_a"));
let d = es.iter().find(|e| e.kind == DiffKind::Removed).unwrap();
assert_eq!(d.path, "old_b");
}
#[test]
fn detect_preserves_modes_on_rename_with_mode_change() {
let r = removed("old", b"same");
let mut a = added("new", b"same");
a.new_mode = Some(EntryMode::Executable);
let mut es = vec![r, a];
detect_exact_renames(&mut es);
assert_eq!(es.len(), 1);
assert_eq!(es[0].kind, DiffKind::Renamed);
assert_eq!(es[0].old_mode, Some(EntryMode::Blob));
assert_eq!(es[0].new_mode, Some(EntryMode::Executable));
}
fn fresh_store() -> (TempDir, ObjectStore) {
let dir = TempDir::new().unwrap();
let store = ObjectStore::init(&crate::layout::RepoLayout::single(dir.path())).unwrap();
(dir, store)
}
fn put_blob(store: &ObjectStore, data: &[u8]) -> Hash {
let obj = Object::Blob(Blob {
data: data.to_vec(),
});
let bytes = serialize::serialize(&obj).unwrap();
store.write(&bytes).unwrap()
}
fn put_tree(store: &ObjectStore, entries: Vec<TreeEntry>) -> Hash {
let obj = Object::Tree(Tree { entries });
let bytes = serialize::serialize(&obj).unwrap();
store.write(&bytes).unwrap()
}
fn entry(name: &[u8], mode: EntryMode, h: Hash) -> TreeEntry {
TreeEntry {
name: name.to_vec(),
mode,
object_hash: h,
}
}
#[test]
fn identical_trees_no_diff() {
let (_d, s) = fresh_store();
let blob = put_blob(&s, b"content");
let tree = put_tree(&s, vec![entry(b"a.txt", EntryMode::Blob, blob)]);
let result = diff_trees(&s, Some(tree), Some(tree)).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn added_file_detected() {
let (_d, s) = fresh_store();
let blob_a = put_blob(&s, b"aaa");
let blob_b = put_blob(&s, b"bbb");
let old = put_tree(&s, vec![entry(b"a.txt", EntryMode::Blob, blob_a)]);
let new = put_tree(
&s,
vec![
entry(b"a.txt", EntryMode::Blob, blob_a),
entry(b"b.txt", EntryMode::Blob, blob_b),
],
);
let r = diff_trees(&s, Some(old), Some(new)).unwrap();
assert_eq!(r.entries.len(), 1);
assert_eq!(r.entries[0].path, "b.txt");
assert_eq!(r.entries[0].kind, DiffKind::Added);
assert_eq!(r.entries[0].old_hash, None);
assert_eq!(r.entries[0].new_hash, Some(blob_b));
}
#[test]
fn removed_file_detected() {
let (_d, s) = fresh_store();
let blob_a = put_blob(&s, b"aaa");
let blob_b = put_blob(&s, b"bbb");
let old = put_tree(
&s,
vec![
entry(b"a.txt", EntryMode::Blob, blob_a),
entry(b"b.txt", EntryMode::Blob, blob_b),
],
);
let new = put_tree(&s, vec![entry(b"a.txt", EntryMode::Blob, blob_a)]);
let r = diff_trees(&s, Some(old), Some(new)).unwrap();
assert_eq!(r.entries.len(), 1);
assert_eq!(r.entries[0].path, "b.txt");
assert_eq!(r.entries[0].kind, DiffKind::Removed);
assert_eq!(r.entries[0].old_hash, Some(blob_b));
assert_eq!(r.entries[0].new_hash, None);
}
#[test]
fn modified_file_detected() {
let (_d, s) = fresh_store();
let v1 = put_blob(&s, b"version 1");
let v2 = put_blob(&s, b"version 2");
let old = put_tree(&s, vec![entry(b"file.txt", EntryMode::Blob, v1)]);
let new = put_tree(&s, vec![entry(b"file.txt", EntryMode::Blob, v2)]);
let r = diff_trees(&s, Some(old), Some(new)).unwrap();
assert_eq!(r.entries.len(), 1);
assert_eq!(r.entries[0].path, "file.txt");
assert_eq!(r.entries[0].kind, DiffKind::Modified);
assert_eq!(r.entries[0].old_hash, Some(v1));
assert_eq!(r.entries[0].new_hash, Some(v2));
}
#[test]
fn mode_change_detected() {
let (_d, s) = fresh_store();
let blob = put_blob(&s, b"content");
let old = put_tree(&s, vec![entry(b"link", EntryMode::Blob, blob)]);
let new = put_tree(&s, vec![entry(b"link", EntryMode::Symlink, blob)]);
let r = diff_trees(&s, Some(old), Some(new)).unwrap();
assert_eq!(r.entries.len(), 1);
assert_eq!(r.entries[0].path, "link");
assert_eq!(r.entries[0].kind, DiffKind::ModeChanged);
assert_eq!(r.entries[0].old_hash, Some(blob));
assert_eq!(r.entries[0].new_hash, Some(blob));
}
#[test]
fn nested_tree_diff() {
let (_d, s) = fresh_store();
let v1 = put_blob(&s, b"old content");
let v2 = put_blob(&s, b"new content");
let other = put_blob(&s, b"unchanged");
let old_sub = put_tree(
&s,
vec![
entry(b"file.txt", EntryMode::Blob, v1),
entry(b"other.txt", EntryMode::Blob, other),
],
);
let new_sub = put_tree(
&s,
vec![
entry(b"file.txt", EntryMode::Blob, v2),
entry(b"other.txt", EntryMode::Blob, other),
],
);
let old_root = put_tree(&s, vec![entry(b"subdir", EntryMode::Tree, old_sub)]);
let new_root = put_tree(&s, vec![entry(b"subdir", EntryMode::Tree, new_sub)]);
let r = diff_trees(&s, Some(old_root), Some(new_root)).unwrap();
assert_eq!(r.entries.len(), 1);
assert_eq!(r.entries[0].path, "subdir/file.txt");
assert_eq!(r.entries[0].kind, DiffKind::Modified);
}
#[test]
fn diff_against_empty_tree() {
let (_d, s) = fresh_store();
let blob_a = put_blob(&s, b"aaa");
let blob_b = put_blob(&s, b"bbb");
let new = put_tree(
&s,
vec![
entry(b"a.txt", EntryMode::Blob, blob_a),
entry(b"b.txt", EntryMode::Blob, blob_b),
],
);
let r = diff_trees(&s, None, Some(new)).unwrap();
assert_eq!(r.entries.len(), 2);
assert_eq!(r.entries[0].path, "a.txt");
assert_eq!(r.entries[0].kind, DiffKind::Added);
assert_eq!(r.entries[1].path, "b.txt");
assert_eq!(r.entries[1].kind, DiffKind::Added);
}
#[test]
fn empty_tree_against_non_empty() {
let (_d, s) = fresh_store();
let blob_a = put_blob(&s, b"aaa");
let blob_b = put_blob(&s, b"bbb");
let old = put_tree(
&s,
vec![
entry(b"a.txt", EntryMode::Blob, blob_a),
entry(b"b.txt", EntryMode::Blob, blob_b),
],
);
let r = diff_trees(&s, Some(old), None).unwrap();
assert_eq!(r.entries.len(), 2);
assert_eq!(r.entries[0].kind, DiffKind::Removed);
assert_eq!(r.entries[1].kind, DiffKind::Removed);
}
#[test]
fn sorted_output() {
let (_d, s) = fresh_store();
let a = put_blob(&s, b"a");
let b = put_blob(&s, b"b");
let c = put_blob(&s, b"c");
let new = put_tree(
&s,
vec![
entry(b"a.txt", EntryMode::Blob, a),
entry(b"m.txt", EntryMode::Blob, b),
entry(b"z.txt", EntryMode::Blob, c),
],
);
let r = diff_trees(&s, None, Some(new)).unwrap();
assert_eq!(r.entries.len(), 3);
assert_eq!(r.entries[0].path, "a.txt");
assert_eq!(r.entries[1].path, "m.txt");
assert_eq!(r.entries[2].path, "z.txt");
}
#[test]
fn max_length_entry_names() {
let (_d, s) = fresh_store();
let blob = put_blob(&s, b"data");
let long_name = vec![b'A'; 255];
let new = put_tree(&s, vec![entry(&long_name, EntryMode::Blob, blob)]);
let r = diff_trees(&s, None, Some(new)).unwrap();
assert_eq!(r.entries.len(), 1);
assert_eq!(r.entries[0].path.len(), 255);
assert_eq!(r.entries[0].kind, DiffKind::Added);
}
#[test]
fn both_none_is_empty() {
let (_d, s) = fresh_store();
let r = diff_trees(&s, None, None).unwrap();
assert!(r.is_empty());
}
fn fresh_workdir() -> TempDir {
TempDir::new().unwrap()
}
#[test]
fn status_diff_does_not_fsync() {
use crate::batch::testing::{Ev, RecordingSyncer};
use std::sync::Arc;
let (_sd, mut store) = fresh_store();
let work = fresh_workdir();
std::fs::write(work.path().join("a.txt"), b"some content").unwrap();
std::fs::write(work.path().join("b.txt"), b"other content").unwrap();
let rec = Arc::new(RecordingSyncer::default());
store.set_syncer(rec.clone());
let result = status_diff(&store, None, work.path(), None).unwrap();
assert!(!result.is_empty(), "untracked files must surface");
let evs = rec.events();
assert!(
evs.iter().all(|e| matches!(e, Ev::Rename { .. })),
"status must not emit any flush events; got {evs:?}"
);
}
#[test]
fn status_empty_worktree_no_head() {
let (_sd, store) = fresh_store();
let work = fresh_workdir();
let result = status_diff(&store, None, work.path(), None).unwrap();
assert!(result.is_empty());
}
#[test]
fn status_worktree_equals_head_is_clean() {
let (_sd, store) = fresh_store();
let work = fresh_workdir();
std::fs::write(work.path().join("a.txt"), b"hello").unwrap();
let head_hash = worktree::build_tree(&store, work.path()).unwrap();
let result = status_diff(&store, Some(&head_hash), work.path(), None).unwrap();
assert!(result.is_empty(), "expected clean, got {result:?}");
}
#[cfg(not(unix))]
#[test]
fn status_no_index_ignores_unrepresentable_executable_mode_on_non_unix() {
let (_sd, store) = fresh_store();
let work = fresh_workdir();
std::fs::write(work.path().join("run.sh"), b"#!/bin/sh\n").unwrap();
let h = worktree::hash_file(&store, &work.path().join("run.sh")).unwrap();
let head_hash = put_tree(&store, vec![entry(b"run.sh", EntryMode::Executable, h)]);
let result = status_diff(&store, Some(&head_hash), work.path(), None).unwrap();
assert!(
result.is_empty(),
"non-Unix should not report executable-only noise, got {result:?}"
);
}
#[test]
fn status_added_only() {
let (_sd, store) = fresh_store();
let work = fresh_workdir();
std::fs::write(work.path().join("a.txt"), b"hello").unwrap();
let head_hash = worktree::build_tree(&store, work.path()).unwrap();
std::fs::write(work.path().join("b.txt"), b"world").unwrap();
let result = status_diff(&store, Some(&head_hash), work.path(), None).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].diff.path, "b.txt");
assert_eq!(result[0].diff.kind, DiffKind::Added);
assert_eq!(result[0].staging, StatusStaging::Unstaged);
}
#[test]
fn status_removed_only() {
let (_sd, store) = fresh_store();
let work = fresh_workdir();
std::fs::write(work.path().join("a.txt"), b"hello").unwrap();
std::fs::write(work.path().join("b.txt"), b"world").unwrap();
let head_hash = worktree::build_tree(&store, work.path()).unwrap();
std::fs::remove_file(work.path().join("b.txt")).unwrap();
let result = status_diff(&store, Some(&head_hash), work.path(), None).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].diff.path, "b.txt");
assert_eq!(result[0].diff.kind, DiffKind::Removed);
assert_eq!(result[0].staging, StatusStaging::Unstaged);
}
#[test]
fn status_modified_only() {
let (_sd, store) = fresh_store();
let work = fresh_workdir();
std::fs::write(work.path().join("a.txt"), b"old").unwrap();
let head_hash = worktree::build_tree(&store, work.path()).unwrap();
std::fs::write(work.path().join("a.txt"), b"new").unwrap();
let result = status_diff(&store, Some(&head_hash), work.path(), None).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].diff.path, "a.txt");
assert_eq!(result[0].diff.kind, DiffKind::Modified);
assert_eq!(result[0].staging, StatusStaging::Unstaged);
}
#[test]
fn status_mixed_changes() {
let (_sd, store) = fresh_store();
let work = fresh_workdir();
std::fs::write(work.path().join("a.txt"), b"original").unwrap();
std::fs::write(work.path().join("b.txt"), b"stays").unwrap();
let head_hash = worktree::build_tree(&store, work.path()).unwrap();
std::fs::write(work.path().join("a.txt"), b"changed").unwrap();
std::fs::remove_file(work.path().join("b.txt")).unwrap();
std::fs::write(work.path().join("c.txt"), b"new").unwrap();
let result = status_diff(&store, Some(&head_hash), work.path(), None).unwrap();
assert_eq!(result.len(), 3);
let paths: Vec<&str> = result.iter().map(|e| e.diff.path.as_str()).collect();
assert!(paths.contains(&"a.txt"), "missing a.txt: {paths:?}");
assert!(paths.contains(&"b.txt"), "missing b.txt: {paths:?}");
assert!(paths.contains(&"c.txt"), "missing c.txt: {paths:?}");
}
#[test]
fn status_no_head_shows_all_as_added() {
let (_sd, store) = fresh_store();
let work = fresh_workdir();
std::fs::write(work.path().join("a.txt"), b"aaa").unwrap();
std::fs::write(work.path().join("b.txt"), b"bbb").unwrap();
let result = status_diff(&store, None, work.path(), None).unwrap();
assert_eq!(result.len(), 2);
for e in &result {
assert_eq!(e.diff.kind, DiffKind::Added);
assert_eq!(e.staging, StatusStaging::Unstaged);
}
}
#[test]
fn status_staged_entry_is_classified_staged() {
use crate::index::{EntryStatus, Index, IndexEntry};
let (_sd, store) = fresh_store();
let work = fresh_workdir();
std::fs::write(work.path().join("b.txt"), b"world").unwrap();
let b_hash = worktree::hash_file(&store, &work.path().join("b.txt")).unwrap();
let mut idx = Index::new();
idx.entries.push(IndexEntry {
path: "b.txt".to_string(),
status: EntryStatus::Blob,
object_hash: b_hash,
mtime_ns: 0,
size: 0,
ino: 0,
ctime_ns: 0,
});
let result = status_diff(&store, None, work.path(), Some(&idx)).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].diff.path, "b.txt");
assert_eq!(result[0].staging, StatusStaging::Staged);
}
#[cfg(not(unix))]
#[test]
fn status_ignores_unrepresentable_executable_mode_on_non_unix_worktree() {
use crate::index::{EntryStatus, Index, IndexEntry};
let (_sd, store) = fresh_store();
let work = fresh_workdir();
std::fs::write(work.path().join("run.sh"), b"#!/bin/sh\n").unwrap();
let h = worktree::hash_file(&store, &work.path().join("run.sh")).unwrap();
let mut idx = Index::new();
idx.entries.push(IndexEntry {
path: "run.sh".to_string(),
status: EntryStatus::Executable,
object_hash: h,
mtime_ns: 0,
size: 0,
ino: 0,
ctime_ns: 0,
});
let result = status_diff(&store, None, work.path(), Some(&idx)).unwrap();
assert_eq!(result.len(), 1, "expected only the staged addition");
assert_eq!(result[0].staging, StatusStaging::Staged);
}
#[test]
fn text_patch_modified_line_emits_hunk() {
let old = b"line1\nline2\nline3\n";
let new = b"line1\nCHANGED\nline3\n";
let patch = text_patch(old, new, "f.txt", "f.txt");
assert!(patch.starts_with("--- a/f.txt\n+++ b/f.txt\n"), "{patch}");
assert!(patch.contains("@@ -1,3 +1,3 @@"), "{patch}");
assert!(patch.contains("-line2\n"), "{patch}");
assert!(patch.contains("+CHANGED\n"), "{patch}");
assert!(patch.contains(" line1\n"), "{patch}");
assert!(patch.contains(" line3\n"), "{patch}");
}
#[test]
fn text_patch_pure_addition() {
let old = b"a\nb\n";
let new = b"a\nb\nc\n";
let patch = text_patch(old, new, "f", "f");
assert!(patch.contains("+c\n"), "{patch}");
assert!(
!patch
.lines()
.any(|l| l.starts_with('-') && !l.starts_with("---")),
"should be no deletions: {patch}"
);
}
#[test]
fn text_patch_identical_is_empty() {
let patch = text_patch(b"same\n", b"same\n", "f", "f");
assert!(patch.is_empty(), "{patch}");
}
#[test]
fn text_patch_binary_reports_differ() {
let old = &[0x00, 0xff, 0x01][..];
let new = &[0x00, 0xfe, 0x02][..];
let patch = text_patch(old, new, "bin", "bin");
assert_eq!(patch, "Binary files a/bin and b/bin differ\n");
}
#[test]
fn text_patch_nul_byte_is_binary_like_git() {
let patch = text_patch(b"a\0b\n", b"a\0c\n", "f", "f");
assert_eq!(patch, "Binary files a/f and b/f differ\n");
}
#[test]
fn unified_hunks_non_utf8_without_nul_is_text_like_git() {
let hunks = unified_hunks(b"\xff\n", b"\xfe\n").expect("not binary");
assert_eq!(
hunks,
b"@@ -1 +1 @@\n-\xff\n+\xfe\n".to_vec(),
"got {hunks:?}"
);
}
#[test]
fn hunk_header_omits_count_one_like_git() {
let patch = text_patch(b"old\n", b"new\n", "f", "f");
assert_eq!(
patch, "--- a/f\n+++ b/f\n@@ -1 +1 @@\n-old\n+new\n",
"{patch}"
);
}
#[test]
fn text_patch_no_trailing_newline_marker() {
let old = b"x\ny";
let new = b"x\nz";
let patch = text_patch(old, new, "f", "f");
assert!(patch.contains("\\ No newline at end of file\n"), "{patch}");
}
#[test]
fn text_patch_separate_hunks_for_distant_changes() {
let old = b"a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n";
let new = b"A\nb\nc\nd\ne\nf\ng\nh\ni\nJ\n";
let patch = text_patch(old, new, "f", "f");
let hunk_count = patch.matches("@@ ").count();
assert_eq!(hunk_count, 2, "expected two hunks: {patch}");
}
#[test]
fn text_patch_inserts_compact_to_git_position() {
let patch = text_patch(b"a\na\nb\n", b"a\na\na\nb\n", "f", "f");
assert_eq!(
patch, "--- a/f\n+++ b/f\n@@ -1,3 +1,4 @@\n a\n a\n+a\n b\n",
"{patch}"
);
}
#[test]
fn text_patch_deletes_compact_to_git_position() {
let patch = text_patch(b"a\na\na\nb\n", b"a\na\nb\n", "f", "f");
assert_eq!(
patch, "--- a/f\n+++ b/f\n@@ -1,4 +1,3 @@\n a\n a\n-a\n b\n",
"{patch}"
);
}
#[test]
fn status_partially_staged_entry_emits_both_legs() {
use crate::index::{EntryStatus, Index, IndexEntry};
let (_sd, store) = fresh_store();
let work = fresh_workdir();
std::fs::write(work.path().join("b.txt"), b"v1").unwrap();
let b_v1_hash = worktree::hash_file(&store, &work.path().join("b.txt")).unwrap();
std::fs::write(work.path().join("b.txt"), b"v2").unwrap();
let mut idx = Index::new();
idx.entries.push(IndexEntry {
path: "b.txt".to_string(),
status: EntryStatus::Blob,
object_hash: b_v1_hash,
mtime_ns: 0,
size: 0,
ino: 0,
ctime_ns: 0,
});
let result = status_diff(&store, None, work.path(), Some(&idx)).unwrap();
assert_eq!(result.len(), 2, "expected staged + unstaged entries");
let stagings: Vec<_> = result.iter().map(|e| e.staging).collect();
assert!(stagings.contains(&StatusStaging::Staged));
assert!(stagings.contains(&StatusStaging::Unstaged));
assert!(result.iter().all(|e| e.diff.path == "b.txt"));
}
#[test]
fn enumerate_hunks_binary_returns_none() {
assert!(enumerate_hunks(b"a\0b", b"c").is_none());
assert!(enumerate_hunks(b"text\n", b"with\0nul").is_none());
}
#[test]
fn enumerate_hunks_identical_is_empty() {
assert_eq!(enumerate_hunks(b"a\nb\n", b"a\nb\n"), Some(vec![]));
}
const HUNK2_OLD: &[u8] = b"l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\nl11\nl12\nl13\nl14\n";
const HUNK2_NEW: &[u8] = b"l1\nL2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\nl11\nl12\nL13\nl14\n";
#[test]
fn apply_all_hunks_reproduces_new_apply_none_reproduces_base() {
let hunks = enumerate_hunks(HUNK2_OLD, HUNK2_NEW).unwrap();
assert_eq!(hunks.len(), 2, "expected two distinct hunks");
let all: Vec<usize> = (0..hunks.len()).collect();
assert_eq!(
apply_hunks_subset(HUNK2_OLD, &hunks, &all),
HUNK2_NEW,
"apply all == new"
);
assert_eq!(
apply_hunks_subset(HUNK2_OLD, &hunks, &[]),
HUNK2_OLD,
"apply none == old"
);
}
#[test]
fn apply_single_hunk_picks_only_that_region() {
let hunks = enumerate_hunks(HUNK2_OLD, HUNK2_NEW).unwrap();
let staged = apply_hunks_subset(HUNK2_OLD, &hunks, &[0]);
assert_eq!(
staged, b"l1\nL2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\nl11\nl12\nl13\nl14\n",
"only the first hunk applied"
);
let staged = apply_hunks_subset(HUNK2_OLD, &hunks, &[1]);
assert_eq!(
staged, b"l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\nl11\nl12\nL13\nl14\n",
"only the second hunk applied"
);
}
#[test]
fn apply_hunks_into_empty_base_adds_lines() {
let old = b"";
let new = b"alpha\nbeta\n";
let hunks = enumerate_hunks(old, new).unwrap();
assert_eq!(apply_hunks_subset(old, &hunks, &[0]), new);
assert_eq!(apply_hunks_subset(old, &hunks, &[]), old);
}
#[test]
fn apply_hunks_preserves_missing_eof_newline() {
let old = b"a\nb\nc";
let new = b"a\nB\nc"; let hunks = enumerate_hunks(old, new).unwrap();
let staged = apply_hunks_subset(old, &hunks, &[0]);
assert_eq!(staged, new, "no trailing newline must be preserved");
}
#[test]
fn merge3_identical_sides_merge_trivially() {
let x = b"a\nb\n";
assert_eq!(merge_blob_3way(b"base\n", x, x), Some(x.to_vec()));
}
#[test]
fn merge3_disjoint_line_edits_auto_merge() {
let base = b"a\nb\nc\nd\ne\n";
let ours = b"A\nb\nc\nd\ne\n"; let theirs = b"a\nb\nc\nd\nE\n"; assert_eq!(
merge_blob_3way(base, ours, theirs),
Some(b"A\nb\nc\nd\nE\n".to_vec())
);
}
#[test]
fn merge3_adjacent_line_edits_auto_merge() {
let base = b"x\ny\n";
let ours = b"X\ny\n"; let theirs = b"x\nY\n"; assert_eq!(
merge_blob_3way(base, ours, theirs),
Some(b"X\nY\n".to_vec())
);
}
#[test]
fn merge3_overlapping_line_edits_conflict() {
let base = b"a\nb\nc\n";
let ours = b"A\nb\nc\n"; let theirs = b"Z\nb\nc\n"; assert_eq!(merge_blob_3way(base, ours, theirs), None);
}
#[test]
fn merge3_one_side_only_takes_that_side() {
let base = b"a\nb\nc\n";
let ours = b"a\nB\nc\n";
assert_eq!(merge_blob_3way(base, ours, base), Some(ours.to_vec()));
}
#[test]
fn merge3_disjoint_insertions_auto_merge() {
let base = b"a\nb\n";
let ours = b"a\nX\nb\n"; let theirs = b"a\nb\nY\n"; assert_eq!(
merge_blob_3way(base, ours, theirs),
Some(b"a\nX\nb\nY\n".to_vec())
);
}
#[test]
fn merge3_coincident_insertions_conflict() {
let base = b"a\nb\n";
let ours = b"a\nX\nb\n"; let theirs = b"a\nY\nb\n";
assert_eq!(merge_blob_3way(base, ours, theirs), None);
}
#[test]
fn merge3_binary_side_conflicts() {
assert_eq!(merge_blob_3way(b"a\nb\n", b"a\0\nb\n", b"a\nb\nc\n"), None);
}
#[test]
fn ignore_all_space_treats_whitespace_only_change_as_unchanged() {
let old = b"head\nfoo(a, b)\ntail\n";
let new = b"head\nfoo(a,b)\ntail\n";
let exact = unified_hunks_opts(old, new, PATCH_CONTEXT, WhitespaceMode::Exact).unwrap();
assert!(!exact.is_empty(), "exact mode should see the change");
let ws =
unified_hunks_opts(old, new, PATCH_CONTEXT, WhitespaceMode::IgnoreAllSpace).unwrap();
assert!(
ws.is_empty(),
"-w should ignore a line that only gained/lost whitespace: {ws:?}"
);
}
#[test]
fn ignore_space_change_does_not_ignore_whitespace_appearing_from_nothing() {
let old = b"foo(a, b)\n";
let new = b"foo(a,b)\n";
let hunks =
unified_hunks_opts(old, new, PATCH_CONTEXT, WhitespaceMode::IgnoreSpaceChange).unwrap();
assert!(
!hunks.is_empty(),
"-b should still flag whitespace appearing where there was none"
);
}
#[test]
fn ignore_space_change_ignores_differing_amounts() {
let old = b"foo(a, b)\n";
let new = b"foo(a, b)\n";
let hunks =
unified_hunks_opts(old, new, PATCH_CONTEXT, WhitespaceMode::IgnoreSpaceChange).unwrap();
assert!(
hunks.is_empty(),
"-b should ignore a pure whitespace-amount change: {hunks:?}"
);
let ws_hunks =
unified_hunks_opts(old, new, PATCH_CONTEXT, WhitespaceMode::IgnoreAllSpace).unwrap();
assert!(ws_hunks.is_empty());
}
#[test]
fn whitespace_mode_never_changes_the_rendered_line_bytes() {
let old = b"foo(x, y)\npad1\npad2\npad3\npad4\npad5\nCHANGED-OLD\npad6\n";
let new = b"foo(x, y)\npad1\npad2\npad3\npad4\npad5\nCHANGED-NEW\npad6\n";
let hunks =
unified_hunks_opts(old, new, PATCH_CONTEXT, WhitespaceMode::IgnoreSpaceChange).unwrap();
let text = String::from_utf8(hunks).unwrap();
assert!(text.contains("-CHANGED-OLD\n"), "{text}");
assert!(text.contains("+CHANGED-NEW\n"), "{text}");
assert!(!text.contains("foo(x"), "{text}");
}
#[test]
fn unified_hunks_opts_default_matches_unified_hunks() {
let old = b"a\nb\nc\n";
let new = b"a\nB\nc\n";
assert_eq!(
unified_hunks(old, new),
unified_hunks_opts(old, new, PATCH_CONTEXT, WhitespaceMode::Exact)
);
}
fn ten_lines_changing_the_fifth() -> (Vec<u8>, Vec<u8>) {
let lines: Vec<String> = (1..=10).map(|n| format!("l{n}")).collect();
let mut old = lines.join("\n");
old.push('\n');
let mut changed = lines;
changed[4] = "l5-changed".to_string();
let mut new = changed.join("\n");
new.push('\n');
(old.into_bytes(), new.into_bytes())
}
fn context_line_count(hunks: &[u8]) -> usize {
String::from_utf8_lossy(hunks)
.lines()
.skip_while(|l| !l.starts_with("@@"))
.skip(1)
.filter(|l| l.starts_with(' '))
.count()
}
#[test]
fn context_zero_shows_no_surrounding_lines() {
let (old, new) = ten_lines_changing_the_fifth();
let hunks = unified_hunks_opts(&old, &new, 0, WhitespaceMode::Exact).unwrap();
assert_eq!(context_line_count(&hunks), 0);
let text = String::from_utf8(hunks).unwrap();
assert!(text.contains("-l5\n"), "{text}");
assert!(text.contains("+l5-changed\n"), "{text}");
}
#[test]
fn context_one_shows_one_line_each_side() {
let (old, new) = ten_lines_changing_the_fifth();
let hunks = unified_hunks_opts(&old, &new, 1, WhitespaceMode::Exact).unwrap();
assert_eq!(context_line_count(&hunks), 2);
}
#[test]
fn context_default_matches_three() {
let (old, new) = ten_lines_changing_the_fifth();
let hunks =
unified_hunks_opts(&old, &new, DEFAULT_CONTEXT_LINES, WhitespaceMode::Exact).unwrap();
assert_eq!(DEFAULT_CONTEXT_LINES, 3);
assert_eq!(context_line_count(&hunks), 6);
}
}