use std::collections::HashSet;
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::config::ConfigSet;
use crate::diff_indent_heuristic;
use crate::diff_rename_parallel::{
diff_rename_parallel_options, inexact_matrix_exceeds_limit, parallel_load_optional_blobs,
parallel_map_indices, parse_rename_limit,
};
use crate::error::{Error, Result};
use crate::index::{CacheTreeNode, Index, IndexEntry, MODE_SYMLINK};
use crate::objects::{parse_commit, CommitData, ObjectId, ObjectKind, TreeEntry, TreeRefIter};
use crate::odb::Odb;
use crate::parallel::ThreadPolicy;
use crate::repo::Repository;
use crate::userdiff::FuncnameMatcher;
use crate::worktree_rules::WorktreeRules;
use super::unified_emit::{
emit_unified_body_from_edits, line_diff_token, line_ending_only_edits, EditSpan, LineSide,
SourceLine, UnifiedEmitOptions,
};
fn line_compare_key(line: &SourceLine, ws: super::WhitespaceMode) -> String {
if ws.normalizes_line_compare() {
ws.normalize_line_for_compare(&line.body).into_owned()
} else {
line_diff_token(line)
}
}
fn line_compare_keys(side: &LineSide, ws: super::WhitespaceMode) -> Vec<String> {
side.lines.iter().map(|l| line_compare_key(l, ws)).collect()
}
#[path = "../structured_diff.rs"]
pub mod structured;
pub(crate) fn imara_unified_hunk_slices(body: &str) -> Vec<&str> {
let mut starts: Vec<usize> = Vec::new();
if body.starts_with("@@") {
starts.push(0);
}
for (idx, _) in body.match_indices("\n@@ ") {
starts.push(idx + 1);
}
starts.push(body.len());
starts.windows(2).map(|w| &body[w[0]..w[1]]).collect()
}
pub(crate) fn histogram_compacted_line_ops(
old_content: &str,
new_content: &str,
indent_heuristic: bool,
ws: super::WhitespaceMode,
) -> Vec<similar::DiffOp> {
let before = LineSide::from_text(old_content);
let after = LineSide::from_text(new_content);
let old_lines: Vec<&str> = before.lines.iter().map(|l| l.body.as_str()).collect();
let new_lines: Vec<&str> = after.lines.iter().map(|l| l.body.as_str()).collect();
let old_len = old_lines.len();
let new_len = new_lines.len();
let old_compare_owned = line_compare_keys(&before, ws);
let new_compare_owned = line_compare_keys(&after, ws);
let old_compare: Vec<&str> = old_compare_owned.iter().map(String::as_str).collect();
let new_compare: Vec<&str> = new_compare_owned.iter().map(String::as_str).collect();
let (changed1, changed2) = git_xdiff::histogram_changed_flags(&old_compare, &new_compare);
if !changed1.iter().any(|c| *c) && !changed2.iter().any(|c| *c) {
return Vec::new();
}
let raw = changed_flags_to_ops(&changed1, &changed2, old_len, new_len);
diff_indent_heuristic::apply_change_compact_to_ops_with_ws_histogram(
&raw,
&old_lines,
&new_lines,
indent_heuristic,
ws,
true,
)
}
pub(crate) fn histogram_unified_body_raw(
old_content: &str,
new_content: &str,
context_lines: usize,
inter_hunk_context: usize,
indent_heuristic: bool,
ws: super::WhitespaceMode,
) -> String {
let before_side = LineSide::from_text(old_content);
let after_side = LineSide::from_text(new_content);
let ops = histogram_compacted_line_ops(old_content, new_content, indent_heuristic, ws);
let mut edits = diff_ops_to_edit_spans(&ops);
if edits.is_empty() {
if old_content != new_content && !ws.normalizes_line_compare() {
edits = line_ending_only_edits(&before_side, &after_side);
}
if edits.is_empty() {
return String::new();
}
}
emit_unified_body_from_edits(
&before_side,
&after_side,
&edits,
UnifiedEmitOptions {
context_lines,
inter_hunk_context,
function_context: false,
},
None,
)
}
#[allow(clippy::too_many_arguments)]
fn diff_ops_to_ignorable_edit_spans<F>(
ops: &[similar::DiffOp],
old_lines: &[&str],
new_lines: &[&str],
is_ignorable_change: F,
) -> Vec<EditSpan>
where
F: Fn(&[&str], &[&str]) -> bool,
{
use similar::DiffOp;
let mut out = Vec::new();
for op in ops {
match *op {
DiffOp::Equal { .. } => {}
DiffOp::Delete {
old_index,
old_len,
new_index,
} => {
let removed = &old_lines[old_index..old_index + old_len];
out.push(EditSpan {
before_start: old_index as i64,
before_len: old_len as i64,
after_start: new_index as i64,
after_len: 0,
skip_in_output: is_ignorable_change(removed, &[]),
});
}
DiffOp::Insert {
old_index,
new_index,
new_len,
} => {
let added = &new_lines[new_index..new_index + new_len];
out.push(EditSpan {
before_start: old_index as i64,
before_len: 0,
after_start: new_index as i64,
after_len: new_len as i64,
skip_in_output: is_ignorable_change(&[], added),
});
}
DiffOp::Replace {
old_index,
old_len,
new_index,
new_len,
} => {
let removed = &old_lines[old_index..old_index + old_len];
let added = &new_lines[new_index..new_index + new_len];
out.push(EditSpan {
before_start: old_index as i64,
before_len: old_len as i64,
after_start: new_index as i64,
after_len: new_len as i64,
skip_in_output: is_ignorable_change(removed, added),
});
}
}
}
out
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn histogram_unified_body_ignore_fc<F>(
old_content: &str,
new_content: &str,
context_lines: usize,
inter_hunk_context: usize,
function_context: bool,
funcname_matcher: Option<&FuncnameMatcher>,
indent_heuristic: bool,
ws: super::WhitespaceMode,
is_ignorable_change: F,
) -> String
where
F: Fn(&[&str], &[&str]) -> bool,
{
let before = LineSide::from_text(old_content);
let after = LineSide::from_text(new_content);
let old_lines: Vec<&str> = before.lines.iter().map(|l| l.body.as_str()).collect();
let new_lines: Vec<&str> = after.lines.iter().map(|l| l.body.as_str()).collect();
let ops = histogram_compacted_line_ops(old_content, new_content, indent_heuristic, ws);
if ops.is_empty() {
return String::new();
}
let edits = diff_ops_to_ignorable_edit_spans(&ops, &old_lines, &new_lines, is_ignorable_change);
emit_unified_body_from_edits(
&before,
&after,
&edits,
UnifiedEmitOptions {
context_lines,
inter_hunk_context,
function_context,
},
funcname_matcher,
)
}
pub(crate) fn git_xdiff_compacted_line_ops(
old_compare: &[&str],
new_compare: &[&str],
old_lines: &[&str],
new_lines: &[&str],
indent_heuristic: bool,
need_min: bool,
) -> Vec<similar::DiffOp> {
use crate::line_interner::LineInterner;
let mut interner = LineInterner::default();
let ids1 = interner.intern_all(old_compare);
let ids2 = interner.intern_all(new_compare);
let (changed1, changed2) = git_xdiff::changed_flags(&ids1, &ids2, need_min);
let raw = changed_flags_to_ops(&changed1, &changed2, old_lines.len(), new_lines.len());
diff_indent_heuristic::apply_change_compact_to_ops(&raw, old_lines, new_lines, indent_heuristic)
}
fn diff_ops_to_edit_spans(ops: &[similar::DiffOp]) -> Vec<EditSpan> {
use similar::DiffOp;
let mut out = Vec::new();
for op in ops {
match *op {
DiffOp::Equal { .. } => {}
DiffOp::Delete {
old_index,
old_len,
new_index,
} => out.push(EditSpan {
before_start: old_index as i64,
before_len: old_len as i64,
after_start: new_index as i64,
after_len: 0,
skip_in_output: false,
}),
DiffOp::Insert {
old_index,
new_index,
new_len,
} => out.push(EditSpan {
before_start: old_index as i64,
before_len: 0,
after_start: new_index as i64,
after_len: new_len as i64,
skip_in_output: false,
}),
DiffOp::Replace {
old_index,
old_len,
new_index,
new_len,
} => out.push(EditSpan {
before_start: old_index as i64,
before_len: old_len as i64,
after_start: new_index as i64,
after_len: new_len as i64,
skip_in_output: false,
}),
}
}
out
}
fn git_xdiff_unified_body_raw(
old_content: &str,
new_content: &str,
context_lines: usize,
inter_hunk_context: usize,
indent_heuristic: bool,
need_min: bool,
ws: super::WhitespaceMode,
) -> String {
git_xdiff_unified_body_raw_with_ignorable(
old_content,
new_content,
context_lines,
inter_hunk_context,
indent_heuristic,
need_min,
ws,
None::<fn(&[&str], &[&str]) -> bool>,
)
}
#[allow(clippy::too_many_arguments)]
fn git_xdiff_unified_body_raw_with_ignorable<F>(
old_content: &str,
new_content: &str,
context_lines: usize,
inter_hunk_context: usize,
indent_heuristic: bool,
need_min: bool,
ws: super::WhitespaceMode,
is_ignorable_change: Option<F>,
) -> String
where
F: Fn(&[&str], &[&str]) -> bool,
{
let before = LineSide::from_text(old_content);
let after = LineSide::from_text(new_content);
if before.is_empty() && after.is_empty() {
return String::new();
}
let old_refs: Vec<&str> = before.lines.iter().map(|l| l.body.as_str()).collect();
let new_refs: Vec<&str> = after.lines.iter().map(|l| l.body.as_str()).collect();
let old_compare_owned = line_compare_keys(&before, ws);
let new_compare_owned = line_compare_keys(&after, ws);
let old_compare: Vec<&str> = old_compare_owned.iter().map(String::as_str).collect();
let new_compare: Vec<&str> = new_compare_owned.iter().map(String::as_str).collect();
let ops = git_xdiff_compacted_line_ops(
&old_compare,
&new_compare,
&old_refs,
&new_refs,
indent_heuristic,
need_min,
);
let mut edits = if let Some(is_ignorable) = is_ignorable_change {
diff_ops_to_ignorable_edit_spans(&ops, &old_refs, &new_refs, is_ignorable)
} else {
diff_ops_to_edit_spans(&ops)
};
if edits.is_empty() {
if old_content != new_content && !ws.normalizes_line_compare() {
edits = line_ending_only_edits(&before, &after);
}
if edits.is_empty() {
return String::new();
}
}
emit_unified_body_from_edits(
&before,
&after,
&edits,
UnifiedEmitOptions {
context_lines,
inter_hunk_context,
function_context: false,
},
None,
)
}
#[allow(clippy::too_many_arguments)]
fn git_patience_unified_body_raw_with_ignorable<F>(
old_content: &str,
new_content: &str,
context_lines: usize,
inter_hunk_context: usize,
indent_heuristic: bool,
ws: super::WhitespaceMode,
is_ignorable_change: Option<F>,
) -> String
where
F: Fn(&[&str], &[&str]) -> bool,
{
let before = LineSide::from_text(old_content);
let after = LineSide::from_text(new_content);
if before.is_empty() && after.is_empty() {
return String::new();
}
let old_refs: Vec<&str> = before.lines.iter().map(|l| l.body.as_str()).collect();
let new_refs: Vec<&str> = after.lines.iter().map(|l| l.body.as_str()).collect();
let old_compare_owned = line_compare_keys(&before, ws);
let new_compare_owned = line_compare_keys(&after, ws);
let old_compare: Vec<&str> = old_compare_owned.iter().map(String::as_str).collect();
let new_compare: Vec<&str> = new_compare_owned.iter().map(String::as_str).collect();
let (changed1, changed2) = git_xdiff::patience_changed_flags(&old_compare, &new_compare);
let raw = changed_flags_to_ops(&changed1, &changed2, old_refs.len(), new_refs.len());
let ops = if ws.normalizes_line_compare() {
diff_indent_heuristic::apply_change_compact_to_ops_with_ws(
&raw,
&old_refs,
&new_refs,
indent_heuristic,
ws,
)
} else {
diff_indent_heuristic::apply_change_compact_to_ops(
&raw,
&old_refs,
&new_refs,
indent_heuristic,
)
};
let mut edits = if let Some(is_ignorable) = is_ignorable_change {
diff_ops_to_ignorable_edit_spans(&ops, &old_refs, &new_refs, is_ignorable)
} else {
diff_ops_to_edit_spans(&ops)
};
if edits.is_empty() {
if old_content != new_content && !ws.normalizes_line_compare() {
edits = line_ending_only_edits(&before, &after);
}
if edits.is_empty() {
return String::new();
}
}
emit_unified_body_from_edits(
&before,
&after,
&edits,
UnifiedEmitOptions {
context_lines,
inter_hunk_context,
function_context: false,
},
None,
)
}
fn blank_line_ignorable(ws: super::WhitespaceMode) -> impl Fn(&[&str], &[&str]) -> bool + Copy {
move |removed: &[&str], added: &[&str]| {
ws.ignore_blank_lines
&& removed.iter().all(|l| crate::ws::ws_blank_line(l))
&& added.iter().all(|l| crate::ws::ws_blank_line(l))
}
}
pub(crate) fn unified_hunk_body_for_diff_options(
old_content: &str,
new_content: &str,
options: &crate::diff::options::DiffOptions,
) -> String {
use crate::diff::options::DiffAlgorithm;
let ws = options.whitespace;
if ws.is_active() {
let blank_only = blank_line_ignorable(ws);
if ws.normalizes_line_compare() {
let ignorable = ws.ignore_blank_lines.then_some(blank_only);
return match options.algorithm {
DiffAlgorithm::Myers => git_xdiff_unified_body_raw_with_ignorable(
old_content,
new_content,
options.context_lines,
options.inter_hunk_context,
options.indent_heuristic,
false,
ws,
ignorable,
),
DiffAlgorithm::Minimal => git_xdiff_unified_body_raw_with_ignorable(
old_content,
new_content,
options.context_lines,
options.inter_hunk_context,
options.indent_heuristic,
true,
ws,
ignorable,
),
DiffAlgorithm::Patience => git_patience_unified_body_raw_with_ignorable(
old_content,
new_content,
options.context_lines,
options.inter_hunk_context,
options.indent_heuristic,
ws,
ignorable,
),
DiffAlgorithm::Histogram => {
if ws.ignore_blank_lines {
histogram_unified_body_ignore_fc(
old_content,
new_content,
options.context_lines,
options.inter_hunk_context,
false,
None,
options.indent_heuristic,
ws,
blank_only,
)
} else {
histogram_unified_body_raw(
old_content,
new_content,
options.context_lines,
options.inter_hunk_context,
options.indent_heuristic,
ws,
)
}
}
};
}
if ws.ignore_blank_lines {
return match options.algorithm {
DiffAlgorithm::Myers => git_xdiff_unified_body_raw_with_ignorable(
old_content,
new_content,
options.context_lines,
options.inter_hunk_context,
options.indent_heuristic,
false,
ws,
Some(blank_only),
),
DiffAlgorithm::Minimal => git_xdiff_unified_body_raw_with_ignorable(
old_content,
new_content,
options.context_lines,
options.inter_hunk_context,
options.indent_heuristic,
true,
ws,
Some(blank_only),
),
DiffAlgorithm::Patience => git_patience_unified_body_raw_with_ignorable(
old_content,
new_content,
options.context_lines,
options.inter_hunk_context,
options.indent_heuristic,
ws,
Some(blank_only),
),
DiffAlgorithm::Histogram => histogram_unified_body_ignore_fc(
old_content,
new_content,
options.context_lines,
options.inter_hunk_context,
false,
None,
options.indent_heuristic,
ws,
blank_only,
),
};
}
}
match options.algorithm {
DiffAlgorithm::Myers => git_xdiff_unified_body_raw(
old_content,
new_content,
options.context_lines,
options.inter_hunk_context,
options.indent_heuristic,
false,
ws,
),
DiffAlgorithm::Minimal => git_xdiff_unified_body_raw(
old_content,
new_content,
options.context_lines,
options.inter_hunk_context,
options.indent_heuristic,
true,
ws,
),
DiffAlgorithm::Patience => git_patience_unified_body_raw(
old_content,
new_content,
options.context_lines,
options.inter_hunk_context,
options.indent_heuristic,
),
DiffAlgorithm::Histogram => histogram_unified_body_raw(
old_content,
new_content,
options.context_lines,
options.inter_hunk_context,
options.indent_heuristic,
ws,
),
}
}
fn git_patience_unified_body_raw(
old_content: &str,
new_content: &str,
context_lines: usize,
inter_hunk_context: usize,
indent_heuristic: bool,
) -> String {
let before = LineSide::from_text(old_content);
let after = LineSide::from_text(new_content);
if before.is_empty() && after.is_empty() {
return String::new();
}
let old_refs: Vec<&str> = before.lines.iter().map(|l| l.body.as_str()).collect();
let new_refs: Vec<&str> = after.lines.iter().map(|l| l.body.as_str()).collect();
let old_compare_owned = line_compare_keys(&before, super::WhitespaceMode::default());
let new_compare_owned = line_compare_keys(&after, super::WhitespaceMode::default());
let old_compare: Vec<&str> = old_compare_owned.iter().map(String::as_str).collect();
let new_compare: Vec<&str> = new_compare_owned.iter().map(String::as_str).collect();
let (changed1, changed2) = git_xdiff::patience_changed_flags(&old_compare, &new_compare);
let raw = changed_flags_to_ops(&changed1, &changed2, old_refs.len(), new_refs.len());
let ops = diff_indent_heuristic::apply_change_compact_to_ops(
&raw,
&old_refs,
&new_refs,
indent_heuristic,
);
let mut edits = diff_ops_to_edit_spans(&ops);
if edits.is_empty() {
if old_content != new_content {
edits = line_ending_only_edits(&before, &after);
}
if edits.is_empty() {
return String::new();
}
}
emit_unified_body_from_edits(
&before,
&after,
&edits,
UnifiedEmitOptions {
context_lines,
inter_hunk_context,
function_context: false,
},
None,
)
}
pub(crate) fn unified_hunk_header_counts(
header_line: &str,
) -> Option<(usize, usize, usize, usize)> {
let rest = header_line.strip_prefix("@@ ")?;
let (old_part, rest2) = rest.split_once(" +")?;
let old_tok = old_part.strip_prefix('-')?.split(' ').next()?;
let new_tok = rest2.split(" @@").next()?.split(' ').next()?;
fn parse_side(spec: &str) -> (usize, usize) {
if let Some((start, count)) = spec.split_once(',') {
(start.parse().unwrap_or(0), count.parse().unwrap_or(0))
} else {
(spec.parse().unwrap_or(0), 1)
}
}
let (os, ol) = parse_side(old_tok);
let (ns, nl) = parse_side(new_tok);
Some((os, ol, ns, nl))
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn write_unified_diff_from_diff_options<W: std::io::Write>(
out: &mut W,
old_content: &str,
new_content: &str,
old_path: &str,
new_path: &str,
src_prefix: &str,
dst_prefix: &str,
funcname_matcher: Option<&FuncnameMatcher>,
options: &crate::diff::options::DiffOptions,
quote_path_fully: bool,
) -> Result<()> {
use crate::quote_path::format_diff_path_with_prefix;
if old_path == "/dev/null" {
out.write_all(b"--- /dev/null\n")?;
} else if src_prefix.is_empty() {
writeln!(out, "--- {old_path}")?;
} else {
out.write_all(b"--- ")?;
out.write_all(
format_diff_path_with_prefix(src_prefix, old_path, quote_path_fully).as_bytes(),
)?;
out.write_all(b"\n")?;
}
if new_path == "/dev/null" {
out.write_all(b"+++ /dev/null\n")?;
} else if dst_prefix.is_empty() {
writeln!(out, "+++ {new_path}")?;
} else {
out.write_all(b"+++ ")?;
out.write_all(
format_diff_path_with_prefix(dst_prefix, new_path, quote_path_fully).as_bytes(),
)?;
out.write_all(b"\n")?;
}
let body = unified_hunk_body_for_diff_options(old_content, new_content, options);
if body.is_empty() {
return Ok(());
}
let old_lines: Vec<&str> = old_content.lines().collect();
for hunk_str in imara_unified_hunk_slices(&body) {
if hunk_str.is_empty() {
continue;
}
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, funcname_matcher, Some(old_path))
{
out.write_all(header_line.as_bytes())?;
out.write_all(b" ")?;
out.write_all(func_ctx.as_bytes())?;
out.write_all(rest.as_bytes())?;
} else {
out.write_all(hunk_str.as_bytes())?;
}
} else {
out.write_all(hunk_str.as_bytes())?;
}
}
Ok(())
}
#[expect(clippy::too_many_arguments)]
fn unified_diff_git_myers_with_prefix_and_funcname(
old_content: &str,
new_content: &str,
old_path: &str,
new_path: &str,
context_lines: usize,
inter_hunk_context: usize,
src_prefix: &str,
dst_prefix: &str,
funcname_matcher: Option<&FuncnameMatcher>,
indent_heuristic: bool,
quote_path_fully: bool,
) -> String {
use crate::quote_path::format_diff_path_with_prefix;
let body = git_xdiff_unified_body_raw(
old_content,
new_content,
context_lines,
inter_hunk_context,
indent_heuristic,
false,
super::WhitespaceMode::default(),
);
let mut output = String::new();
if old_path == "/dev/null" {
output.push_str("--- /dev/null\n");
} else if src_prefix.is_empty() {
output.push_str(&format!("--- {old_path}\n"));
} else {
output.push_str("--- ");
output.push_str(&format_diff_path_with_prefix(
src_prefix,
old_path,
quote_path_fully,
));
output.push('\n');
}
if new_path == "/dev/null" {
output.push_str("+++ /dev/null\n");
} else if dst_prefix.is_empty() {
output.push_str(&format!("+++ {new_path}\n"));
} else {
output.push_str("+++ ");
output.push_str(&format_diff_path_with_prefix(
dst_prefix,
new_path,
quote_path_fully,
));
output.push('\n');
}
let old_lines: Vec<&str> = old_content.lines().collect();
for hunk_str in imara_unified_hunk_slices(&body) {
if hunk_str.is_empty() {
continue;
}
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, funcname_matcher, Some(old_path))
{
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);
}
}
annotate_unified_diff_no_newline_at_eof(&output, old_content, new_content)
}
#[expect(clippy::too_many_arguments)]
fn unified_diff_git_minimal_with_prefix_and_funcname(
old_content: &str,
new_content: &str,
old_path: &str,
new_path: &str,
context_lines: usize,
inter_hunk_context: usize,
src_prefix: &str,
dst_prefix: &str,
funcname_matcher: Option<&FuncnameMatcher>,
indent_heuristic: bool,
quote_path_fully: bool,
) -> String {
use crate::quote_path::format_diff_path_with_prefix;
let body = git_xdiff_unified_body_raw(
old_content,
new_content,
context_lines,
inter_hunk_context,
indent_heuristic,
true,
super::WhitespaceMode::default(),
);
let mut output = String::new();
if old_path == "/dev/null" {
output.push_str("--- /dev/null\n");
} else if src_prefix.is_empty() {
output.push_str(&format!("--- {old_path}\n"));
} else {
output.push_str("--- ");
output.push_str(&format_diff_path_with_prefix(
src_prefix,
old_path,
quote_path_fully,
));
output.push('\n');
}
if new_path == "/dev/null" {
output.push_str("+++ /dev/null\n");
} else if dst_prefix.is_empty() {
output.push_str(&format!("+++ {new_path}\n"));
} else {
output.push_str("+++ ");
output.push_str(&format_diff_path_with_prefix(
dst_prefix,
new_path,
quote_path_fully,
));
output.push('\n');
}
let old_lines: Vec<&str> = old_content.lines().collect();
for hunk_str in imara_unified_hunk_slices(&body) {
if hunk_str.is_empty() {
continue;
}
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, funcname_matcher, Some(old_path))
{
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);
}
}
annotate_unified_diff_no_newline_at_eof(&output, old_content, new_content)
}
#[expect(clippy::too_many_arguments)]
fn unified_diff_git_patience_with_prefix_and_funcname(
old_content: &str,
new_content: &str,
old_path: &str,
new_path: &str,
context_lines: usize,
inter_hunk_context: usize,
src_prefix: &str,
dst_prefix: &str,
funcname_matcher: Option<&FuncnameMatcher>,
indent_heuristic: bool,
quote_path_fully: bool,
) -> String {
use crate::quote_path::format_diff_path_with_prefix;
let body = git_patience_unified_body_raw(
old_content,
new_content,
context_lines,
inter_hunk_context,
indent_heuristic,
);
let mut output = String::new();
if old_path == "/dev/null" {
output.push_str("--- /dev/null\n");
} else if src_prefix.is_empty() {
output.push_str(&format!("--- {old_path}\n"));
} else {
output.push_str("--- ");
output.push_str(&format_diff_path_with_prefix(
src_prefix,
old_path,
quote_path_fully,
));
output.push('\n');
}
if new_path == "/dev/null" {
output.push_str("+++ /dev/null\n");
} else if dst_prefix.is_empty() {
output.push_str(&format!("+++ {new_path}\n"));
} else {
output.push_str("+++ ");
output.push_str(&format_diff_path_with_prefix(
dst_prefix,
new_path,
quote_path_fully,
));
output.push('\n');
}
let old_lines: Vec<&str> = old_content.lines().collect();
for hunk_str in imara_unified_hunk_slices(&body) {
if hunk_str.is_empty() {
continue;
}
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, funcname_matcher, Some(old_path))
{
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);
}
}
annotate_unified_diff_no_newline_at_eof(&output, old_content, new_content)
}
#[must_use]
pub fn unified_diff_histogram_hunks_only(
old_content: &str,
new_content: &str,
context_lines: usize,
inter_hunk_context: usize,
indent_heuristic: bool,
) -> String {
histogram_unified_body_raw(
old_content,
new_content,
context_lines,
inter_hunk_context,
indent_heuristic,
super::WhitespaceMode::default(),
)
}
#[expect(clippy::too_many_arguments)]
#[must_use]
pub fn unified_diff_histogram_with_prefix_and_funcname(
old_content: &str,
new_content: &str,
old_path: &str,
new_path: &str,
context_lines: usize,
inter_hunk_context: usize,
src_prefix: &str,
dst_prefix: &str,
funcname_matcher: Option<&FuncnameMatcher>,
indent_heuristic: bool,
quote_path_fully: bool,
) -> String {
unified_diff_histogram_with_prefix_and_funcname_indent(
old_content,
new_content,
old_path,
new_path,
context_lines,
inter_hunk_context,
src_prefix,
dst_prefix,
funcname_matcher,
indent_heuristic,
quote_path_fully,
)
}
#[expect(clippy::too_many_arguments)]
fn unified_diff_histogram_with_prefix_and_funcname_indent(
old_content: &str,
new_content: &str,
old_path: &str,
new_path: &str,
context_lines: usize,
inter_hunk_context: usize,
src_prefix: &str,
dst_prefix: &str,
funcname_matcher: Option<&FuncnameMatcher>,
indent_heuristic: bool,
quote_path_fully: bool,
) -> String {
use crate::quote_path::format_diff_path_with_prefix;
let body = histogram_unified_body_raw(
old_content,
new_content,
context_lines,
inter_hunk_context,
indent_heuristic,
super::WhitespaceMode::default(),
);
let mut output = String::new();
if old_path == "/dev/null" {
output.push_str("--- /dev/null\n");
} else if src_prefix.is_empty() {
output.push_str(&format!("--- {old_path}\n"));
} else {
output.push_str("--- ");
output.push_str(&format_diff_path_with_prefix(
src_prefix,
old_path,
quote_path_fully,
));
output.push('\n');
}
if new_path == "/dev/null" {
output.push_str("+++ /dev/null\n");
} else if dst_prefix.is_empty() {
output.push_str(&format!("+++ {new_path}\n"));
} else {
output.push_str("+++ ");
output.push_str(&format_diff_path_with_prefix(
dst_prefix,
new_path,
quote_path_fully,
));
output.push('\n');
}
let old_lines: Vec<&str> = old_content.lines().collect();
for hunk_str in imara_unified_hunk_slices(&body) {
if hunk_str.is_empty() {
continue;
}
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, funcname_matcher, Some(old_path))
{
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);
}
}
annotate_unified_diff_no_newline_at_eof(&output, old_content, new_content)
}
#[allow(clippy::too_many_arguments)]
pub fn unified_diff_histogram_ignore_with_prefix_and_funcname<F>(
old_content: &str,
new_content: &str,
old_path: &str,
new_path: &str,
context_lines: usize,
inter_hunk_context: usize,
src_prefix: &str,
dst_prefix: &str,
funcname_matcher: Option<&FuncnameMatcher>,
quote_path_fully: bool,
function_context: bool,
indent_heuristic: bool,
is_ignorable_change: F,
) -> String
where
F: Fn(&[&str], &[&str]) -> bool,
{
use crate::quote_path::format_diff_path_with_prefix;
let body = histogram_unified_body_ignore_fc(
old_content,
new_content,
context_lines,
inter_hunk_context,
function_context,
funcname_matcher,
indent_heuristic,
super::WhitespaceMode::default(),
is_ignorable_change,
);
if body.is_empty() {
return String::new();
}
let mut output = String::new();
if old_path == "/dev/null" {
output.push_str("--- /dev/null\n");
} else if src_prefix.is_empty() {
output.push_str(&format!("--- {old_path}\n"));
} else {
output.push_str("--- ");
output.push_str(&format_diff_path_with_prefix(
src_prefix,
old_path,
quote_path_fully,
));
output.push('\n');
}
if new_path == "/dev/null" {
output.push_str("+++ /dev/null\n");
} else if dst_prefix.is_empty() {
output.push_str(&format!("+++ {new_path}\n"));
} else {
output.push_str("+++ ");
output.push_str(&format_diff_path_with_prefix(
dst_prefix,
new_path,
quote_path_fully,
));
output.push('\n');
}
let old_lines: Vec<&str> = old_content.lines().collect();
for hunk_str in imara_unified_hunk_slices(&body) {
if hunk_str.is_empty() {
continue;
}
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, funcname_matcher, Some(old_path))
{
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
}
#[must_use]
pub fn indent_heuristic_from_config(config: &ConfigSet) -> bool {
match config.get_bool("diff.indentHeuristic") {
Some(Ok(b)) => b,
Some(Err(_)) | None => true,
}
}
#[must_use]
pub fn resolve_indent_heuristic(
config: &ConfigSet,
cli_indent_heuristic: bool,
cli_no_indent_heuristic: bool,
) -> bool {
if cli_no_indent_heuristic {
false
} else if cli_indent_heuristic {
true
} else {
indent_heuristic_from_config(config)
}
}
#[must_use]
pub fn parse_indent_heuristic_cli_flags(argv: &[String]) -> (bool, bool) {
let mut indent_heuristic = false;
let mut no_indent_heuristic = false;
for a in argv {
match a.as_str() {
"--indent-heuristic" => {
indent_heuristic = true;
no_indent_heuristic = false;
}
"--no-indent-heuristic" => {
no_indent_heuristic = true;
indent_heuristic = false;
}
_ => {}
}
}
(indent_heuristic, no_indent_heuristic)
}
mod git_xdiff {
const XDL_MAX_COST_MIN: i64 = 256;
const XDL_HEUR_MIN_COST: i64 = 256;
const XDL_SNAKE_CNT: i64 = 20;
const XDL_K_HEUR: i64 = 4;
const XDL_LINE_MAX: i64 = i64::MAX;
const XDL_KPDIS_RUN: i64 = 4;
const XDL_MAX_EQLIMIT: i64 = 1024;
const XDL_SIMSCAN_WINDOW: i64 = 100;
const DISCARD: u8 = 0;
const KEEP: u8 = 1;
const INVESTIGATE: u8 = 2;
fn bogosqrt(mut n: i64) -> i64 {
let mut i: i64 = 1;
while n > 0 {
i <<= 1;
n >>= 2;
}
i
}
struct XdAlgoEnv {
mxcost: i64,
snake_cnt: i64,
heur_min: i64,
}
struct XdpSplit {
i1: i64,
i2: i64,
min_lo: bool,
min_hi: bool,
}
struct XdFile<'a> {
ids: &'a [u32],
changed: Vec<bool>,
reference_index: Vec<usize>,
dstart: i64,
dend: i64,
nreff: i64,
}
#[inline]
fn get_id(xdf: &XdFile<'_>, idx: i64) -> u32 {
xdf.ids[xdf.reference_index[idx as usize]]
}
#[allow(clippy::too_many_arguments)]
fn xdl_split(
xdf1: &XdFile<'_>,
off1: i64,
lim1: i64,
xdf2: &XdFile<'_>,
off2: i64,
lim2: i64,
kvdf: &mut [i64],
kvdb: &mut [i64],
koff: i64,
need_min: bool,
spl: &mut XdpSplit,
xenv: &XdAlgoEnv,
) -> i64 {
let dmin = off1 - lim2;
let dmax = lim1 - off2;
let fmid = off1 - off2;
let bmid = lim1 - lim2;
let odd = ((fmid - bmid) & 1) != 0;
let mut fmin = fmid;
let mut fmax = fmid;
let mut bmin = bmid;
let mut bmax = bmid;
let kf = |k: i64| -> usize { (k + koff) as usize };
kvdf[kf(fmid)] = off1;
kvdb[kf(bmid)] = lim1;
let mut ec: i64 = 1;
loop {
let mut got_snake = false;
if fmin > dmin {
fmin -= 1;
kvdf[kf(fmin - 1)] = -1;
} else {
fmin += 1;
}
if fmax < dmax {
fmax += 1;
kvdf[kf(fmax + 1)] = -1;
} else {
fmax -= 1;
}
let mut d = fmax;
while d >= fmin {
let mut i1 = if kvdf[kf(d - 1)] >= kvdf[kf(d + 1)] {
kvdf[kf(d - 1)] + 1
} else {
kvdf[kf(d + 1)]
};
let prev1 = i1;
let mut i2 = i1 - d;
while i1 < lim1 && i2 < lim2 && get_id(xdf1, i1) == get_id(xdf2, i2) {
i1 += 1;
i2 += 1;
}
if i1 - prev1 > xenv.snake_cnt {
got_snake = true;
}
kvdf[kf(d)] = i1;
if odd && bmin <= d && d <= bmax && kvdb[kf(d)] <= i1 {
spl.i1 = i1;
spl.i2 = i2;
spl.min_lo = true;
spl.min_hi = true;
return ec;
}
d -= 2;
}
if bmin > dmin {
bmin -= 1;
kvdb[kf(bmin - 1)] = XDL_LINE_MAX;
} else {
bmin += 1;
}
if bmax < dmax {
bmax += 1;
kvdb[kf(bmax + 1)] = XDL_LINE_MAX;
} else {
bmax -= 1;
}
let mut d = bmax;
while d >= bmin {
let mut i1 = if kvdb[kf(d - 1)] < kvdb[kf(d + 1)] {
kvdb[kf(d - 1)]
} else {
kvdb[kf(d + 1)] - 1
};
let prev1 = i1;
let mut i2 = i1 - d;
while i1 > off1 && i2 > off2 && get_id(xdf1, i1 - 1) == get_id(xdf2, i2 - 1) {
i1 -= 1;
i2 -= 1;
}
if prev1 - i1 > xenv.snake_cnt {
got_snake = true;
}
kvdb[kf(d)] = i1;
if !odd && fmin <= d && d <= fmax && i1 <= kvdf[kf(d)] {
spl.i1 = i1;
spl.i2 = i2;
spl.min_lo = true;
spl.min_hi = true;
return ec;
}
d -= 2;
}
if need_min {
ec += 1;
continue;
}
if got_snake && ec > xenv.heur_min {
let mut best = 0i64;
let mut d = fmax;
while d >= fmin {
let dd = if d > fmid { d - fmid } else { fmid - d };
let i1 = kvdf[kf(d)];
let i2 = i1 - d;
let v = (i1 - off1) + (i2 - off2) - dd;
if v > XDL_K_HEUR * ec
&& v > best
&& off1 + xenv.snake_cnt <= i1
&& i1 < lim1
&& off2 + xenv.snake_cnt <= i2
&& i2 < lim2
{
let mut k = 1i64;
while get_id(xdf1, i1 - k) == get_id(xdf2, i2 - k) {
if k == xenv.snake_cnt {
best = v;
spl.i1 = i1;
spl.i2 = i2;
break;
}
k += 1;
}
}
d -= 2;
}
if best > 0 {
spl.min_lo = true;
spl.min_hi = false;
return ec;
}
let mut best = 0i64;
let mut d = bmax;
while d >= bmin {
let dd = if d > bmid { d - bmid } else { bmid - d };
let i1 = kvdb[kf(d)];
let i2 = i1 - d;
let v = (lim1 - i1) + (lim2 - i2) - dd;
if v > XDL_K_HEUR * ec
&& v > best
&& off1 < i1
&& i1 <= lim1 - xenv.snake_cnt
&& off2 < i2
&& i2 <= lim2 - xenv.snake_cnt
{
let mut k = 0i64;
while get_id(xdf1, i1 + k) == get_id(xdf2, i2 + k) {
if k == xenv.snake_cnt - 1 {
best = v;
spl.i1 = i1;
spl.i2 = i2;
break;
}
k += 1;
}
}
d -= 2;
}
if best > 0 {
spl.min_lo = false;
spl.min_hi = true;
return ec;
}
}
if ec >= xenv.mxcost {
let mut fbest = -1i64;
let mut fbest1 = -1i64;
let mut d = fmax;
while d >= fmin {
let mut i1 = kvdf[kf(d)].min(lim1);
let mut i2 = i1 - d;
if lim2 < i2 {
i1 = lim2 + d;
i2 = lim2;
}
if fbest < i1 + i2 {
fbest = i1 + i2;
fbest1 = i1;
}
d -= 2;
}
let mut bbest = XDL_LINE_MAX;
let mut bbest1 = XDL_LINE_MAX;
let mut d = bmax;
while d >= bmin {
let mut i1 = off1.max(kvdb[kf(d)]);
let mut i2 = i1 - d;
if i2 < off2 {
i1 = off2 + d;
i2 = off2;
}
if i1 + i2 < bbest {
bbest = i1 + i2;
bbest1 = i1;
}
d -= 2;
}
if (lim1 + lim2) - bbest < fbest - (off1 + off2) {
spl.i1 = fbest1;
spl.i2 = fbest - fbest1;
spl.min_lo = true;
spl.min_hi = false;
} else {
spl.i1 = bbest1;
spl.i2 = bbest - bbest1;
spl.min_lo = false;
spl.min_hi = true;
}
return ec;
}
ec += 1;
}
}
#[allow(clippy::too_many_arguments)]
fn xdl_recs_cmp(
xdf1: &mut XdFile<'_>,
mut off1: i64,
mut lim1: i64,
xdf2: &mut XdFile<'_>,
mut off2: i64,
mut lim2: i64,
kvdf: &mut [i64],
kvdb: &mut [i64],
koff: i64,
need_min: bool,
xenv: &XdAlgoEnv,
) {
while off1 < lim1 && off2 < lim2 && get_id(xdf1, off1) == get_id(xdf2, off2) {
off1 += 1;
off2 += 1;
}
while off1 < lim1 && off2 < lim2 && get_id(xdf1, lim1 - 1) == get_id(xdf2, lim2 - 1) {
lim1 -= 1;
lim2 -= 1;
}
if off1 == lim1 {
while off2 < lim2 {
let r = xdf2.reference_index[off2 as usize];
xdf2.changed[r] = true;
off2 += 1;
}
} else if off2 == lim2 {
while off1 < lim1 {
let r = xdf1.reference_index[off1 as usize];
xdf1.changed[r] = true;
off1 += 1;
}
} else {
let mut spl = XdpSplit {
i1: 0,
i2: 0,
min_lo: false,
min_hi: false,
};
xdl_split(
xdf1, off1, lim1, xdf2, off2, lim2, kvdf, kvdb, koff, need_min, &mut spl, xenv,
);
xdl_recs_cmp(
xdf1, off1, spl.i1, xdf2, off2, spl.i2, kvdf, kvdb, koff, spl.min_lo, xenv,
);
xdl_recs_cmp(
xdf1, spl.i1, lim1, xdf2, spl.i2, lim2, kvdf, kvdb, koff, spl.min_hi, xenv,
);
}
}
fn clean_mmatch(action: &[u8], i: i64, mut s: i64, mut e: i64) -> bool {
if i - s > XDL_SIMSCAN_WINDOW {
s = i - XDL_SIMSCAN_WINDOW;
}
if e - i > XDL_SIMSCAN_WINDOW {
e = i + XDL_SIMSCAN_WINDOW;
}
let mut rdis0 = 0i64;
let mut rpdis0 = 1i64;
let mut r = 1i64;
while i - r >= s {
match action[(i - r) as usize] {
DISCARD => rdis0 += 1,
INVESTIGATE => rpdis0 += 1,
_ => break, }
r += 1;
}
if rdis0 == 0 {
return false;
}
let mut rdis1 = 0i64;
let mut rpdis1 = 1i64;
let mut r = 1i64;
while i + r <= e {
match action[(i + r) as usize] {
DISCARD => rdis1 += 1,
INVESTIGATE => rpdis1 += 1,
_ => break, }
r += 1;
}
if rdis1 == 0 {
return false;
}
rdis1 += rdis0;
rpdis1 += rpdis0;
rpdis1 * XDL_KPDIS_RUN < (rpdis1 + rdis1)
}
pub fn changed_flags(ids1: &[u32], ids2: &[u32], need_min: bool) -> (Vec<bool>, Vec<bool>) {
let nrec1 = ids1.len();
let nrec2 = ids2.len();
use std::collections::HashMap;
let mut count1: HashMap<u32, i64> = HashMap::new();
let mut count2: HashMap<u32, i64> = HashMap::new();
for &id in ids1 {
*count1.entry(id).or_insert(0) += 1;
}
for &id in ids2 {
*count2.entry(id).or_insert(0) += 1;
}
let mut xdf1 = XdFile {
ids: ids1,
changed: vec![false; nrec1],
reference_index: Vec::new(),
dstart: 0,
dend: nrec1 as i64 - 1,
nreff: 0,
};
let mut xdf2 = XdFile {
ids: ids2,
changed: vec![false; nrec2],
reference_index: Vec::new(),
dstart: 0,
dend: nrec2 as i64 - 1,
nreff: 0,
};
let lim = nrec1.min(nrec2) as i64;
let mut i = 0i64;
while i < lim && ids1[i as usize] == ids2[i as usize] {
i += 1;
}
xdf1.dstart = i;
xdf2.dstart = i;
let mut j = 0i64;
let rem = lim - i;
while j < rem && ids1[nrec1 - 1 - j as usize] == ids2[nrec2 - 1 - j as usize] {
j += 1;
}
xdf1.dend = nrec1 as i64 - j - 1;
xdf2.dend = nrec2 as i64 - j - 1;
let mut action1 = vec![0u8; nrec1 + 1];
let mut action2 = vec![0u8; nrec2 + 1];
let mlim1 = if need_min {
i64::MAX
} else {
let mut mlim = bogosqrt(nrec1 as i64);
if mlim > XDL_MAX_EQLIMIT {
mlim = XDL_MAX_EQLIMIT;
}
mlim
};
let mut idx = xdf1.dstart;
while idx <= xdf1.dend {
let id = ids1[idx as usize];
let nm = *count2.get(&id).unwrap_or(&0);
action1[idx as usize] = if nm == 0 {
DISCARD
} else if nm >= mlim1 {
INVESTIGATE
} else {
KEEP
};
idx += 1;
}
let mlim2 = if need_min {
i64::MAX
} else {
let mut mlim = bogosqrt(nrec2 as i64);
if mlim > XDL_MAX_EQLIMIT {
mlim = XDL_MAX_EQLIMIT;
}
mlim
};
let mut idx = xdf2.dstart;
while idx <= xdf2.dend {
let id = ids2[idx as usize];
let nm = *count1.get(&id).unwrap_or(&0);
action2[idx as usize] = if nm == 0 {
DISCARD
} else if nm >= mlim2 {
INVESTIGATE
} else {
KEEP
};
idx += 1;
}
let mut idx = xdf1.dstart;
while idx <= xdf1.dend {
let a = action1[idx as usize];
if a == KEEP
|| (a == INVESTIGATE && !clean_mmatch(&action1, idx, xdf1.dstart, xdf1.dend))
{
xdf1.reference_index.push(idx as usize);
} else {
xdf1.changed[idx as usize] = true;
}
idx += 1;
}
xdf1.nreff = xdf1.reference_index.len() as i64;
let mut idx = xdf2.dstart;
while idx <= xdf2.dend {
let a = action2[idx as usize];
if a == KEEP
|| (a == INVESTIGATE && !clean_mmatch(&action2, idx, xdf2.dstart, xdf2.dend))
{
xdf2.reference_index.push(idx as usize);
} else {
xdf2.changed[idx as usize] = true;
}
idx += 1;
}
xdf2.nreff = xdf2.reference_index.len() as i64;
let ndiags = xdf1.nreff + xdf2.nreff + 3;
let kvd_len = (2 * ndiags + 2) as usize;
let mut kvd = vec![0i64; kvd_len];
let koff = xdf2.nreff + 1;
let (kvdf_slice, kvdb_slice) = kvd.split_at_mut(ndiags as usize);
let xenv = XdAlgoEnv {
mxcost: bogosqrt(ndiags).max(XDL_MAX_COST_MIN),
snake_cnt: XDL_SNAKE_CNT,
heur_min: XDL_HEUR_MIN_COST,
};
let nreff1 = xdf1.nreff;
let nreff2 = xdf2.nreff;
xdl_recs_cmp(
&mut xdf1, 0, nreff1, &mut xdf2, 0, nreff2, kvdf_slice, kvdb_slice, koff, need_min,
&xenv,
);
(xdf1.changed, xdf2.changed)
}
pub fn patience_changed_flags(lines1: &[&str], lines2: &[&str]) -> (Vec<bool>, Vec<bool>) {
let mut changed1 = vec![false; lines1.len()];
let mut changed2 = vec![false; lines2.len()];
patience_recurse(
lines1,
lines2,
&mut changed1,
&mut changed2,
0,
lines1.len(),
0,
lines2.len(),
);
(changed1, changed2)
}
#[allow(clippy::too_many_arguments)]
fn patience_recurse(
lines1: &[&str],
lines2: &[&str],
changed1: &mut [bool],
changed2: &mut [bool],
o_start: usize,
o_end: usize,
n_start: usize,
n_end: usize,
) {
if o_start >= o_end && n_start >= n_end {
return;
}
if o_start >= o_end {
changed2[n_start..n_end].fill(true);
return;
}
if n_start >= n_end {
changed1[o_start..o_end].fill(true);
return;
}
let anchors = patience_anchors(lines1, lines2, o_start, o_end, n_start, n_end);
if anchors.is_empty() {
merge_myers_region(
lines1, lines2, changed1, changed2, o_start, o_end, n_start, n_end,
);
return;
}
let mut prev_o = o_start;
let mut prev_n = n_start;
for (ao, an) in anchors {
patience_recurse(lines1, lines2, changed1, changed2, prev_o, ao, prev_n, an);
prev_o = ao + 1;
prev_n = an + 1;
}
patience_recurse(
lines1, lines2, changed1, changed2, prev_o, o_end, prev_n, n_end,
);
}
#[allow(clippy::too_many_arguments)]
fn patience_anchors(
lines1: &[&str],
lines2: &[&str],
o_start: usize,
o_end: usize,
n_start: usize,
n_end: usize,
) -> Vec<(usize, usize)> {
use std::collections::HashMap;
let old_slice = &lines1[o_start..o_end];
let new_slice = &lines2[n_start..n_end];
let mut old_pos: HashMap<&str, Vec<usize>> = HashMap::new();
let mut new_pos: HashMap<&str, Vec<usize>> = HashMap::new();
for (i, line) in old_slice.iter().enumerate() {
old_pos.entry(*line).or_default().push(o_start + i);
}
for (i, line) in new_slice.iter().enumerate() {
new_pos.entry(*line).or_default().push(n_start + i);
}
let mut pairs: Vec<(usize, usize)> = Vec::new();
for (line, o_indices) in &old_pos {
if o_indices.len() != 1 {
continue;
}
if let Some(n_indices) = new_pos.get(line) {
if n_indices.len() == 1 {
pairs.push((o_indices[0], n_indices[0]));
}
}
}
pairs.sort_by_key(|p| p.0);
longest_increasing_by_second(&pairs)
}
fn longest_increasing_by_second(pairs: &[(usize, usize)]) -> Vec<(usize, usize)> {
if pairs.is_empty() {
return Vec::new();
}
let n = pairs.len();
let mut tails = vec![0usize; n];
let mut prev = vec![usize::MAX; n];
let mut len = 0usize;
for i in 0..n {
let v = pairs[i].1;
let mut lo = 0usize;
let mut hi = len;
while lo < hi {
let mid = (lo + hi) / 2;
if pairs[tails[mid]].1 < v {
lo = mid + 1;
} else {
hi = mid;
}
}
if lo > 0 {
prev[i] = tails[lo - 1];
}
tails[lo] = i;
if lo == len {
len += 1;
}
}
let mut out = Vec::with_capacity(len);
let mut k = tails[len.saturating_sub(1)];
while k != usize::MAX {
out.push(pairs[k]);
k = prev[k];
}
out.reverse();
out
}
pub(crate) fn histogram_changed_flags(
lines1: &[&str],
lines2: &[&str],
) -> (Vec<bool>, Vec<bool>) {
crate::diff::git_histogram::histogram_changed_flags(
lines1,
lines2,
|changed1, changed2, o_start, o_end, n_start, n_end| {
merge_myers_region(
lines1, lines2, changed1, changed2, o_start, o_end, n_start, n_end,
);
},
)
}
#[allow(clippy::too_many_arguments)]
fn merge_myers_region(
lines1: &[&str],
lines2: &[&str],
changed1: &mut [bool],
changed2: &mut [bool],
o_start: usize,
o_end: usize,
n_start: usize,
n_end: usize,
) {
use crate::line_interner::LineInterner;
let old_slice = &lines1[o_start..o_end];
let new_slice = &lines2[n_start..n_end];
if old_slice.is_empty() && new_slice.is_empty() {
return;
}
let mut interner = LineInterner::default();
let ids1 = interner.intern_all(old_slice);
let ids2 = interner.intern_all(new_slice);
let (sub1, sub2) = changed_flags(&ids1, &ids2, false);
for (i, ch) in sub1.iter().enumerate() {
if *ch {
changed1[o_start + i] = true;
}
}
for (j, ch) in sub2.iter().enumerate() {
if *ch {
changed2[n_start + j] = true;
}
}
}
}
pub(crate) fn myers_rediff_changed_masks_for_region(
old_lines: &[&str],
new_lines: &[&str],
ws: super::WhitespaceMode,
) -> (Vec<bool>, Vec<bool>) {
use crate::line_interner::LineInterner;
if old_lines.is_empty() && new_lines.is_empty() {
return (Vec::new(), Vec::new());
}
let mut interner = LineInterner::default();
let (ids1, ids2) = if ws.normalizes_line_compare() {
(
interner.intern_all_normalized(old_lines, |line| ws.normalize_line_for_compare(line)),
interner.intern_all_normalized(new_lines, |line| ws.normalize_line_for_compare(line)),
)
} else {
(
interner.intern_all(old_lines),
interner.intern_all(new_lines),
)
};
git_xdiff::changed_flags(&ids1, &ids2, false)
}
fn changed_flags_to_ops(
changed1: &[bool],
changed2: &[bool],
old_len: usize,
new_len: usize,
) -> Vec<similar::DiffOp> {
use similar::DiffOp;
let mut ops: Vec<DiffOp> = Vec::new();
let mut i = 0usize;
let mut j = 0usize;
while i < old_len || j < new_len {
let del = i < old_len && changed1[i];
let ins = j < new_len && changed2[j];
if !del && !ins {
if i < old_len && j < new_len {
let start_i = i;
let start_j = j;
while i < old_len && j < new_len && !changed1[i] && !changed2[j] {
i += 1;
j += 1;
}
ops.push(DiffOp::Equal {
old_index: start_i,
new_index: start_j,
len: i - start_i,
});
} else if i < old_len {
let start_i = i;
while i < old_len {
i += 1;
}
ops.push(DiffOp::Delete {
old_index: start_i,
old_len: i - start_i,
new_index: j,
});
} else if j < new_len {
let start_j = j;
while j < new_len {
j += 1;
}
ops.push(DiffOp::Insert {
old_index: i,
new_index: start_j,
new_len: j - start_j,
});
}
} else {
let start_i = i;
let start_j = j;
while i < old_len && changed1[i] {
i += 1;
}
while j < new_len && changed2[j] {
j += 1;
}
let dlen = i - start_i;
let ilen = j - start_j;
if dlen > 0 && ilen > 0 {
ops.push(DiffOp::Replace {
old_index: start_i,
old_len: dlen,
new_index: start_j,
new_len: ilen,
});
} else if dlen > 0 {
ops.push(DiffOp::Delete {
old_index: start_i,
old_len: dlen,
new_index: start_j,
});
} else if ilen > 0 {
ops.push(DiffOp::Insert {
old_index: start_i,
new_index: start_j,
new_len: ilen,
});
}
}
}
ops
}
#[must_use]
pub fn word_diff_ops_imara(old_words: &[&str], new_words: &[&str]) -> Vec<similar::DiffOp> {
use std::collections::HashMap;
let mut interner: HashMap<&str, u32> = HashMap::new();
let mut ids1: Vec<u32> = Vec::with_capacity(old_words.len());
for &w in old_words {
let next = interner.len() as u32;
let id = *interner.entry(w).or_insert(next);
ids1.push(id);
}
let mut ids2: Vec<u32> = Vec::with_capacity(new_words.len());
for &w in new_words {
let next = interner.len() as u32;
let id = *interner.entry(w).or_insert(next);
ids2.push(id);
}
let (changed1, changed2) = git_xdiff::changed_flags(&ids1, &ids2, false);
let ops = changed_flags_to_ops(&changed1, &changed2, old_words.len(), new_words.len());
diff_indent_heuristic::apply_change_compact_to_ops(&ops, old_words, new_words, false)
}
#[must_use]
pub fn diff_slice_ops_compacted(
old_lines: &[&str],
new_lines: &[&str],
algorithm: similar::Algorithm,
indent_heuristic: bool,
) -> Vec<similar::DiffOp> {
diff_indent_heuristic::diff_slice_ops_compacted(
old_lines,
new_lines,
algorithm,
indent_heuristic,
)
}
#[must_use]
pub(crate) fn line_diff_ops_git_xdiff_compacted(
old_lines: &[&str],
new_lines: &[&str],
indent_heuristic: bool,
) -> Vec<similar::DiffOp> {
use std::collections::HashMap;
let mut interner: HashMap<&str, u32> = HashMap::new();
let mut ids1 = Vec::with_capacity(old_lines.len());
for &line in old_lines {
let next = interner.len() as u32;
let id = *interner.entry(line).or_insert(next);
ids1.push(id);
}
let mut ids2 = Vec::with_capacity(new_lines.len());
for &line in new_lines {
let next = interner.len() as u32;
let id = *interner.entry(line).or_insert(next);
ids2.push(id);
}
line_diff_ops_git_xdiff_compacted_ids(&ids1, &ids2, old_lines, new_lines, indent_heuristic)
}
#[must_use]
pub(crate) fn line_diff_ops_git_xdiff_compacted_ids(
old_ids: &[u32],
new_ids: &[u32],
old_lines: &[&str],
new_lines: &[&str],
indent_heuristic: bool,
) -> Vec<similar::DiffOp> {
let (changed1, changed2) = git_xdiff::changed_flags(old_ids, new_ids, false);
let ops = changed_flags_to_ops(&changed1, &changed2, old_lines.len(), new_lines.len());
diff_indent_heuristic::apply_change_compact_to_ops(&ops, old_lines, new_lines, indent_heuristic)
}
#[cfg(test)]
#[must_use]
pub(crate) fn xdiff_new_line_changed_flags(
old_lines: &[&str],
new_lines: &[&str],
indent_heuristic: bool,
) -> Vec<bool> {
let mut interner = crate::line_interner::LineInterner::default();
let ids1 = interner.intern_all(old_lines);
let ids2 = interner.intern_all(new_lines);
let (changed1, changed2) = git_xdiff::changed_flags(&ids1, &ids2, false);
let ops = changed_flags_to_ops(&changed1, &changed2, old_lines.len(), new_lines.len());
let compacted = diff_indent_heuristic::apply_change_compact_to_ops(
&ops,
old_lines,
new_lines,
indent_heuristic,
);
let mut flags = vec![false; new_lines.len()];
for op in compacted {
match op.tag() {
similar::DiffTag::Insert | similar::DiffTag::Replace => {
for idx in op.new_range() {
if idx < flags.len() {
flags[idx] = true;
}
}
}
similar::DiffTag::Equal | similar::DiffTag::Delete => {}
}
}
flags
}
#[must_use]
pub fn map_new_to_old_lines_compacted(
old_joined: &str,
new_joined: &str,
algorithm: similar::Algorithm,
indent_heuristic: bool,
new_line_count: usize,
) -> Vec<Option<usize>> {
let ops = diff_indent_heuristic::diff_lines_ops_compacted(
old_joined,
new_joined,
algorithm,
indent_heuristic,
);
diff_indent_heuristic::map_new_to_old_from_ops(&ops, new_line_count)
}
#[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, PartialEq, Eq)]
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("")
}
#[must_use]
pub fn display_path(&self) -> String {
match self.status {
DiffStatus::Renamed | DiffStatus::Copied => {
let old = self.old_path.as_deref().unwrap_or("");
let new = self.new_path.as_deref().unwrap_or("");
if old.is_empty() || new.is_empty() {
self.path().to_owned()
} else {
format!("{old} -> {new}")
}
}
_ => self.path().to_owned(),
}
}
}
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");
})
}
#[must_use]
pub fn empty_blob_oid() -> ObjectId {
ObjectId::from_hex("e69de29bb2d1d6434b8b29ae775ad8c2e48c5391").unwrap_or_else(|_| {
panic!("internal error: failed to create empty blob OID");
})
}
pub fn diff_trees(
odb: &Odb,
old_tree_oid: Option<&ObjectId>,
new_tree_oid: Option<&ObjectId>,
prefix: &str,
) -> Result<Vec<DiffEntry>> {
crate::trace::trace_span!("grit.diff.tree");
diff_trees_opts(odb, old_tree_oid, new_tree_oid, prefix, false, None)
}
pub(crate) fn diff_trees_with_index(
odb: &Odb,
index: &Index,
old_tree_oid: Option<&ObjectId>,
new_tree_oid: Option<&ObjectId>,
prefix: &str,
trust_index_for_old_tree: bool,
) -> Result<Vec<DiffEntry>> {
let hint = (trust_index_for_old_tree
&& old_tree_oid.is_some()
&& index.entries_sorted
&& !index.entries.iter().any(|e| e.stage() != 0))
.then_some(DiffTreesIndexHint { index });
diff_trees_opts(
odb,
old_tree_oid,
new_tree_oid,
prefix,
false,
hint.as_ref(),
)
}
#[derive(Clone, Copy)]
struct DiffTreesIndexHint<'a> {
index: &'a Index,
}
pub fn diff_tree_path_change_count(
odb: &Odb,
old_tree_oid: Option<&ObjectId>,
new_tree_oid: Option<&ObjectId>,
prefix: &str,
) -> Result<usize> {
if old_tree_oid == new_tree_oid {
return Ok(0);
}
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 count = 0usize;
diff_tree_entries_count(odb, &old_entries, &new_entries, prefix, false, &mut count)?;
Ok(count)
}
pub fn diff_trees_with_renames(
odb: &Odb,
old_tree_oid: Option<&ObjectId>,
new_tree_oid: Option<&ObjectId>,
prefix: &str,
rename_threshold: u32,
) -> Result<Vec<DiffEntry>> {
let config = ConfigSet::new();
let policy = ThreadPolicy::default();
diff_trees_with_renames_and_policy(
odb,
old_tree_oid,
new_tree_oid,
prefix,
rename_threshold,
&config,
&policy,
)
}
pub fn diff_trees_with_renames_and_policy(
odb: &Odb,
old_tree_oid: Option<&ObjectId>,
new_tree_oid: Option<&ObjectId>,
prefix: &str,
rename_threshold: u32,
config: &ConfigSet,
thread_policy: &ThreadPolicy,
) -> Result<Vec<DiffEntry>> {
let mut entries = diff_trees(odb, old_tree_oid, new_tree_oid, prefix)?;
if rename_threshold <= 100 {
entries =
detect_renames_with_policy(odb, None, entries, rename_threshold, config, thread_policy);
}
Ok(entries)
}
pub fn tree_oid_of_commit(odb: &Odb, commit_oid: &ObjectId) -> Result<ObjectId> {
let obj = odb.read(commit_oid)?;
if obj.kind != ObjectKind::Commit {
return Err(Error::InvalidRef(format!(
"invalid ref: {commit_oid} is not a commit"
)));
}
let commit = parse_commit(&obj.data)?;
Ok(commit.tree)
}
pub fn tree_oid_of_commit_in_repo(
repo: &crate::repo::Repository,
commit_oid: &ObjectId,
) -> Result<ObjectId> {
if let Some(chain) = repo.caches().commit_graph_chain(repo.odb().objects_dir()) {
if let Some(tree) = chain.graph_commit_tree(commit_oid) {
return Ok(tree);
}
}
tree_oid_of_commit(repo.odb(), commit_oid)
}
pub fn diff_commits(odb: &Odb, base: &ObjectId, tip: &ObjectId) -> Result<Vec<DiffEntry>> {
let base_tree = tree_oid_of_commit(odb, base)?;
let tip_tree = tree_oid_of_commit(odb, tip)?;
diff_trees(odb, Some(&base_tree), Some(&tip_tree), "")
}
pub fn diff_commits_with_renames(
odb: &Odb,
base: &ObjectId,
tip: &ObjectId,
rename_threshold: u32,
) -> Result<Vec<DiffEntry>> {
let config = ConfigSet::new();
let policy = ThreadPolicy::default();
diff_commits_with_renames_and_policy(odb, base, tip, rename_threshold, &config, &policy)
}
pub fn diff_commits_with_renames_and_policy(
odb: &Odb,
base: &ObjectId,
tip: &ObjectId,
rename_threshold: u32,
config: &ConfigSet,
thread_policy: &ThreadPolicy,
) -> Result<Vec<DiffEntry>> {
let base_tree = tree_oid_of_commit(odb, base)?;
let tip_tree = tree_oid_of_commit(odb, tip)?;
diff_trees_with_renames_and_policy(
odb,
Some(&base_tree),
Some(&tip_tree),
"",
rename_threshold,
config,
thread_policy,
)
}
pub fn diff_trees_show_tree_entries(
odb: &Odb,
old_tree_oid: Option<&ObjectId>,
new_tree_oid: Option<&ObjectId>,
prefix: &str,
) -> Result<Vec<DiffEntry>> {
diff_trees_opts(odb, old_tree_oid, new_tree_oid, prefix, true, None)
}
struct TreeReadCache<'a> {
odb: &'a Odb,
parsed: std::collections::HashMap<ObjectId, Arc<Vec<TreeEntry>>>,
}
impl<'a> TreeReadCache<'a> {
fn new(odb: &'a Odb) -> Self {
Self {
odb,
parsed: std::collections::HashMap::new(),
}
}
fn entries_arc(&mut self, oid: &ObjectId) -> Result<Arc<Vec<TreeEntry>>> {
if let Some(e) = self.parsed.get(oid) {
return Ok(Arc::clone(e));
}
let arc = self.odb.cached_tree_entries(oid)?;
self.parsed.insert(*oid, Arc::clone(&arc));
Ok(arc)
}
}
fn diff_subtrees_in_place(
cache: &mut TreeReadCache<'_>,
old_tree_oid: Option<&ObjectId>,
new_tree_oid: Option<&ObjectId>,
prefix: &str,
show_trees: bool,
index_hint: Option<&DiffTreesIndexHint<'_>>,
result: &mut Vec<DiffEntry>,
) -> Result<()> {
if !show_trees {
if let (Some(a), Some(b)) = (old_tree_oid, new_tree_oid) {
if a == b {
return Ok(());
}
}
}
let empty: Arc<Vec<TreeEntry>> = Arc::new(Vec::new());
let old_entries = match old_tree_oid {
Some(oid) => cache.entries_arc(oid)?,
None => Arc::clone(&empty),
};
let new_entries = match new_tree_oid {
Some(oid) => cache.entries_arc(oid)?,
None => Arc::clone(&empty),
};
diff_tree_entries_opts(
cache,
old_entries.as_slice(),
new_entries.as_slice(),
prefix,
show_trees,
index_hint,
result,
)
}
fn diff_trees_opts(
odb: &Odb,
old_tree_oid: Option<&ObjectId>,
new_tree_oid: Option<&ObjectId>,
prefix: &str,
show_trees: bool,
index_hint: Option<&DiffTreesIndexHint<'_>>,
) -> Result<Vec<DiffEntry>> {
let mut result = Vec::new();
let mut cache = TreeReadCache::new(odb);
diff_subtrees_in_place(
&mut cache,
old_tree_oid,
new_tree_oid,
prefix,
show_trees,
index_hint,
&mut result,
)?;
Ok(result)
}
#[cfg(test)]
use std::cell::RefCell;
#[cfg(test)]
use std::rc::Rc;
#[cfg(test)]
use std::sync::atomic::{AtomicUsize, Ordering};
#[cfg(test)]
thread_local! {
static DIFF_TREE_READ_HOOK: RefCell<Option<Rc<AtomicUsize>>> = const { RefCell::new(None) };
}
#[cfg(test)]
pub(crate) fn set_diff_tree_read_hook_for_tests(hook: Option<Rc<AtomicUsize>>) {
DIFF_TREE_READ_HOOK.with(|cell| *cell.borrow_mut() = hook);
}
fn read_tree_from_odb(odb: &Odb, oid: &ObjectId) -> Result<Vec<TreeEntry>> {
#[cfg(test)]
if let Some(counter) = DIFF_TREE_READ_HOOK.with(|cell| cell.borrow().clone()) {
counter.fetch_add(1, Ordering::Relaxed);
}
Ok((*odb.cached_tree_entries(oid)?).clone())
}
fn read_tree(odb: &Odb, oid: &ObjectId) -> Result<Vec<TreeEntry>> {
read_tree_from_odb(odb, oid)
}
fn diff_tree_entries_count(
odb: &Odb,
old: &[TreeEntry],
new: &[TreeEntry],
prefix: &str,
show_trees: bool,
count: &mut usize,
) -> 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_count(odb, o, prefix, show_trees, count)?;
oi += 1;
}
std::cmp::Ordering::Greater => {
emit_added_count(odb, n, prefix, show_trees, count)?;
ni += 1;
}
std::cmp::Ordering::Equal => {
if o.oid != n.oid || o.mode != n.mode {
let path = crate::repo_relpath::format_path_stored(prefix, &o.name);
if is_tree_mode(o.mode) && is_tree_mode(n.mode) {
if show_trees {
*count += 1;
}
*count += diff_tree_path_change_count(
odb,
Some(&o.oid),
Some(&n.oid),
&path,
)?;
} else if is_tree_mode(o.mode) != is_tree_mode(n.mode) {
emit_deleted_count(odb, o, prefix, show_trees, count)?;
emit_added_count(odb, n, prefix, show_trees, count)?;
} else {
*count += 1;
}
}
oi += 1;
ni += 1;
}
}
}
(Some(o), None) => {
emit_deleted_count(odb, o, prefix, show_trees, count)?;
oi += 1;
}
(None, Some(n)) => {
emit_added_count(odb, n, prefix, show_trees, count)?;
ni += 1;
}
(None, None) => break,
}
}
Ok(())
}
fn emit_deleted_count(
odb: &Odb,
entry: &TreeEntry,
prefix: &str,
show_trees: bool,
count: &mut usize,
) -> Result<()> {
let path = crate::repo_relpath::format_path_stored(prefix, &entry.name);
if is_tree_mode(entry.mode) {
if show_trees {
*count += 1;
}
*count += diff_tree_path_change_count(odb, Some(&entry.oid), None, &path)?;
} else {
*count += 1;
}
Ok(())
}
fn emit_added_count(
odb: &Odb,
entry: &TreeEntry,
prefix: &str,
show_trees: bool,
count: &mut usize,
) -> Result<()> {
let path = crate::repo_relpath::format_path_stored(prefix, &entry.name);
if is_tree_mode(entry.mode) {
if show_trees {
*count += 1;
}
*count += diff_tree_path_change_count(odb, None, Some(&entry.oid), &path)?;
} else {
*count += 1;
}
Ok(())
}
fn diff_tree_entries_opts(
cache: &mut TreeReadCache<'_>,
old: &[TreeEntry],
new: &[TreeEntry],
prefix: &str,
show_trees: bool,
index_hint: Option<&DiffTreesIndexHint<'_>>,
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_opts(cache, o, prefix, show_trees, index_hint, result)?;
oi += 1;
}
std::cmp::Ordering::Greater => {
emit_added_opts(cache, n, prefix, show_trees, result)?;
ni += 1;
}
std::cmp::Ordering::Equal => {
if o.oid != n.oid || o.mode != n.mode {
let path = crate::repo_relpath::format_path_stored(prefix, &o.name);
if is_tree_mode(o.mode) && is_tree_mode(n.mode) {
if show_trees {
result.push(DiffEntry {
status: DiffStatus::Modified,
old_path: Some(path.clone()),
new_path: Some(path.clone()),
old_mode: format_mode(o.mode),
new_mode: format_mode(n.mode),
old_oid: o.oid,
new_oid: n.oid,
score: None,
});
}
diff_subtrees_in_place(
cache,
Some(&o.oid),
Some(&n.oid),
&path,
show_trees,
index_hint,
result,
)?;
} else if is_tree_mode(o.mode) && !is_tree_mode(n.mode) {
emit_deleted_opts(
cache, o, prefix, show_trees, index_hint, result,
)?;
emit_added_opts(cache, n, prefix, show_trees, result)?;
} else if !is_tree_mode(o.mode) && is_tree_mode(n.mode) {
emit_deleted_opts(
cache, o, prefix, show_trees, index_hint, result,
)?;
emit_added_opts(cache, n, prefix, show_trees, result)?;
} else {
let old_type = o.mode & 0o170000;
let new_type = n.mode & 0o170000;
result.push(DiffEntry {
status: if old_type != new_type {
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_opts(cache, o, prefix, show_trees, index_hint, result)?;
oi += 1;
}
(None, Some(n)) => {
emit_added_opts(cache, n, prefix, show_trees, result)?;
ni += 1;
}
(None, None) => break,
}
}
Ok(())
}
fn emit_deleted_tree_from_index(index: &Index, dir_prefix: &str, result: &mut Vec<DiffEntry>) {
let scan_prefix: Vec<u8> = if dir_prefix.is_empty() {
Vec::new()
} else {
format!("{dir_prefix}/").into_bytes()
};
if index.entries_sorted {
let start = if scan_prefix.is_empty() {
0
} else {
index
.entries
.partition_point(|e| e.path.as_slice() < scan_prefix.as_slice())
};
for ie in index.entries[start..].iter() {
if ie.stage() != 0 {
continue;
}
if !scan_prefix.is_empty() && !ie.path.starts_with(&scan_prefix) {
break;
}
if scan_prefix.is_empty() && ie.path.contains(&b'/') {
continue;
}
let path = String::from_utf8_lossy(&ie.path).into_owned();
result.push(DiffEntry {
status: DiffStatus::Deleted,
old_path: Some(path),
new_path: None,
old_mode: format_mode(ie.mode),
new_mode: "000000".to_owned(),
old_oid: ie.oid,
new_oid: zero_oid(),
score: None,
});
}
return;
}
for ie in &index.entries {
if ie.stage() != 0 {
continue;
}
let Ok(path) = std::str::from_utf8(&ie.path) else {
continue;
};
let under = if dir_prefix.is_empty() {
!path.contains('/')
} else if path == dir_prefix {
false
} else {
path.strip_prefix(dir_prefix)
.is_some_and(|rest| rest.starts_with('/'))
};
if !under {
continue;
}
result.push(DiffEntry {
status: DiffStatus::Deleted,
old_path: Some(path.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,
});
}
}
fn emit_deleted_opts(
cache: &mut TreeReadCache<'_>,
entry: &TreeEntry,
prefix: &str,
show_trees: bool,
index_hint: Option<&DiffTreesIndexHint<'_>>,
result: &mut Vec<DiffEntry>,
) -> Result<()> {
let path = crate::repo_relpath::format_path_stored(prefix, &entry.name);
if is_tree_mode(entry.mode) {
if show_trees {
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,
});
}
if let Some(hint) = index_hint {
emit_deleted_tree_from_index(hint.index, &path, result);
return Ok(());
}
diff_subtrees_in_place(
cache,
Some(&entry.oid),
None,
&path,
show_trees,
None,
result,
)?;
} 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_opts(
cache: &mut TreeReadCache<'_>,
entry: &TreeEntry,
prefix: &str,
show_trees: bool,
result: &mut Vec<DiffEntry>,
) -> Result<()> {
let path = crate::repo_relpath::format_path_stored(prefix, &entry.name);
if is_tree_mode(entry.mode) {
if show_trees {
result.push(DiffEntry {
status: DiffStatus::Added,
old_path: None,
new_path: Some(path.clone()),
old_mode: "000000".to_owned(),
new_mode: format_mode(entry.mode),
old_oid: zero_oid(),
new_oid: entry.oid,
score: None,
});
}
diff_subtrees_in_place(
cache,
None,
Some(&entry.oid),
&path,
show_trees,
None,
result,
)?;
} 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>,
ignore_submodules: bool,
) -> Result<Vec<DiffEntry>> {
match index_cache_tree_walk(index, tree_oid) {
CacheTreeWalk::Fast {
head_tree,
cache_root,
} => {
diff_index_to_tree_with_cache_tree(odb, index, head_tree, cache_root, ignore_submodules)
}
CacheTreeWalk::Flatten => {
diff_index_to_tree_flatten(odb, index, tree_oid, ignore_submodules)
}
}
}
pub fn diff_index_to_tree_with_renames(
odb: &Odb,
index: &Index,
tree_oid: Option<&ObjectId>,
ignore_submodules: bool,
rename_threshold: u32,
config: &ConfigSet,
thread_policy: &ThreadPolicy,
) -> Result<Vec<DiffEntry>> {
let entries = diff_index_to_tree(odb, index, tree_oid, ignore_submodules)?;
apply_rename_detection_to_diff_entries(
odb,
entries,
None,
rename_threshold,
config,
thread_policy,
)
}
pub fn apply_rename_detection_to_diff_entries(
odb: &Odb,
entries: Vec<DiffEntry>,
work_root: Option<&Path>,
rename_threshold: u32,
config: &ConfigSet,
thread_policy: &ThreadPolicy,
) -> Result<Vec<DiffEntry>> {
if entries.is_empty() || rename_threshold >= 100 {
return Ok(entries);
}
if work_root.is_some() {
let opts = DiffPostprocessOptions {
rename_threshold,
..DiffPostprocessOptions::default()
};
postprocess_diff_entries(
odb,
entries,
None,
work_root,
None,
config,
thread_policy,
&opts,
)
} else {
Ok(detect_renames_with_policy(
odb,
None,
entries,
rename_threshold,
config,
thread_policy,
))
}
}
pub fn append_untracked_worktree_added_for_rename_detection(
repo: &Repository,
index: &Index,
rules: &WorktreeRules,
mut entries: Vec<DiffEntry>,
) -> Result<(Vec<DiffEntry>, HashSet<String>)> {
let mut synthetic_new_paths = HashSet::new();
if !entries.iter().any(|e| e.status == DiffStatus::Deleted) {
return Ok((entries, synthetic_new_paths));
}
let mut existing_new: HashSet<String> =
entries.iter().filter_map(|e| e.new_path.clone()).collect();
let untracked = list_untracked_worktree_files_for_rename(repo, index, rules)?;
for path in untracked {
if existing_new.contains(&path) {
continue;
}
existing_new.insert(path.clone());
synthetic_new_paths.insert(path.clone());
entries.push(DiffEntry {
status: DiffStatus::Added,
old_path: None,
new_path: Some(path),
old_mode: "000000".to_owned(),
new_mode: "100644".to_owned(),
old_oid: zero_oid(),
new_oid: zero_oid(),
score: None,
});
}
Ok((entries, synthetic_new_paths))
}
#[must_use]
pub fn drop_unused_synthetic_rename_candidates(
entries: Vec<DiffEntry>,
synthetic_new_paths: &HashSet<String>,
) -> Vec<DiffEntry> {
if synthetic_new_paths.is_empty() {
return entries;
}
entries
.into_iter()
.filter(|e| {
!(e.status == DiffStatus::Added
&& e.new_path
.as_ref()
.is_some_and(|p| synthetic_new_paths.contains(p)))
})
.collect()
}
fn list_untracked_worktree_files_for_rename(
repo: &Repository,
index: &Index,
rules: &WorktreeRules,
) -> Result<Vec<String>> {
let work_tree = repo
.work_tree()
.ok_or_else(|| Error::NotARepository("bare repository".into()))?;
let mut out = Vec::new();
walk_untracked_worktree_files(repo, index, work_tree, "", rules, &mut out)?;
out.sort();
Ok(out)
}
fn walk_untracked_worktree_files(
repo: &Repository,
index: &Index,
dir: &Path,
rel_prefix: &str,
rules: &WorktreeRules,
out: &mut Vec<String>,
) -> Result<()> {
let read = match fs::read_dir(dir) {
Ok(r) => r,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(Error::Io(e)),
};
for entry in read {
let entry = entry.map_err(Error::Io)?;
let name = entry.file_name();
let name = name.to_string_lossy();
if rel_prefix.is_empty() && name == ".git" {
continue;
}
let rel_path = if rel_prefix.is_empty() {
name.to_string()
} else {
format!("{rel_prefix}/{name}")
};
let file_type = entry.file_type().map_err(Error::Io)?;
if file_type.is_dir() {
let (ignored, _) = rules
.ignore_mut()
.check_path(repo, Some(index), &rel_path, true)?;
if ignored {
continue;
}
walk_untracked_worktree_files(
repo,
index,
&dir.join(name.as_ref()),
&rel_path,
rules,
out,
)?;
} else if file_type.is_file() || file_type.is_symlink() {
if index.get(rel_path.as_bytes(), 0).is_some() {
continue;
}
let (ignored, _) =
rules
.ignore_mut()
.check_path(repo, Some(index), &rel_path, false)?;
if !ignored {
out.push(rel_path);
}
}
}
Ok(())
}
pub fn diff_index_to_worktree_with_renames(
repo: &Repository,
index: &mut Index,
work_tree: &Path,
options: DiffIndexToWorktreeOptions,
rename_threshold: u32,
) -> Result<(Vec<DiffEntry>, bool)> {
let config = repo.config()?.as_ref().clone();
let thread_policy = repo.thread_policy();
let odb = repo.odb();
let rules_for_append = options.worktree_rules.clone();
let (mut entries, changed) =
diff_index_to_worktree_with_options(odb, index, work_tree, options)?;
let mut synthetic_new_paths = HashSet::new();
if rename_threshold < 100 {
if let Some(rules_arc) = rules_for_append {
let rules = rules_arc
.lock()
.map_err(|e| Error::Message(format!("worktree rules lock poisoned: {e}")))?;
(entries, synthetic_new_paths) =
append_untracked_worktree_added_for_rename_detection(repo, index, &rules, entries)?;
} else {
let rules = WorktreeRules::from_repository(repo, index)?;
(entries, synthetic_new_paths) =
append_untracked_worktree_added_for_rename_detection(repo, index, &rules, entries)?;
}
}
let entries = apply_rename_detection_to_diff_entries(
odb,
entries,
Some(work_tree),
rename_threshold,
&config,
&thread_policy,
)?;
let entries = drop_unused_synthetic_rename_candidates(entries, &synthetic_new_paths);
Ok((entries, changed))
}
enum CacheTreeWalk<'a> {
Fast {
head_tree: &'a ObjectId,
cache_root: &'a CacheTreeNode,
},
Flatten,
}
fn index_cache_tree_walk<'a>(
index: &'a Index,
tree_oid: Option<&'a ObjectId>,
) -> CacheTreeWalk<'a> {
let Some(head_tree) = tree_oid else {
return CacheTreeWalk::Flatten;
};
let Some(cache_root) = index.cache_tree.as_ref() else {
return CacheTreeWalk::Flatten;
};
if index.entries.iter().any(|e| e.stage() != 0) {
return CacheTreeWalk::Flatten;
}
CacheTreeWalk::Fast {
head_tree,
cache_root,
}
}
fn diff_index_to_tree_flatten(
odb: &Odb,
index: &Index,
tree_oid: Option<&ObjectId>,
ignore_submodules: bool,
) -> 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();
let mut stage0_paths = std::collections::BTreeSet::new();
let mut unmerged_modes: std::collections::BTreeMap<String, (u8, u32)> =
std::collections::BTreeMap::new();
for ie in &index.entries {
let path = String::from_utf8_lossy(&ie.path).to_string();
if ie.stage() == 0 && ie.intent_to_add() {
continue;
}
if ie.stage() != 0 {
let rank = match ie.stage() {
2 => 0u8,
3 => 1u8,
1 => 2u8,
_ => 3u8,
};
match unmerged_modes.get(&path) {
Some((existing_rank, _)) if *existing_rank <= rank => {}
_ => {
unmerged_modes.insert(path, (rank, ie.mode));
}
}
continue;
}
if ignore_submodules && ie.mode == 0o160000 {
let _ = tree_map.remove(path.as_str());
stage0_paths.insert(path.clone());
continue;
}
stage0_paths.insert(path.clone());
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,
});
}
}
}
diff_index_to_tree_finish_unmerged_and_deleted(
ignore_submodules,
&mut tree_map,
&unmerged_modes,
&stage0_paths,
&mut result,
);
result.sort_by(|a, b| a.path().cmp(b.path()));
Ok(result)
}
fn diff_index_to_tree_finish_unmerged_and_deleted(
ignore_submodules: bool,
tree_map: &mut std::collections::BTreeMap<&str, &FlatEntry>,
unmerged_modes: &std::collections::BTreeMap<String, (u8, u32)>,
stage0_paths: &std::collections::BTreeSet<String>,
result: &mut Vec<DiffEntry>,
) {
for (path, (_, mode)) in unmerged_modes {
if stage0_paths.contains(path) {
continue;
}
tree_map.remove(path.as_str());
result.push(DiffEntry {
status: DiffStatus::Unmerged,
old_path: Some(path.clone()),
new_path: Some(path.clone()),
old_mode: "000000".to_owned(),
new_mode: format_mode(*mode),
old_oid: zero_oid(),
new_oid: zero_oid(),
score: None,
});
}
for (path, te) in std::mem::take(tree_map) {
if ignore_submodules && te.mode == 0o160000 {
continue;
}
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,
});
}
}
fn diff_index_to_tree_with_cache_tree(
odb: &Odb,
index: &Index,
head_tree: &ObjectId,
cache_root: &CacheTreeNode,
ignore_submodules: bool,
) -> Result<Vec<DiffEntry>> {
let mut result = Vec::new();
let mut idx_cursor = 0usize;
let mut walk = DiffIndexTreeWalk {
odb,
index,
idx_cursor: &mut idx_cursor,
ignore_submodules,
result: &mut result,
abort_to_flatten: false,
};
diff_index_tree_at(&mut walk, head_tree, Some(cache_root), b"")?;
if walk.abort_to_flatten {
return diff_index_to_tree_flatten(odb, index, Some(head_tree), ignore_submodules);
}
while idx_cursor < index.entries.len() {
let ie = &index.entries[idx_cursor];
idx_cursor += 1;
let path = String::from_utf8_lossy(&ie.path).to_string();
if ie.stage() == 0 && ie.intent_to_add() {
continue;
}
if ie.stage() != 0 {
continue;
}
if ignore_submodules && ie.mode == 0o160000 {
continue;
}
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,
});
}
result.sort_by(|a, b| a.path().cmp(b.path()));
Ok(result)
}
enum IndexLevelKind<'a> {
Blob {
entry: &'a IndexEntry,
name: &'a [u8],
},
Tree {
name: &'a [u8],
},
}
fn index_relative_in_dir<'a>(path: &'a [u8], dir_prefix: &[u8]) -> Option<&'a [u8]> {
if dir_prefix.is_empty() {
return Some(path);
}
let rest = path.strip_prefix(dir_prefix)?;
match rest.split_first() {
None => Some(rest),
Some((b'/', tail)) => Some(tail),
Some(_) => None,
}
}
fn join_index_path(dir_prefix: &[u8], name: &[u8]) -> Vec<u8> {
if dir_prefix.is_empty() {
return name.to_vec();
}
let mut path = Vec::with_capacity(dir_prefix.len() + 1 + name.len());
path.extend_from_slice(dir_prefix);
path.push(b'/');
path.extend_from_slice(name);
path
}
fn peek_index_level_entry<'a>(
index: &'a Index,
cursor: &mut usize,
dir_prefix: &[u8],
ignore_submodules: bool,
) -> Option<IndexLevelKind<'a>> {
while let Some(ie) = index.entries.get(*cursor) {
let rel = index_relative_in_dir(&ie.path, dir_prefix)?;
if ie.stage() != 0 || ie.intent_to_add() || rel.is_empty() {
*cursor += 1;
continue;
}
if let Some(slash) = rel.iter().position(|&b| b == b'/') {
return Some(IndexLevelKind::Tree {
name: &rel[..slash],
});
}
if ignore_submodules && ie.mode == 0o160000 {
*cursor += 1;
continue;
}
return Some(IndexLevelKind::Blob {
entry: ie,
name: rel,
});
}
None
}
fn skip_index_subtree(index: &Index, cursor: &mut usize, dir_prefix: &[u8]) {
while index
.entries
.get(*cursor)
.is_some_and(|ie| index_relative_in_dir(&ie.path, dir_prefix).is_some())
{
*cursor += 1;
}
}
const CACHE_CHILD_LINEAR_SCAN_MAX: usize = 16;
struct CacheChildren<'a> {
children: &'a [CacheTreeNode],
sorted: Vec<&'a CacheTreeNode>,
}
impl<'a> CacheChildren<'a> {
fn new(node: Option<&'a CacheTreeNode>) -> Self {
let children = node.map_or(&[][..], |n| n.children.as_slice());
let sorted = if children.len() > CACHE_CHILD_LINEAR_SCAN_MAX {
let mut sorted: Vec<&CacheTreeNode> = children.iter().collect();
sorted.sort_by(|a, b| a.name.cmp(&b.name));
sorted
} else {
Vec::new()
};
Self { children, sorted }
}
fn get(&self, name: &[u8]) -> Option<&'a CacheTreeNode> {
if self.sorted.is_empty() {
return self.children.iter().find(|c| c.name == name);
}
let pos = self.sorted.partition_point(|c| c.name.as_slice() < name);
self.sorted.get(pos).copied().filter(|c| c.name == name)
}
}
struct DiffIndexTreeWalk<'a, 'b> {
odb: &'a Odb,
index: &'a Index,
idx_cursor: &'b mut usize,
ignore_submodules: bool,
result: &'b mut Vec<DiffEntry>,
abort_to_flatten: bool,
}
fn tree_entries_have_duplicate_names(entries: &[TreeEntry]) -> bool {
let mut seen = std::collections::BTreeSet::<&[u8]>::new();
for entry in entries {
if !seen.insert(entry.name.as_slice()) {
return true;
}
}
false
}
fn diff_index_tree_at(
walk: &mut DiffIndexTreeWalk<'_, '_>,
tree_oid: &ObjectId,
cache_node: Option<&CacheTreeNode>,
dir_prefix: &[u8],
) -> Result<()> {
let tree_entries = read_tree(walk.odb, tree_oid)?;
if tree_entries_have_duplicate_names(&tree_entries) {
walk.abort_to_flatten = true;
return Ok(());
}
let cache_children = CacheChildren::new(cache_node);
let mut ti = 0usize;
loop {
let te = tree_entries.get(ti);
let ie_kind = peek_index_level_entry(
walk.index,
walk.idx_cursor,
dir_prefix,
walk.ignore_submodules,
);
match (te, ie_kind) {
(Some(t_entry), Some(ie)) => {
let t_name = &t_entry.name;
let (i_name, i_is_tree) = match &ie {
IndexLevelKind::Blob { name, .. } => (*name, false),
IndexLevelKind::Tree { name } => (*name, true),
};
let t_is_tree = is_tree_mode(t_entry.mode);
let cmp = crate::objects::tree_entry_cmp(t_name, t_is_tree, i_name, i_is_tree);
match cmp {
std::cmp::Ordering::Less => {
emit_index_tree_deleted(
walk.odb,
t_entry,
dir_prefix,
walk.ignore_submodules,
walk.result,
)?;
ti += 1;
}
std::cmp::Ordering::Greater => {
emit_index_tree_added(walk, dir_prefix)?;
}
std::cmp::Ordering::Equal => {
if t_is_tree && i_is_tree {
let full_path = join_index_path(dir_prefix, t_name);
let child_cache = cache_children.get(t_name);
if child_cache
.is_some_and(|c| c.is_valid() && c.oid == Some(t_entry.oid))
{
skip_index_subtree(walk.index, walk.idx_cursor, &full_path);
ti += 1;
continue;
}
diff_index_tree_at(walk, &t_entry.oid, child_cache, &full_path)?;
} else if t_is_tree != i_is_tree {
emit_index_tree_deleted(
walk.odb,
t_entry,
dir_prefix,
walk.ignore_submodules,
walk.result,
)?;
emit_index_tree_added(walk, dir_prefix)?;
} else {
let IndexLevelKind::Blob { entry: ie, .. } = ie else {
ti += 1;
continue;
};
*walk.idx_cursor += 1;
if t_entry.oid != ie.oid || t_entry.mode != ie.mode {
let path = String::from_utf8_lossy(&ie.path).into_owned();
walk.result.push(DiffEntry {
status: DiffStatus::Modified,
old_path: Some(path.clone()),
new_path: Some(path),
old_mode: format_mode(t_entry.mode),
new_mode: format_mode(ie.mode),
old_oid: t_entry.oid,
new_oid: ie.oid,
score: None,
});
}
}
ti += 1;
}
}
}
(Some(t_entry), None) => {
emit_index_tree_deleted(
walk.odb,
t_entry,
dir_prefix,
walk.ignore_submodules,
walk.result,
)?;
ti += 1;
}
(None, Some(_)) => {
emit_index_tree_added(walk, dir_prefix)?;
}
(None, None) => break,
}
}
Ok(())
}
fn emit_index_tree_deleted(
odb: &Odb,
entry: &TreeEntry,
dir_prefix: &[u8],
ignore_submodules: bool,
result: &mut Vec<DiffEntry>,
) -> Result<()> {
if ignore_submodules && entry.mode == 0o160000 {
return Ok(());
}
let path = String::from_utf8_lossy(&join_index_path(dir_prefix, &entry.name)).into_owned();
if is_tree_mode(entry.mode) {
for fe in flatten_tree(odb, &entry.oid, &path)? {
if ignore_submodules && fe.mode == 0o160000 {
continue;
}
result.push(DiffEntry {
status: DiffStatus::Deleted,
old_path: Some(fe.path.clone()),
new_path: None,
old_mode: format_mode(fe.mode),
new_mode: "000000".to_owned(),
old_oid: fe.oid,
new_oid: zero_oid(),
score: None,
});
}
} else {
result.push(DiffEntry {
status: DiffStatus::Deleted,
old_path: Some(path),
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_index_tree_added(walk: &mut DiffIndexTreeWalk<'_, '_>, dir_prefix: &[u8]) -> Result<()> {
let ie_kind = peek_index_level_entry(
walk.index,
walk.idx_cursor,
dir_prefix,
walk.ignore_submodules,
)
.ok_or_else(|| Error::IndexError("missing index entry during tree walk".to_owned()))?;
match ie_kind {
IndexLevelKind::Blob { entry: ie, .. } => {
let path = String::from_utf8_lossy(&ie.path).into_owned();
*walk.idx_cursor += 1;
walk.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,
});
}
IndexLevelKind::Tree { name } => {
let full_path = join_index_path(dir_prefix, name);
let start = *walk.idx_cursor;
skip_index_subtree(walk.index, walk.idx_cursor, &full_path);
for ie in &walk.index.entries[start..*walk.idx_cursor] {
if ie.stage() != 0 || ie.intent_to_add() {
continue;
}
if walk.ignore_submodules && ie.mode == 0o160000 {
continue;
}
let path = String::from_utf8_lossy(&ie.path).into_owned();
walk.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,
});
}
}
}
Ok(())
}
pub fn diff_index_to_worktree(
odb: &Odb,
index: &mut Index,
work_tree: &Path,
ignore_submodule_untracked: bool,
simplify_gitlinks: bool,
) -> Result<Vec<DiffEntry>> {
diff_index_to_worktree_with_options(
odb,
index,
work_tree,
DiffIndexToWorktreeOptions {
ignore_submodule_untracked,
simplify_gitlinks,
..DiffIndexToWorktreeOptions::default()
},
)
.map(|(entries, _)| entries)
}
#[derive(Clone)]
pub struct DiffIndexToWorktreeOptions {
pub index_mtime: Option<(u32, u32)>,
pub ignore_submodule_untracked: bool,
pub simplify_gitlinks: bool,
pub error_on_broken_gitlinks: bool,
pub repository_git_dir: Option<std::path::PathBuf>,
pub refresh_index_stat_in_pass: bool,
pub config: Option<std::sync::Arc<crate::config::ConfigSet>>,
pub stat_parallel_threads: Option<usize>,
pub thread_policy: Option<crate::parallel::ThreadPolicy>,
pub worktree_rules:
Option<std::sync::Arc<std::sync::Mutex<crate::worktree_rules::WorktreeRules>>>,
pub for_status: bool,
pub refresh_fsmonitor: bool,
pub command_runner: Option<std::sync::Arc<dyn crate::command_runner::CommandRunner>>,
pub fsmonitor_query_boundary: Option<crate::fsmonitor::FsmonitorQueryBoundary>,
pub restrict_to_paths: Option<std::sync::Arc<std::collections::BTreeSet<String>>>,
pub hash_blobs_without_write: bool,
}
impl Default for DiffIndexToWorktreeOptions {
fn default() -> Self {
Self {
index_mtime: None,
ignore_submodule_untracked: false,
simplify_gitlinks: false,
error_on_broken_gitlinks: false,
repository_git_dir: None,
refresh_index_stat_in_pass: false,
config: None,
stat_parallel_threads: None,
thread_policy: None,
worktree_rules: None,
for_status: false,
refresh_fsmonitor: true,
command_runner: None,
fsmonitor_query_boundary: None,
restrict_to_paths: None,
hash_blobs_without_write: false,
}
}
}
struct StagingHashJob {
entry_index: usize,
rel_path: String,
file_path: PathBuf,
entry: IndexEntry,
worktree_mode: u32,
file_attrs: crate::crlf::FileAttrs,
}
#[allow(clippy::too_many_arguments)]
fn parallel_process_blob_worktree_jobs(
odb: &Odb,
jobs: &[StagingHashJob],
thread_policy: &crate::parallel::ThreadPolicy,
config: &crate::config::ConfigSet,
trust_filemode: bool,
materialize_dirty_blobs: bool,
hash_blobs_without_write: bool,
conv: &crate::crlf::ConversionConfig,
filter_process: Option<&crate::filter_process::FilterProcessState>,
result: &mut Vec<DiffEntry>,
) -> Result<()> {
use crate::parallel::{try_par_map, ParallelHashError, ParallelOp};
if jobs.is_empty() {
return Ok(());
}
let mut par_opts = thread_policy.resolve(ParallelOp::IndexRefresh, config);
par_opts.min_items = 0;
par_opts.min_total_bytes = None;
let est_bytes = jobs.len().saturating_mul(8192);
let rows = try_par_map::<StagingHashJob, Option<(usize, DiffEntry)>, Error, _>(
jobs,
par_opts,
est_bytes,
|job| {
let data = read_regular_worktree_blob_bytes(
odb,
&job.file_path,
conv,
&job.file_attrs,
&job.rel_path,
Some(&job.entry),
filter_process,
)?;
let worktree_oid = if materialize_dirty_blobs {
if hash_blobs_without_write {
odb.hash(ObjectKind::Blob, &data)?
} else {
odb.write_with_options(
ObjectKind::Blob,
&data,
crate::odb::BULK_NEW_LOOSE_WRITE,
)?
}
} else {
odb.hash(ObjectKind::Blob, &data)?
};
let mut eff_oid = worktree_oid;
if eff_oid != job.entry.oid {
let raw_oid = odb.hash(ObjectKind::Blob, &data)?;
if raw_oid == job.entry.oid {
eff_oid = job.entry.oid;
}
}
let mode_differs = trust_filemode && job.worktree_mode != job.entry.mode;
if eff_oid != job.entry.oid || mode_differs {
let path_owned = job.rel_path.clone();
let new_oid = if eff_oid != job.entry.oid && !materialize_dirty_blobs {
zero_oid()
} else {
eff_oid
};
let staged_mode =
staged_index_file_mode(trust_filemode, job.entry.mode, job.worktree_mode);
Ok(Some((
job.entry_index,
DiffEntry {
status: DiffStatus::Modified,
old_path: Some(path_owned.clone()),
new_path: Some(path_owned),
old_mode: format_mode(job.entry.mode),
new_mode: format_mode(staged_mode),
old_oid: job.entry.oid,
new_oid,
score: None,
},
)))
} else {
Ok(None)
}
},
)
.map_err(|e| match e {
ParallelHashError::Task(err) => err,
})?;
let mut rows: Vec<(usize, DiffEntry)> = rows.into_iter().flatten().collect();
rows.sort_by_key(|(idx, _)| *idx);
result.extend(rows.into_iter().map(|(_, d)| d));
Ok(())
}
#[must_use]
fn try_push_status_modified_on_size_mismatch(
ie: &IndexEntry,
path_str_ref: &str,
meta: &fs::Metadata,
for_status: bool,
result: &mut Vec<DiffEntry>,
) -> bool {
if ie.stat_cache_unset() {
return false;
}
if !for_status || crate::index::munge_index_size(meta.len()) == ie.size {
return false;
}
let worktree_mode = mode_from_metadata(meta);
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: ie.oid,
score: None,
});
true
}
fn try_push_status_modified_on_size_mismatch_stat(
ie: &IndexEntry,
path_str_ref: &str,
stat: &crate::worktree_scan::BlobDiskStat,
for_status: bool,
result: &mut Vec<DiffEntry>,
) -> bool {
if ie.stat_cache_unset() {
return false;
}
if !for_status || stat.worktree_size() == ie.size {
return false;
}
let worktree_mode = stat.git_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: ie.oid,
score: None,
});
true
}
#[allow(clippy::too_many_arguments)]
fn push_index_blob_worktree_diff(
odb: &Odb,
ie: &IndexEntry,
path_str_ref: &str,
file_path: &Path,
meta: fs::Metadata,
index_mtime: Option<(u32, u32)>,
trust_filemode: bool,
stat_policy: crate::index::StatMatchPolicy,
file_attrs_for: &dyn Fn(&str) -> crate::crlf::FileAttrs,
conv: &crate::crlf::ConversionConfig,
materialize_dirty_blobs: bool,
hash_blobs_without_write: bool,
for_status: bool,
filter_process: Option<&crate::filter_process::FilterProcessState>,
result: &mut Vec<DiffEntry>,
) -> Result<()> {
if meta.is_dir() {
if file_path.join(".git").exists() {
let head = read_submodule_head_oid(file_path).unwrap_or_else(zero_oid);
let path_owned = path_str_ref.to_owned();
result.push(DiffEntry {
status: DiffStatus::TypeChanged,
old_path: Some(path_owned.clone()),
new_path: Some(path_owned),
old_mode: format_mode(ie.mode),
new_mode: format_mode(0o160000),
old_oid: ie.oid,
new_oid: head,
score: None,
});
return Ok(());
}
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: String::new(),
old_oid: ie.oid,
new_oid: zero_oid(),
score: None,
});
return Ok(());
}
let worktree_mode = mode_from_metadata(&meta);
let stat_same = stat_matches_with_policy(ie, &meta, stat_policy);
if trust_filemode && stat_same && 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: ie.oid,
score: None,
});
return Ok(());
}
if stat_same
&& (!trust_filemode || worktree_mode == ie.mode)
&& index_entry_skips_worktree_content_hash_after_stat_match(
ie,
index_mtime,
stat_policy,
trust_filemode,
)
{
return Ok(());
}
if try_push_status_modified_on_size_mismatch(ie, path_str_ref, &meta, for_status, result) {
return Ok(());
}
let file_attrs = file_attrs_for(path_str_ref);
let data = worktree_blob_bytes(
odb,
file_path,
&meta,
conv,
&file_attrs,
path_str_ref,
Some(ie),
filter_process,
)?;
let worktree_oid = if materialize_dirty_blobs {
if hash_blobs_without_write {
odb.hash(ObjectKind::Blob, &data)?
} else {
odb.write_with_options(ObjectKind::Blob, &data, crate::odb::BULK_NEW_LOOSE_WRITE)?
}
} else {
odb.hash(ObjectKind::Blob, &data)?
};
let mut eff_oid = worktree_oid;
if eff_oid != ie.oid {
let raw_oid = odb.hash(ObjectKind::Blob, &data)?;
if raw_oid == ie.oid {
eff_oid = ie.oid;
}
}
let mode_differs = trust_filemode && worktree_mode != ie.mode;
if eff_oid != ie.oid || mode_differs {
let path_owned = path_str_ref.to_owned();
let new_oid = if eff_oid != ie.oid && !materialize_dirty_blobs {
zero_oid()
} else {
eff_oid
};
let new_mode = if materialize_dirty_blobs {
staged_index_file_mode(trust_filemode, ie.mode, worktree_mode)
} else {
worktree_mode
};
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(new_mode),
old_oid: ie.oid,
new_oid,
score: None,
});
}
Ok(())
}
pub fn diff_index_to_worktree_with_options(
odb: &Odb,
index: &mut Index,
work_tree: &Path,
options: DiffIndexToWorktreeOptions,
) -> Result<(Vec<DiffEntry>, bool)> {
let default_git_dir = work_tree.join(".git");
let git_dir = options
.repository_git_dir
.clone()
.unwrap_or(default_git_dir);
diff_index_to_worktree_inner(odb, index, work_tree, &git_dir, options, false)
.map(|(entries, changed, _)| (entries, changed))
}
pub fn diff_index_to_worktree_for_staging(
odb: &Odb,
repository_git_dir: &Path,
index: &mut Index,
work_tree: &Path,
options: DiffIndexToWorktreeOptions,
) -> Result<(
Vec<DiffEntry>,
bool,
std::collections::BTreeMap<String, crate::index::IndexStatFields>,
)> {
diff_index_to_worktree_inner(odb, index, work_tree, repository_git_dir, options, true)
}
#[allow(clippy::too_many_arguments)]
fn worktree_diff_pass1_index_entry(
odb: &Odb,
work_tree: &Path,
ie: &IndexEntry,
path_str_ref: &str,
simplify_gitlinks: bool,
ignore_submodule_untracked: bool,
error_on_broken_gitlinks: bool,
materialize_dirty_blobs: bool,
filter_fp: Option<&crate::filter_process::FilterProcessState>,
conv: &crate::crlf::ConversionConfig,
file_attrs_for: &dyn Fn(&str) -> crate::crlf::FileAttrs,
precompose_unicode: bool,
ignorecase: bool,
result: &mut Vec<DiffEntry>,
) -> Result<()> {
if ie.skip_worktree() || ie.assume_unchanged() {
return Ok(());
}
let is_intent_to_add = ie.intent_to_add();
if ie.mode == 0o160000 {
let sub_dir =
index_entry_worktree_abs_bytes(work_tree, &ie.path, precompose_unicode, ignorecase);
match fs::symlink_metadata(&sub_dir) {
Err(e)
if e.kind() == std::io::ErrorKind::NotFound
|| e.raw_os_error() == Some(20) =>
{
if !simplify_gitlinks {
let path_owned = path_str_ref.to_owned();
result.push(DiffEntry {
status: DiffStatus::Deleted,
old_path: Some(path_owned.clone()),
new_path: Some(path_owned),
old_mode: format_mode(ie.mode),
new_mode: "000000".to_owned(),
old_oid: ie.oid,
new_oid: zero_oid(),
score: None,
});
}
return Ok(());
}
Err(e) => return Err(Error::Io(e)),
Ok(meta) if !meta.is_dir() => {
let worktree_mode = mode_from_metadata(&meta);
let file_attrs = file_attrs_for(path_str_ref);
let worktree_oid = worktree_file_oid(
odb,
&sub_dir,
&meta,
conv,
&file_attrs,
path_str_ref,
None,
materialize_dirty_blobs,
filter_fp,
)?;
let path_owned = path_str_ref.to_owned();
result.push(DiffEntry {
status: DiffStatus::TypeChanged,
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,
});
return Ok(());
}
Ok(_) => {}
}
let sub_head_oid = read_submodule_head_oid(&sub_dir);
let ref_matches = if let Some(oid) = sub_head_oid {
oid == ie.oid
} else {
let is_placeholder = submodule_worktree_is_unpopulated_placeholder(&sub_dir);
if error_on_broken_gitlinks
&& !is_placeholder
&& submodule_embedded_git_dir(&sub_dir).is_some()
{
return Err(Error::Config(
format!("could not read submodule HEAD for '{path_str_ref}'").into(),
));
}
is_placeholder
};
if simplify_gitlinks {
if !ref_matches {
let path_owned = path_str_ref.to_owned();
let new_oid = sub_head_oid.unwrap_or_else(zero_oid);
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(ie.mode),
old_oid: ie.oid,
new_oid,
score: None,
});
}
return Ok(());
}
if sub_head_oid.is_some() && submodule_head_object_broken(&sub_dir) {
return Err(Error::Config(
format!("'git status --porcelain=2' failed in submodule {path_str_ref}").into(),
));
}
let mut flags = submodule_porcelain_flags(work_tree, path_str_ref, ie.oid);
if ignore_submodule_untracked {
flags.untracked = false;
}
let inner_dirty = flags.modified || flags.untracked;
if !ref_matches || inner_dirty {
let path_owned = path_str_ref.to_owned();
let new_oid = if !ref_matches {
sub_head_oid.unwrap_or_else(zero_oid)
} else {
zero_oid()
};
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(ie.mode),
old_oid: ie.oid,
new_oid,
score: None,
});
}
return Ok(());
}
let file_path =
index_entry_worktree_abs_bytes(work_tree, &ie.path, precompose_unicode, ignorecase);
let path_match = crate::repo_relpath::as_utf8_for_matching(path_str_ref);
if is_intent_to_add {
match fs::symlink_metadata(&file_path) {
Ok(meta) => {
let file_attrs = file_attrs_for(path_match.as_ref());
let worktree_oid = worktree_file_oid(
odb,
&file_path,
&meta,
conv,
&file_attrs,
path_match.as_ref(),
None,
materialize_dirty_blobs,
filter_fp,
)?;
let worktree_mode = mode_from_metadata(&meta);
result.push(DiffEntry {
status: DiffStatus::Added,
old_path: None,
new_path: Some(path_str_ref.to_owned()),
old_mode: "000000".to_owned(),
new_mode: format_mode(worktree_mode),
old_oid: zero_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(())
}
#[inline]
fn index_entry_needs_worktree_diff_pass1(ie: &IndexEntry) -> bool {
ie.mode == crate::index::MODE_GITLINK || ie.intent_to_add()
}
fn diff_index_to_worktree_inner(
odb: &Odb,
index: &mut Index,
work_tree: &Path,
repository_git_dir: &Path,
options: DiffIndexToWorktreeOptions,
materialize_dirty_blobs: bool,
) -> Result<(
Vec<DiffEntry>,
bool,
std::collections::BTreeMap<String, crate::index::IndexStatFields>,
)> {
use crate::config::ConfigSet;
use crate::crlf;
use std::collections::BTreeMap;
let mut staging_stat_cache = BTreeMap::new();
let index_mtime = index_mtime_for_diff(index, options.index_mtime);
let ignore_submodule_untracked = options.ignore_submodule_untracked;
let simplify_gitlinks = options.simplify_gitlinks;
let rules_lock = options
.worktree_rules
.as_ref()
.map(|shared| {
shared
.lock()
.map_err(|e| Error::Message(format!("worktree rules lock poisoned: {e}")))
})
.transpose()?;
let config = if let Some(rules) = rules_lock.as_ref() {
rules.config().clone()
} else if let Some(c) = options.config.as_ref() {
c.as_ref().clone()
} else {
ConfigSet::load(
&crate::environment::Environment::empty(),
Some(repository_git_dir),
true,
)
.unwrap_or_else(|_| ConfigSet::new())
};
let conv = rules_lock
.as_ref()
.map(|r| r.conversion().clone())
.unwrap_or_else(|| crlf::ConversionConfig::from_config(&config));
let legacy_attrs = if rules_lock.is_none() {
Some(crlf::load_gitattributes(work_tree))
} else {
None
};
let file_attrs_for = |path: &str| -> crlf::FileAttrs {
if let Some(rules) = rules_lock.as_ref() {
rules.file_attrs(path, false)
} else {
crlf::get_file_attrs(legacy_attrs.as_deref().unwrap_or(&[]), path, false, &config)
}
};
let filter_fp = rules_lock.as_ref().map(|r| r.filter_process());
let precompose_unicode = config
.get_bool("core.precomposeunicode")
.and_then(|r| r.ok())
.unwrap_or(false);
let trust_filemode = config
.get_bool("core.filemode")
.and_then(|r| r.ok())
.unwrap_or(true);
let stat_policy = crate::index::StatMatchPolicy::from_config(Some(&config));
let ignorecase = config
.get_bool("core.ignorecase")
.and_then(|r| r.ok())
.unwrap_or(false);
let hash_blobs_without_write = options.hash_blobs_without_write;
let mut result = Vec::new();
let refresh_index_stat_in_pass = options.refresh_index_stat_in_pass;
let mut index_stat_refresh_changed = false;
let mut honor_fsmonitor_valid = false;
if options.refresh_fsmonitor {
let default_runner = crate::command_runner::system_command_runner();
let runner = options
.command_runner
.as_deref()
.unwrap_or(default_runner.as_ref());
let boundary = options.fsmonitor_query_boundary.unwrap_or_else(|| {
crate::fsmonitor::FsmonitorQueryBoundary(
crate::environment::wall_clock_unix_timestamp_nanos(),
)
});
let refresh_opts = crate::fsmonitor::FsmonitorRefreshOptions::with_query_boundary(boundary);
let refresh = crate::fsmonitor::refresh_index(
index,
repository_git_dir,
work_tree,
&config,
Some(runner),
&refresh_opts,
)?;
if refresh.index_changed {
index_stat_refresh_changed = true;
}
honor_fsmonitor_valid = refresh.honor_valid_bits;
}
let use_fsmonitor_stat_skip = honor_fsmonitor_valid;
let mut unmerged_base: std::collections::BTreeMap<String, (u8, IndexEntry)> =
std::collections::BTreeMap::new();
let mut dir_symlinks = SymlinkDirCache::default();
let error_on_broken_gitlinks = options.error_on_broken_gitlinks;
if options.restrict_to_paths.is_none() {
for ie in &index.entries {
if ie.stage() != 0 {
let path = String::from_utf8_lossy(&ie.path).to_string();
let rank = match ie.stage() {
2 => 0u8,
3 => 1u8,
1 => 2u8,
_ => 3u8,
};
match unmerged_base.get(&path) {
Some((existing_rank, _)) if *existing_rank <= rank => {}
_ => {
unmerged_base.insert(path, (rank, ie.clone()));
}
}
}
}
}
if let Some(limit) = options.restrict_to_paths.as_ref() {
for path in limit.iter() {
let Some(ie) = index.get(path.as_bytes(), 0) else {
continue;
};
if !index_entry_needs_worktree_diff_pass1(ie) {
continue;
}
worktree_diff_pass1_index_entry(
odb,
work_tree,
ie,
path.as_str(),
simplify_gitlinks,
ignore_submodule_untracked,
error_on_broken_gitlinks,
materialize_dirty_blobs,
filter_fp,
&conv,
&file_attrs_for,
precompose_unicode,
ignorecase,
&mut result,
)?;
}
} else {
for ie in &index.entries {
if ie.stage() != 0 {
continue;
}
if !index_entry_needs_worktree_diff_pass1(ie) {
continue;
}
let path_stored = crate::repo_relpath::storage_string(&ie.path);
worktree_diff_pass1_index_entry(
odb,
work_tree,
ie,
&path_stored,
simplify_gitlinks,
ignore_submodule_untracked,
error_on_broken_gitlinks,
materialize_dirty_blobs,
filter_fp,
&conv,
&file_attrs_for,
precompose_unicode,
ignorecase,
&mut result,
)?;
}
}
use crate::worktree_scan::{
blob_lookup_batch_entry_indices, collect_blob_lookup_batches, group_blob_entries_by_dir,
stat_scan_thread_count, visit_blob_lookup_batches, BlobDiskLookup, BlobScanSparseMode,
WorktreeBlobScanOptions, PARALLEL_STAT_MIN_ENTRIES,
};
let preload_index = config
.get_bool("core.preloadIndex")
.and_then(|r| r.ok())
.unwrap_or(true);
let blob_scan = WorktreeBlobScanOptions {
preload_index,
stat_parallel_threads: options.stat_parallel_threads,
};
let blob_dirs = if let Some(limit) = options.restrict_to_paths.as_ref() {
crate::worktree_scan::group_blob_entries_by_dir_for_paths(index, limit)
} else {
group_blob_entries_by_dir(index, honor_fsmonitor_valid)
};
let work_tree_owned = work_tree.to_path_buf();
let dir_abs = move |dir: &[u8]| {
if dir.is_empty() {
work_tree_owned.clone()
} else {
index_entry_worktree_abs_bytes(&work_tree_owned, dir, precompose_unicode, ignorecase)
}
};
let work_tree_for_files = work_tree.to_path_buf();
let file_abs = move |rel: &[u8]| {
index_entry_worktree_abs_bytes(&work_tree_for_files, rel, precompose_unicode, ignorecase)
};
let blob_count = blob_dirs.len();
let thread_policy = options.thread_policy.unwrap_or_default();
let hash_threads = stat_scan_thread_count(&thread_policy, &config, blob_scan, blob_count);
let parallel_blob_work = blob_count >= PARALLEL_STAT_MIN_ENTRIES
&& hash_threads > 1
&& (materialize_dirty_blobs || options.for_status);
let mut staging_hash_jobs: Vec<StagingHashJob> = Vec::new();
let mut stat_refresh_verified = vec![false; index.entries.len()];
let sparse = if parallel_blob_work {
BlobScanSparseMode::StatusDiff {
stat_policy,
index_mtime,
trust_filemode,
for_status: options.for_status,
refresh_index_stat_in_pass,
parallel_blob_work,
honor_fsmonitor_stat_skip: use_fsmonitor_stat_skip,
}
} else {
BlobScanSparseMode::Dense
};
let blob_stat_batches = collect_blob_lookup_batches(
index,
&blob_dirs,
blob_scan,
&thread_policy,
&config,
true,
None,
dir_abs,
file_abs,
|rel_path| {
dir_symlinks.has_symlink_in_path_bytes(
work_tree,
rel_path,
precompose_unicode,
ignorecase,
)
},
sparse,
)?;
let retained_indices = blob_lookup_batch_entry_indices(&blob_stat_batches);
visit_blob_lookup_batches(blob_stat_batches, |entry_index, lookup| {
let path_owned = crate::repo_relpath::storage_string(&index.entries[entry_index].path);
let path_str_ref = path_owned.as_str();
if use_fsmonitor_stat_skip {
let ie_skip = &index.entries[entry_index];
if crate::fsmonitor::entry_skips_worktree_stat(ie_skip, index) {
return Ok(());
}
}
match lookup {
BlobDiskLookup::Missing => {
let ie = &index.entries[entry_index];
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,
});
}
BlobDiskLookup::Io(e) => return Err(Error::Io(e)),
BlobDiskLookup::Present(stat) => {
if materialize_dirty_blobs {
staging_stat_cache.insert(path_str_ref.to_owned(), stat.fields);
}
if refresh_index_stat_in_pass {
let ie_ref = &index.entries[entry_index];
let stat_matches = stat_matches_blob_stat(ie_ref, &stat, stat_policy);
let worktree_size = stat.worktree_size();
let skip_refresh_for_status_diff = options.for_status
&& !stat_matches
&& worktree_size != ie_ref.size
&& !ie_ref.stat_cache_unset();
let skip_refresh_clean_status = options.for_status
&& stat_matches
&& !entry_is_racy_with_policy(ie_ref, index_mtime, stat_policy);
if skip_refresh_clean_status && index_stat_cache_trustworthy(index_mtime) {
stat_refresh_verified[entry_index] = true;
}
if !skip_refresh_for_status_diff && !skip_refresh_clean_status {
let path_owned = path_str_ref.to_owned();
let path_bytes = index.entries[entry_index].path.as_slice();
let file_path = index_entry_worktree_abs_bytes(
work_tree,
path_bytes,
precompose_unicode,
ignorecase,
);
let ie = &mut index.entries[entry_index];
let meta = fs::symlink_metadata(&file_path).map_err(Error::Io)?;
let refresh = refresh_index_blob_stat_from_metadata(
odb,
ie,
&meta,
&path_owned,
&file_path,
index_mtime,
&conv,
&file_attrs_for,
stat_policy,
filter_fp,
);
if refresh.index_changed {
index_stat_refresh_changed = true;
}
if refresh.content_verified {
stat_refresh_verified[entry_index] = true;
}
if index.fsmonitor_last_update.is_some()
&& stat_matches_blob_stat(
&index.entries[entry_index],
&stat,
stat_policy,
)
&& !refresh.index_changed
{
index.entries[entry_index].set_fsmonitor_valid(true);
}
}
}
let ie = &index.entries[entry_index];
if parallel_blob_work {
let worktree_mode = stat.git_mode;
let stat_same = stat_matches_blob_stat(ie, &stat, stat_policy);
let racy = entry_is_racy_with_policy(ie, index_mtime, stat_policy);
if stat_same
&& !racy
&& (!trust_filemode || worktree_mode == ie.mode)
&& index_entry_skips_worktree_content_hash_after_stat_match(
ie,
index_mtime,
stat_policy,
trust_filemode,
)
{
return Ok(());
}
if trust_filemode && stat_same && 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: ie.oid,
score: None,
});
} else if try_push_status_modified_on_size_mismatch_stat(
ie,
path_str_ref,
&stat,
options.for_status,
&mut result,
) {
} else if !(stat_same && (!trust_filemode || worktree_mode == ie.mode) && !racy)
&& !stat_refresh_verified[entry_index]
{
let file_path = index_entry_worktree_abs_bytes(
work_tree,
&index.entries[entry_index].path,
precompose_unicode,
ignorecase,
);
staging_hash_jobs.push(StagingHashJob {
entry_index,
rel_path: path_str_ref.to_owned(),
file_path,
entry: ie.clone(),
worktree_mode,
file_attrs: file_attrs_for(path_str_ref),
});
}
} else {
if !stat.is_dir
&& stat_matches_blob_stat(ie, &stat, stat_policy)
&& (!trust_filemode || stat.git_mode == ie.mode)
&& index_entry_skips_worktree_content_hash_after_stat_match(
ie,
index_mtime,
stat_policy,
trust_filemode,
)
{
return Ok(());
}
let file_path = index_entry_worktree_abs_bytes(
work_tree,
&index.entries[entry_index].path,
precompose_unicode,
ignorecase,
);
let meta = fs::symlink_metadata(&file_path).map_err(Error::Io)?;
push_index_blob_worktree_diff(
odb,
ie,
path_str_ref,
&file_path,
meta,
index_mtime,
trust_filemode,
stat_policy,
&file_attrs_for,
&conv,
materialize_dirty_blobs,
hash_blobs_without_write,
options.for_status,
filter_fp,
&mut result,
)?;
}
}
}
Ok(())
})?;
if parallel_blob_work
&& refresh_index_stat_in_pass
&& options.for_status
&& index_stat_cache_trustworthy(index_mtime)
{
for &entry_index in &blob_dirs.indices {
if retained_indices.contains(&entry_index) {
continue;
}
let ie = &index.entries[entry_index];
if !index_entry_needs_worktree_diff_pass1(ie) {
continue;
}
if !entry_is_racy_with_policy(ie, index_mtime, stat_policy) {
stat_refresh_verified[entry_index] = true;
}
}
}
if parallel_blob_work && !staging_hash_jobs.is_empty() {
parallel_process_blob_worktree_jobs(
odb,
&staging_hash_jobs,
&thread_policy,
&config,
trust_filemode,
materialize_dirty_blobs,
hash_blobs_without_write,
&conv,
filter_fp,
&mut result,
)?;
}
for (path, (_, base_entry)) in unmerged_base {
let file_path = index_entry_worktree_abs(work_tree, &path, precompose_unicode, ignorecase);
let wt_meta = match fs::symlink_metadata(&file_path) {
Ok(meta) => Some(meta),
Err(e)
if e.kind() == std::io::ErrorKind::NotFound
|| e.raw_os_error() == Some(20) =>
{
None
}
Err(e) => return Err(Error::Io(e)),
};
let new_mode = wt_meta.as_ref().map_or_else(
|| "000000".to_owned(),
|meta| format_mode(mode_from_metadata(meta)),
);
result.push(DiffEntry {
status: DiffStatus::Unmerged,
old_path: Some(path.clone()),
new_path: Some(path.clone()),
old_mode: "000000".to_owned(),
new_mode,
old_oid: zero_oid(),
new_oid: zero_oid(),
score: None,
});
if !options.for_status {
if let Some(meta) = wt_meta {
let file_attrs = file_attrs_for(&path);
let wt_oid = worktree_file_oid(
odb,
&file_path,
&meta,
&conv,
&file_attrs,
&path,
Some(&base_entry),
materialize_dirty_blobs,
filter_fp,
)?;
let wt_mode = mode_from_metadata(&meta);
if wt_oid != base_entry.oid || wt_mode != base_entry.mode {
result.push(DiffEntry {
status: DiffStatus::Modified,
old_path: Some(path.clone()),
new_path: Some(path),
old_mode: format_mode(base_entry.mode),
new_mode: format_mode(wt_mode),
old_oid: base_entry.oid,
new_oid: wt_oid,
score: None,
});
}
}
}
}
Ok((result, index_stat_refresh_changed, staging_stat_cache))
}
#[derive(Default)]
pub(crate) struct SymlinkDirCache {
symlink: std::collections::HashSet<String>,
plain: std::collections::HashSet<String>,
}
pub(crate) fn index_entry_worktree_abs(
work_tree: &Path,
index_relpath: &str,
precompose_unicode: bool,
ignorecase: bool,
) -> PathBuf {
index_entry_worktree_abs_bytes(
work_tree,
index_relpath.as_bytes(),
precompose_unicode,
ignorecase,
)
}
pub(crate) fn index_entry_worktree_abs_bytes(
work_tree: &Path,
index_relpath: &[u8],
precompose_unicode: bool,
ignorecase: bool,
) -> PathBuf {
if precompose_unicode || ignorecase {
if let Ok(s) = std::str::from_utf8(index_relpath) {
if precompose_unicode {
return crate::unicode_normalization::resolve_worktree_path_for_staging(
work_tree, s, true,
)
.abs;
}
return crate::path_icase::worktree_path_for_index_entry(work_tree, s, ignorecase);
}
}
crate::index::worktree_path_from_index_rel(work_tree, index_relpath)
}
#[must_use]
pub fn index_mtime_for_diff(index: &Index, explicit: Option<(u32, u32)>) -> Option<(u32, u32)> {
explicit.or(index.source_mtime)
}
impl SymlinkDirCache {
fn probe_prefix(
&mut self,
work_tree: &Path,
prefix: &str,
precompose_unicode: bool,
ignorecase: bool,
) -> bool {
if self.symlink.contains(prefix) {
return true;
}
if self.plain.contains(prefix) {
return false;
}
let abs = index_entry_worktree_abs(work_tree, prefix, precompose_unicode, ignorecase);
let is_symlink = match fs::symlink_metadata(&abs) {
Ok(meta) => meta.file_type().is_symlink(),
_ => false,
};
if is_symlink {
self.symlink.insert(prefix.to_owned());
} else {
self.plain.insert(prefix.to_owned());
}
is_symlink
}
pub(crate) fn has_symlink_in_path(
&mut self,
work_tree: &Path,
rel_path: &str,
precompose_unicode: bool,
ignorecase: bool,
) -> bool {
let components: Vec<&str> = rel_path.split('/').collect();
let mut prefix = String::new();
for component in &components[..components.len().saturating_sub(1)] {
if !prefix.is_empty() {
prefix.push('/');
}
prefix.push_str(component);
if self.symlink.contains(&prefix) {
return true;
}
if self.plain.contains(&prefix) {
continue;
}
if self.probe_prefix(work_tree, &prefix, precompose_unicode, ignorecase) {
return true;
}
}
false
}
pub(crate) fn has_symlink_in_path_bytes(
&mut self,
work_tree: &Path,
rel_path: &[u8],
precompose_unicode: bool,
ignorecase: bool,
) -> bool {
if let Ok(s) = std::str::from_utf8(rel_path) {
return self.has_symlink_in_path(work_tree, s, precompose_unicode, ignorecase);
}
let mut prefix = Vec::<u8>::new();
let mut start = 0usize;
let end = rel_path
.iter()
.rposition(|&b| b == b'/')
.unwrap_or(rel_path.len());
while start < end {
let next = rel_path[start..end]
.iter()
.position(|&b| b == b'/')
.map_or(end, |p| start + p);
let component = &rel_path[start..next];
start = next + 1;
if component.is_empty() {
continue;
}
if !prefix.is_empty() {
prefix.push(b'/');
}
prefix.extend_from_slice(component);
let key = crate::repo_relpath::storage_string(&prefix);
if self.symlink.contains(&key) {
return true;
}
if self.plain.contains(&key) {
continue;
}
let abs =
index_entry_worktree_abs_bytes(work_tree, &prefix, precompose_unicode, ignorecase);
let is_symlink = match fs::symlink_metadata(&abs) {
Ok(meta) => meta.file_type().is_symlink(),
_ => false,
};
if is_symlink {
self.symlink.insert(key);
return true;
}
self.plain.insert(key);
}
false
}
}
#[must_use]
pub fn entry_is_racy(ie: &IndexEntry, index_mtime: Option<(u32, u32)>) -> bool {
entry_is_racy_with_policy(ie, index_mtime, crate::index::StatMatchPolicy::default())
}
#[must_use]
pub fn entry_is_racy_with_policy(
ie: &IndexEntry,
index_mtime: Option<(u32, u32)>,
policy: crate::index::StatMatchPolicy,
) -> bool {
let Some((index_mtime_sec, index_mtime_nsec)) = index_mtime else {
return false;
};
if index_mtime_sec == 0 {
return false;
}
if policy.use_nanosec {
index_mtime_sec < ie.mtime_sec
|| (index_mtime_sec == ie.mtime_sec && index_mtime_nsec <= ie.mtime_nsec)
} else {
index_mtime_sec <= ie.mtime_sec
}
}
pub fn smudge_racily_clean_entries(
odb: &Odb,
git_dir: &Path,
index: &mut Index,
work_tree: &Path,
index_mtime: Option<(u32, u32)>,
config: Option<&ConfigSet>,
filter_process: Option<&crate::filter_process::FilterProcessState>,
) -> bool {
use crate::crlf;
use crate::index::{MODE_EXECUTABLE, MODE_REGULAR, MODE_SYMLINK};
let Some(index_mtime) = index_mtime else {
return false;
};
let config = config.cloned().unwrap_or_else(|| {
ConfigSet::load(
&crate::environment::Environment::empty(),
Some(git_dir),
true,
)
.unwrap_or_default()
});
let conv = crlf::ConversionConfig::from_config(&config);
let attrs = crlf::load_gitattributes(work_tree);
let mut changed = false;
for ie in &mut index.entries {
if ie.stage() != 0 || ie.skip_worktree() || ie.assume_unchanged() || ie.intent_to_add() {
continue;
}
if ie.mode != MODE_REGULAR && ie.mode != MODE_EXECUTABLE && ie.mode != MODE_SYMLINK {
continue;
}
if ie.size == 0 {
continue;
}
if !entry_is_racy(ie, Some(index_mtime)) {
continue;
}
let Ok(path) = std::str::from_utf8(&ie.path) else {
continue;
};
let abs = work_tree.join(path);
let Ok(meta) = fs::symlink_metadata(&abs) else {
continue;
};
if !stat_matches(ie, &meta) {
continue;
}
let file_attrs = crlf::get_file_attrs(&attrs, path, false, &config);
let content_matches = hash_worktree_file(
odb,
&abs,
&meta,
&conv,
&file_attrs,
path,
Some(ie),
filter_process,
)
.map(|oid| oid == ie.oid)
.unwrap_or(false);
if content_matches {
continue;
}
ie.size = 0;
changed = true;
}
changed
}
pub fn worktree_differs_from_index_entry(
odb: &Odb,
repository_git_dir: &Path,
work_tree: &Path,
ie: &IndexEntry,
ignore_submodule_untracked: bool,
config: Option<&ConfigSet>,
filter_process: Option<&crate::filter_process::FilterProcessState>,
) -> Result<bool> {
use crate::config::ConfigSet;
use crate::crlf;
let path_stored = crate::repo_relpath::storage_string(&ie.path);
let path_str_ref = crate::repo_relpath::as_utf8_for_matching(&path_stored);
let config = config.cloned().unwrap_or_else(|| {
ConfigSet::load(
&crate::environment::Environment::empty(),
Some(repository_git_dir),
true,
)
.unwrap_or_else(|_| ConfigSet::new())
});
let trust_filemode = config
.get_bool("core.filemode")
.and_then(|r| r.ok())
.unwrap_or(true);
let precompose_unicode = config
.get_bool("core.precomposeunicode")
.and_then(|r| r.ok())
.unwrap_or(false);
let ignorecase = config
.get_bool("core.ignorecase")
.and_then(|r| r.ok())
.unwrap_or(false);
let file_path =
index_entry_worktree_abs_bytes(work_tree, &ie.path, precompose_unicode, ignorecase);
if ie.mode == 0o160000 {
let sub_head_oid = read_submodule_head(&file_path);
let ref_matches = match sub_head_oid {
Some(oid) => oid == ie.oid,
None => submodule_worktree_is_unpopulated_placeholder(&file_path),
};
let mut flags = submodule_porcelain_flags(work_tree, path_str_ref.as_ref(), ie.oid);
if ignore_submodule_untracked {
flags.untracked = false;
}
return Ok(!ref_matches || flags.modified || flags.untracked);
}
let meta = match fs::symlink_metadata(&file_path) {
Ok(m) => m,
Err(e)
if e.kind() == std::io::ErrorKind::NotFound
|| e.raw_os_error() == Some(20) =>
{
return Ok(true);
}
Err(e) => return Err(Error::Io(e)),
};
if meta.is_dir() {
return Ok(true);
}
let worktree_mode = mode_from_metadata(&meta);
if trust_filemode && worktree_mode != ie.mode {
return Ok(true);
}
let conv = crlf::ConversionConfig::from_config(&config);
let attrs = crlf::load_gitattributes(work_tree);
let file_attrs = crlf::get_file_attrs(&attrs, path_str_ref.as_ref(), false, &config);
let worktree_oid = hash_worktree_file(
odb,
&file_path,
&meta,
&conv,
&file_attrs,
path_str_ref.as_ref(),
Some(ie),
filter_process,
)?;
let mut eff_oid = worktree_oid;
if eff_oid != ie.oid {
if let Ok(raw) = fs::read(&file_path) {
let raw_oid = odb.hash(ObjectKind::Blob, &raw)?;
if raw_oid == ie.oid {
eff_oid = ie.oid;
}
}
}
Ok(eff_oid != ie.oid)
}
pub struct WorktreeAddRefreshParams<'a> {
pub odb: &'a Odb,
pub ie: &'a IndexEntry,
pub meta: &'a fs::Metadata,
pub abs_path: &'a Path,
pub rel_path: &'a str,
pub conv: &'a crate::crlf::ConversionConfig,
pub file_attrs: &'a crate::crlf::FileAttrs,
pub index_mtime: Option<(u32, u32)>,
pub staged_mode: u32,
pub filter_process: Option<&'a crate::filter_process::FilterProcessState>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WorktreeAddRefresh {
UpToDate,
ModeOnly {
mode: u32,
},
StatOnly,
NeedsRestage,
}
#[must_use]
pub(crate) fn index_stat_cache_trustworthy(index_mtime: Option<(u32, u32)>) -> bool {
match index_mtime {
None => false,
Some((0, _)) => false,
Some(_) => true,
}
}
#[must_use]
pub(crate) fn index_entry_skips_worktree_content_hash_after_stat_match(
ie: &IndexEntry,
index_mtime: Option<(u32, u32)>,
stat_policy: crate::index::StatMatchPolicy,
trust_filemode: bool,
) -> bool {
if !trust_filemode || ie.stat_cache_unset() || ie.size == 0 {
return false;
}
if !index_stat_cache_trustworthy(index_mtime) {
return false;
}
!entry_is_racy_with_policy(ie, index_mtime, stat_policy)
}
pub fn classify_worktree_entry_for_add(
params: &WorktreeAddRefreshParams<'_>,
) -> Result<WorktreeAddRefresh> {
use crate::index::{MODE_EXECUTABLE, MODE_REGULAR, MODE_SYMLINK};
let ie = params.ie;
let meta = params.meta;
let staged_mode = params.staged_mode;
if ie.intent_to_add() {
return Ok(WorktreeAddRefresh::NeedsRestage);
}
let racy = entry_is_racy(ie, params.index_mtime);
let stat_same = stat_matches(ie, meta);
let trust_stat = index_stat_cache_trustworthy(params.index_mtime);
if ie.mode == MODE_SYMLINK {
if !meta.file_type().is_symlink() {
return Ok(WorktreeAddRefresh::NeedsRestage);
}
if stat_same && !racy && trust_stat && staged_mode == ie.mode {
return Ok(WorktreeAddRefresh::UpToDate);
}
if stat_same && !racy && trust_stat && staged_mode != ie.mode {
return Ok(WorktreeAddRefresh::ModeOnly { mode: staged_mode });
}
return worktree_effective_oid_matches_index(params);
}
if ie.mode != MODE_REGULAR && ie.mode != MODE_EXECUTABLE {
return Ok(WorktreeAddRefresh::NeedsRestage);
}
if meta.file_type().is_symlink() || meta.is_dir() {
return Ok(WorktreeAddRefresh::NeedsRestage);
}
if stat_same && !racy && trust_stat {
if staged_mode == ie.mode {
return Ok(WorktreeAddRefresh::UpToDate);
}
return Ok(WorktreeAddRefresh::ModeOnly { mode: staged_mode });
}
worktree_effective_oid_matches_index(params)
}
fn worktree_effective_oid_matches_index(
params: &WorktreeAddRefreshParams<'_>,
) -> Result<WorktreeAddRefresh> {
let WorktreeAddRefreshParams {
odb,
ie,
meta,
abs_path,
rel_path,
conv,
file_attrs,
staged_mode,
..
} = params;
let worktree_oid = hash_worktree_file(
odb,
abs_path,
meta,
conv,
file_attrs,
rel_path,
Some(ie),
params.filter_process,
)?;
let mut eff_oid = worktree_oid;
if eff_oid != ie.oid {
if let Ok(raw) = fs::read(abs_path) {
let raw_oid = odb.hash(ObjectKind::Blob, &raw)?;
if raw_oid == ie.oid {
eff_oid = ie.oid;
}
}
}
if eff_oid != ie.oid {
return Ok(WorktreeAddRefresh::NeedsRestage);
}
if *staged_mode != ie.mode {
return Ok(WorktreeAddRefresh::ModeOnly { mode: *staged_mode });
}
if stat_matches(ie, meta) {
Ok(WorktreeAddRefresh::UpToDate)
} else {
Ok(WorktreeAddRefresh::StatOnly)
}
}
#[must_use]
pub fn stat_matches(ie: &IndexEntry, meta: &fs::Metadata) -> bool {
stat_matches_with_policy(ie, meta, crate::index::StatMatchPolicy::default())
}
pub(crate) fn stat_matches_blob_stat(
ie: &IndexEntry,
stat: &crate::worktree_scan::BlobDiskStat,
policy: crate::index::StatMatchPolicy,
) -> bool {
stat.fields.matches_index_entry(ie, policy)
}
#[must_use]
pub fn stat_matches_with_policy(
ie: &IndexEntry,
meta: &fs::Metadata,
policy: crate::index::StatMatchPolicy,
) -> bool {
if crate::index::munge_index_size(meta.len()) != ie.size {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if meta.mtime() as u32 != ie.mtime_sec {
return false;
}
if policy.use_nanosec && policy.check_stat && meta.mtime_nsec() as u32 != ie.mtime_nsec {
return false;
}
if policy.trust_ctime && policy.check_stat {
if meta.ctime() as u32 != ie.ctime_sec {
return false;
}
if policy.use_nanosec && meta.ctime_nsec() as u32 != ie.ctime_nsec {
return false;
}
}
if policy.check_stat && meta.ino() as u32 != ie.ino {
return false;
}
}
#[cfg(not(unix))]
{
let ((ctime_sec, ctime_nsec), (mtime_sec, mtime_nsec)) = crate::index::metadata_times(meta);
if mtime_sec != ie.mtime_sec {
return false;
}
if policy.use_nanosec && policy.check_stat && mtime_nsec != ie.mtime_nsec {
return false;
}
if policy.trust_ctime && policy.check_stat {
if ctime_sec != ie.ctime_sec {
return false;
}
if policy.use_nanosec && ctime_nsec != ie.ctime_nsec {
return false;
}
}
}
true
}
pub fn refresh_index_stat_content_verified(
odb: &Odb,
git_dir: &Path,
index: &mut Index,
work_tree: &Path,
index_mtime: Option<(u32, u32)>,
config: Option<&ConfigSet>,
_stat_parallel_threads: Option<usize>,
) -> Result<bool> {
let _ = _stat_parallel_threads;
refresh_index_stat_content_verified_with_rules(
odb,
git_dir,
index,
work_tree,
index_mtime,
config,
_stat_parallel_threads,
None,
)
}
#[allow(clippy::too_many_arguments)]
pub fn refresh_index_stat_content_verified_with_rules(
odb: &Odb,
git_dir: &Path,
index: &mut Index,
work_tree: &Path,
index_mtime: Option<(u32, u32)>,
config: Option<&ConfigSet>,
stat_parallel_threads: Option<usize>,
worktree_rules: Option<&std::sync::Arc<std::sync::Mutex<crate::worktree_rules::WorktreeRules>>>,
) -> Result<bool> {
use crate::config::ConfigSet;
use crate::crlf;
use crate::worktree_scan::{
collect_blob_lookup_batches, group_blob_entries_by_dir, visit_blob_lookup_batches,
BlobDiskLookup, BlobScanSparseMode, WorktreeBlobScanOptions,
};
let index_mtime = index_mtime_for_diff(index, index_mtime);
let rules_lock = worktree_rules
.map(|shared| {
shared
.lock()
.map_err(|e| Error::Message(format!("worktree rules lock poisoned: {e}")))
})
.transpose()?;
let config = rules_lock
.as_ref()
.map(|r| r.config().clone())
.unwrap_or_else(|| {
config.cloned().unwrap_or_else(|| {
ConfigSet::load(
&crate::environment::Environment::empty(),
Some(git_dir),
true,
)
.unwrap_or_default()
})
});
let preload_index = config
.get_bool("core.preloadIndex")
.and_then(|r| r.ok())
.unwrap_or(true);
let blob_scan = WorktreeBlobScanOptions {
preload_index,
stat_parallel_threads,
};
let conv = rules_lock
.as_ref()
.map(|r| r.conversion().clone())
.unwrap_or_else(|| crlf::ConversionConfig::from_config(&config));
let legacy_attrs = if rules_lock.is_none() {
Some(crlf::load_gitattributes(work_tree))
} else {
None
};
let file_attrs_for = |path: &str| -> crlf::FileAttrs {
if let Some(rules) = rules_lock.as_ref() {
rules.file_attrs(path, false)
} else {
crlf::get_file_attrs(legacy_attrs.as_deref().unwrap_or(&[]), path, false, &config)
}
};
let precompose_unicode = config
.get_bool("core.precomposeunicode")
.and_then(|r| r.ok())
.unwrap_or(false);
let ignorecase = config
.get_bool("core.ignorecase")
.and_then(|r| r.ok())
.unwrap_or(false);
let stat_policy = crate::index::StatMatchPolicy::from_config(Some(&config));
let blob_dirs = group_blob_entries_by_dir(index, false);
let work_tree_owned = work_tree.to_path_buf();
let dir_abs = move |dir: &[u8]| {
if dir.is_empty() {
work_tree_owned.clone()
} else {
index_entry_worktree_abs_bytes(&work_tree_owned, dir, precompose_unicode, ignorecase)
}
};
let work_tree_for_files = work_tree.to_path_buf();
let file_abs = move |rel: &[u8]| {
index_entry_worktree_abs_bytes(&work_tree_for_files, rel, precompose_unicode, ignorecase)
};
let mut dir_symlinks = SymlinkDirCache::default();
let mut changed = false;
let filter_fp = rules_lock.as_ref().map(|r| r.filter_process());
let thread_policy = crate::parallel::ThreadPolicy::default();
let refresh_batches = collect_blob_lookup_batches(
index,
&blob_dirs,
blob_scan,
&thread_policy,
&config,
true,
None,
dir_abs,
file_abs,
|rel_path| {
dir_symlinks.has_symlink_in_path_bytes(
work_tree,
rel_path,
precompose_unicode,
ignorecase,
)
},
BlobScanSparseMode::StatRefresh {
stat_policy,
index_mtime,
},
)?;
visit_blob_lookup_batches(refresh_batches, |entry_index, lookup| {
let BlobDiskLookup::Present(_stat) = lookup else {
return Ok(());
};
let path_owned = crate::repo_relpath::storage_string(&index.entries[entry_index].path);
let file_path = index_entry_worktree_abs_bytes(
work_tree,
&index.entries[entry_index].path,
precompose_unicode,
ignorecase,
);
let meta = fs::symlink_metadata(&file_path).map_err(Error::Io)?;
let ie = &mut index.entries[entry_index];
if refresh_index_blob_stat_from_metadata(
odb,
ie,
&meta,
&path_owned,
&file_path,
index_mtime,
&conv,
&file_attrs_for,
stat_policy,
filter_fp,
)
.index_changed
{
changed = true;
}
Ok(())
})?;
Ok(changed)
}
struct BlobStatRefreshOutcome {
index_changed: bool,
content_verified: bool,
}
#[allow(clippy::too_many_arguments)]
fn refresh_index_blob_stat_from_metadata(
odb: &Odb,
ie: &mut IndexEntry,
meta: &fs::Metadata,
path: &str,
abs: &Path,
index_mtime: Option<(u32, u32)>,
conv: &crate::crlf::ConversionConfig,
file_attrs_for: &dyn Fn(&str) -> crate::crlf::FileAttrs,
stat_policy: crate::index::StatMatchPolicy,
filter_process: Option<&crate::filter_process::FilterProcessState>,
) -> BlobStatRefreshOutcome {
use crate::index::{MODE_EXECUTABLE, MODE_REGULAR, MODE_SYMLINK};
if ie.stage() != 0 || ie.skip_worktree() || ie.assume_unchanged() || ie.intent_to_add() {
return BlobStatRefreshOutcome {
index_changed: false,
content_verified: false,
};
}
if ie.mode != MODE_REGULAR && ie.mode != MODE_EXECUTABLE && ie.mode != MODE_SYMLINK {
return BlobStatRefreshOutcome {
index_changed: false,
content_verified: false,
};
}
if !stat_matches_with_policy(ie, meta, stat_policy) {
let file_attrs = file_attrs_for(path);
let content_matches = hash_worktree_file(
odb,
abs,
meta,
conv,
&file_attrs,
path,
Some(ie),
filter_process,
)
.map(|oid| oid == ie.oid)
.unwrap_or(false);
if !content_matches {
return BlobStatRefreshOutcome {
index_changed: false,
content_verified: false,
};
}
let refreshed = crate::index::entry_from_metadata(meta, &ie.path, ie.oid, ie.mode);
if index_entry_stat_matches(ie, &refreshed, stat_policy) {
return BlobStatRefreshOutcome {
index_changed: false,
content_verified: true,
};
}
ie.ctime_sec = refreshed.ctime_sec;
ie.ctime_nsec = refreshed.ctime_nsec;
ie.mtime_sec = refreshed.mtime_sec;
ie.mtime_nsec = refreshed.mtime_nsec;
ie.dev = refreshed.dev;
ie.ino = refreshed.ino;
ie.uid = refreshed.uid;
ie.gid = refreshed.gid;
ie.size = refreshed.size;
return BlobStatRefreshOutcome {
index_changed: true,
content_verified: true,
};
}
if entry_is_racy_with_policy(ie, index_mtime, stat_policy) {
let file_attrs = file_attrs_for(path);
let content_matches = hash_worktree_file(
odb,
abs,
meta,
conv,
&file_attrs,
path,
Some(ie),
filter_process,
)
.map(|oid| oid == ie.oid)
.unwrap_or(false);
if !content_matches {
invalidate_index_stat_cache(ie);
return BlobStatRefreshOutcome {
index_changed: true,
content_verified: false,
};
}
let refreshed = crate::index::entry_from_metadata(meta, &ie.path, ie.oid, ie.mode);
let index_changed = if index_entry_stat_matches(ie, &refreshed, stat_policy) {
false
} else {
ie.ctime_sec = refreshed.ctime_sec;
ie.ctime_nsec = refreshed.ctime_nsec;
ie.mtime_sec = refreshed.mtime_sec;
ie.mtime_nsec = refreshed.mtime_nsec;
ie.dev = refreshed.dev;
ie.ino = refreshed.ino;
ie.uid = refreshed.uid;
ie.gid = refreshed.gid;
ie.size = refreshed.size;
true
};
return BlobStatRefreshOutcome {
index_changed,
content_verified: true,
};
}
let refreshed = crate::index::entry_from_metadata(meta, &ie.path, ie.oid, ie.mode);
if index_entry_stat_matches(ie, &refreshed, stat_policy) {
return BlobStatRefreshOutcome {
index_changed: false,
content_verified: false,
};
}
ie.ctime_sec = refreshed.ctime_sec;
ie.ctime_nsec = refreshed.ctime_nsec;
ie.mtime_sec = refreshed.mtime_sec;
ie.mtime_nsec = refreshed.mtime_nsec;
ie.dev = refreshed.dev;
ie.ino = refreshed.ino;
ie.uid = refreshed.uid;
ie.gid = refreshed.gid;
ie.size = refreshed.size;
BlobStatRefreshOutcome {
index_changed: true,
content_verified: false,
}
}
fn index_entry_stat_matches(
a: &IndexEntry,
b: &IndexEntry,
policy: crate::index::StatMatchPolicy,
) -> bool {
crate::index::index_entries_stat_equivalent(a, b, policy)
}
#[allow(clippy::too_many_arguments)] pub fn path_checkout_skip_blob_write_when_up_to_date(
odb: &Odb,
git_dir: &Path,
work_tree: &Path,
index: &Index,
entry: &IndexEntry,
expected_oid: &ObjectId,
expected_mode: u32,
rel_path: &str,
filter_process: Option<&crate::filter_process::FilterProcessState>,
) -> Result<bool> {
path_checkout_skip_blob_write_when_up_to_date_with_config(
odb,
git_dir,
work_tree,
index,
entry,
expected_oid,
expected_mode,
rel_path,
filter_process,
None,
)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn path_checkout_skip_blob_write_when_up_to_date_with_config(
odb: &Odb,
git_dir: &Path,
work_tree: &Path,
index: &Index,
entry: &IndexEntry,
expected_oid: &ObjectId,
expected_mode: u32,
rel_path: &str,
filter_process: Option<&crate::filter_process::FilterProcessState>,
preloaded_config: Option<&crate::config::ConfigSet>,
) -> Result<bool> {
use crate::config::ConfigSet;
use crate::crlf;
use crate::index::{MODE_EXECUTABLE, MODE_REGULAR};
if entry.oid != *expected_oid || entry.mode != expected_mode {
return Ok(false);
}
if expected_mode != MODE_REGULAR && expected_mode != MODE_EXECUTABLE {
return Ok(false);
}
let abs_path = crate::repo_relpath::worktree_abs(work_tree, rel_path);
let meta = match fs::symlink_metadata(&abs_path) {
Ok(m) => m,
Err(_) => return Ok(false),
};
if !meta.is_file() || !stat_matches(entry, &meta) {
return Ok(false);
}
let owned_config;
let config = match preloaded_config {
Some(cfg) => cfg,
None => {
owned_config = ConfigSet::load(
&crate::environment::Environment::empty(),
Some(git_dir),
true,
)
.unwrap_or_default();
&owned_config
}
};
let trust_filemode = config
.get_bool("core.filemode")
.and_then(|r| r.ok())
.unwrap_or(true);
if trust_filemode && mode_from_metadata(&meta) != expected_mode {
return Ok(false);
}
let index_mtime = index_mtime_for_diff(index, None);
if !entry_is_racy(entry, index_mtime) {
return Ok(true);
}
let conv = crlf::ConversionConfig::from_config(config);
let attrs = crlf::load_gitattributes(work_tree);
let file_attrs = crlf::get_file_attrs(&attrs, rel_path, false, config);
let wt_oid = hash_worktree_file(
odb,
&abs_path,
&meta,
&conv,
&file_attrs,
rel_path,
Some(entry),
filter_process,
)?;
Ok(wt_oid == *expected_oid)
}
fn invalidate_index_stat_cache(ie: &mut IndexEntry) {
ie.ctime_sec = 0;
ie.ctime_nsec = 0;
ie.mtime_sec = 0;
ie.mtime_nsec = 0;
ie.dev = 0;
ie.ino = 0;
ie.size = 0;
}
pub fn read_worktree_bytes_for_mode(path: &Path, mode: u32) -> std::io::Result<Vec<u8>> {
if mode == MODE_SYMLINK {
let target = fs::read_link(path)?;
return Ok(symlink_target_bytes(&target));
}
fs::read(path)
}
pub fn read_worktree_side_bytes_for_diff(
odb: &Odb,
work_tree: &Path,
rel_path: &str,
mode: u32,
config: &crate::config::ConfigSet,
index_entry: Option<&IndexEntry>,
worktree_rules: Option<&crate::worktree_rules::WorktreeRules>,
) -> Result<Vec<u8>> {
let file_path = crate::repo_relpath::worktree_abs(work_tree, rel_path);
let rel_match = crate::repo_relpath::as_utf8_for_matching(rel_path);
if mode == MODE_SYMLINK {
return read_worktree_bytes_for_mode(&file_path, mode).map_err(Error::Io);
}
let meta = fs::symlink_metadata(&file_path).map_err(Error::Io)?;
if let Some(rules) = worktree_rules {
return worktree_blob_bytes(
odb,
&file_path,
&meta,
rules.conversion(),
&rules.file_attrs(rel_match.as_ref(), meta.is_dir()),
rel_match.as_ref(),
index_entry,
Some(rules.filter_process()),
);
}
let conv = crate::crlf::ConversionConfig::from_config(config);
let attrs = crate::crlf::load_gitattributes(work_tree);
let file_attrs = crate::crlf::get_file_attrs(&attrs, rel_match.as_ref(), meta.is_dir(), config);
worktree_blob_bytes(
odb,
&file_path,
&meta,
&conv,
&file_attrs,
rel_match.as_ref(),
index_entry,
None,
)
}
pub(crate) fn symlink_target_bytes(target: &Path) -> Vec<u8> {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt as _;
target.as_os_str().as_bytes().to_vec()
}
#[cfg(not(unix))]
{
target.as_os_str().to_string_lossy().as_bytes().to_vec()
}
}
fn read_regular_worktree_blob_bytes(
odb: &Odb,
path: &Path,
conv: &crate::crlf::ConversionConfig,
file_attrs: &crate::crlf::FileAttrs,
rel_path: &str,
index_entry: Option<&IndexEntry>,
filter_process: Option<&crate::filter_process::FilterProcessState>,
) -> Result<Vec<u8>> {
let needs_index_blob =
index_entry.is_some_and(|_| crate::crlf::clean_uses_autocrlf_index_guard(file_attrs, conv));
let prior_blob: Option<Vec<u8>> = if needs_index_blob {
index_entry
.filter(|e| e.oid != zero_oid())
.and_then(|e| odb.read(&e.oid).ok().map(|o| o.data))
} else {
None
};
let raw = fs::read(path)?;
#[cfg(test)]
odb.hot_path_metrics().record_blob_content_read();
let opts = crate::crlf::ConvertToGitOpts {
index_blob: prior_blob.as_deref(),
renormalize: false,
check_safecrlf: false,
filter_process,
command_runner: None,
};
Ok(
crate::crlf::convert_to_git_with_opts(&raw, rel_path, conv, file_attrs, opts)
.unwrap_or(raw),
)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn worktree_blob_bytes(
odb: &Odb,
path: &Path,
meta: &fs::Metadata,
conv: &crate::crlf::ConversionConfig,
file_attrs: &crate::crlf::FileAttrs,
rel_path: &str,
index_entry: Option<&IndexEntry>,
filter_process: Option<&crate::filter_process::FilterProcessState>,
) -> Result<Vec<u8>> {
let needs_index_blob =
index_entry.is_some_and(|_| crate::crlf::clean_uses_autocrlf_index_guard(file_attrs, conv));
let prior_blob: Option<Vec<u8>> = if needs_index_blob {
index_entry
.filter(|e| e.oid != zero_oid())
.and_then(|e| odb.read(&e.oid).ok().map(|o| o.data))
} else {
None
};
if meta.file_type().is_symlink() {
let target = fs::read_link(path)?;
return Ok(symlink_target_bytes(&target));
}
if meta.is_dir() {
return Ok(Vec::new());
}
let raw = fs::read(path)?;
#[cfg(test)]
odb.hot_path_metrics().record_blob_content_read();
let opts = crate::crlf::ConvertToGitOpts {
index_blob: prior_blob.as_deref(),
renormalize: false,
check_safecrlf: false,
filter_process,
command_runner: None,
};
Ok(
crate::crlf::convert_to_git_with_opts(&raw, rel_path, conv, file_attrs, opts)
.unwrap_or(raw),
)
}
#[allow(clippy::too_many_arguments)]
fn worktree_file_oid(
odb: &Odb,
path: &Path,
meta: &fs::Metadata,
conv: &crate::crlf::ConversionConfig,
file_attrs: &crate::crlf::FileAttrs,
rel_path: &str,
index_entry: Option<&IndexEntry>,
materialize: bool,
filter_process: Option<&crate::filter_process::FilterProcessState>,
) -> Result<ObjectId> {
let data = worktree_blob_bytes(
odb,
path,
meta,
conv,
file_attrs,
rel_path,
index_entry,
filter_process,
)?;
if materialize {
odb.write_with_options(ObjectKind::Blob, &data, crate::odb::BULK_NEW_LOOSE_WRITE)
} else {
odb.hash(ObjectKind::Blob, &data)
}
}
#[allow(clippy::too_many_arguments)]
pub fn hash_worktree_file(
odb: &Odb,
path: &Path,
meta: &fs::Metadata,
conv: &crate::crlf::ConversionConfig,
file_attrs: &crate::crlf::FileAttrs,
rel_path: &str,
index_entry: Option<&IndexEntry>,
filter_process: Option<&crate::filter_process::FilterProcessState>,
) -> Result<ObjectId> {
let data = worktree_blob_bytes(
odb,
path,
meta,
conv,
file_attrs,
rel_path,
index_entry,
filter_process,
)?;
odb.hash(ObjectKind::Blob, &data)
}
#[allow(clippy::too_many_arguments)]
pub fn materialize_worktree_blob(
odb: &Odb,
path: &Path,
meta: &fs::Metadata,
conv: &crate::crlf::ConversionConfig,
file_attrs: &crate::crlf::FileAttrs,
rel_path: &str,
index_entry: Option<&IndexEntry>,
filter_process: Option<&crate::filter_process::FilterProcessState>,
) -> Result<ObjectId> {
worktree_file_oid(
odb,
path,
meta,
conv,
file_attrs,
rel_path,
index_entry,
true,
filter_process,
)
}
fn staged_index_file_mode(trust_filemode: bool, index_mode: u32, worktree_mode: u32) -> u32 {
use crate::index::{MODE_EXECUTABLE, MODE_REGULAR};
if trust_filemode {
return worktree_mode;
}
let index_is_regular = index_mode == MODE_REGULAR || index_mode == MODE_EXECUTABLE;
let worktree_is_regular = worktree_mode == MODE_REGULAR || worktree_mode == MODE_EXECUTABLE;
if index_is_regular && worktree_is_regular {
index_mode
} else {
worktree_mode
}
}
pub fn mode_from_metadata(meta: &fs::Metadata) -> u32 {
if meta.file_type().is_symlink() {
0o120000
} else {
#[cfg(unix)]
{
if meta.mode() & 0o111 != 0 {
return 0o100755;
}
}
0o100644
}
}
pub fn diff_tree_to_worktree(
odb: &Odb,
tree_oid: Option<&ObjectId>,
work_tree: &Path,
index: &Index,
) -> Result<Vec<DiffEntry>> {
diff_tree_to_worktree_with_git_dir(
odb,
tree_oid,
work_tree,
&work_tree.join(".git"),
index,
None,
)
}
pub fn diff_tree_to_worktree_with_git_dir(
odb: &Odb,
tree_oid: Option<&ObjectId>,
work_tree: &Path,
repository_git_dir: &Path,
index: &Index,
config: Option<&ConfigSet>,
) -> Result<Vec<DiffEntry>> {
diff_tree_to_worktree_with_git_dir_and_rules(
odb,
tree_oid,
work_tree,
repository_git_dir,
index,
config,
None,
)
}
pub fn diff_tree_to_worktree_with_git_dir_and_rules(
odb: &Odb,
tree_oid: Option<&ObjectId>,
work_tree: &Path,
repository_git_dir: &Path,
index: &Index,
config: Option<&ConfigSet>,
worktree_rules: Option<&crate::worktree_rules::WorktreeRules>,
) -> Result<Vec<DiffEntry>> {
use crate::config::ConfigSet;
use crate::crlf;
if worktree_rules.is_none() {
if let Some(tree_oid) = tree_oid {
if index.cache_tree_root.as_ref() == Some(tree_oid)
&& crate::write_tree::cache_tree_fully_valid(odb, index.cache_tree.as_ref())
{
let mut idx = index.clone();
let options = DiffIndexToWorktreeOptions {
repository_git_dir: Some(repository_git_dir.to_path_buf()),
refresh_index_stat_in_pass: false,
for_status: false,
config: config.cloned().map(std::sync::Arc::new),
..DiffIndexToWorktreeOptions::default()
};
let (entries, _, _) = diff_index_to_worktree_inner(
odb,
&mut idx,
work_tree,
repository_git_dir,
options,
false,
)?;
return Ok(entries);
}
}
}
let config = worktree_rules
.map(|r| r.config().clone())
.unwrap_or_else(|| {
config.cloned().unwrap_or_else(|| {
ConfigSet::load(
&crate::environment::Environment::empty(),
Some(repository_git_dir),
true,
)
.unwrap_or_else(|_| ConfigSet::new())
})
});
let conv = worktree_rules
.map(|r| r.conversion().clone())
.unwrap_or_else(|| crlf::ConversionConfig::from_config(&config));
let legacy_attrs = if worktree_rules.is_none() {
Some(crlf::load_gitattributes(work_tree))
} else {
None
};
let file_attrs_for = |path: &str| -> crlf::FileAttrs {
if let Some(rules) = worktree_rules {
rules.file_attrs(path, false)
} else {
crlf::get_file_attrs(legacy_attrs.as_deref().unwrap_or(&[]), path, false, &config)
}
};
let filter_fp = worktree_rules.map(|r| r.filter_process());
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();
let mut stage0_paths: std::collections::BTreeSet<Vec<u8>> = 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);
stage0_paths.insert(ie.path.clone());
}
let mut unmerged_only_paths: std::collections::BTreeSet<String> =
std::collections::BTreeSet::new();
for ie in &index.entries {
if !(1..=3).contains(&ie.stage()) {
continue;
}
if stage0_paths.contains(&ie.path) {
continue;
}
unmerged_only_paths.insert(String::from_utf8_lossy(&ie.path).into_owned());
}
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());
all_paths.extend(unmerged_only_paths.iter().cloned());
let index_mtime = index_mtime_for_diff(index, None);
let stat_policy = crate::index::StatMatchPolicy::from_config(Some(&config));
let trust_filemode = config
.get_bool("core.filemode")
.and_then(|r| r.ok())
.unwrap_or(true);
let mut result = Vec::new();
for path in &all_paths {
if index_entries
.get(path.as_bytes())
.is_some_and(|ie| ie.skip_worktree())
{
continue;
}
if index_entries
.get(path.as_bytes())
.is_some_and(|ie| ie.assume_unchanged())
{
continue;
}
let tree_entry = tree_map.get(path.as_str());
let is_gitlink = tree_entry.is_some_and(|te| te.mode == 0o160000)
|| index_entries
.get(path.as_bytes())
.is_some_and(|ie| ie.mode == 0o160000);
if is_gitlink {
let sub_dir = work_tree.join(path);
let index_gitlink_oid = index_entries
.get(path.as_bytes())
.filter(|ie| ie.mode == 0o160000)
.map(|ie| ie.oid);
match (tree_entry, index_gitlink_oid) {
(Some(te), _) => {
let sub_head = read_submodule_head_oid(&sub_dir);
if sub_head.is_none() && !sub_dir.exists() {
result.push(DiffEntry {
status: DiffStatus::Deleted,
old_path: Some(path.clone()),
new_path: Some(path.clone()),
old_mode: format_mode(te.mode),
new_mode: "000000".to_string(),
old_oid: te.oid,
new_oid: zero_oid(),
score: None,
});
continue;
}
let ref_matches = if let Some(head) = sub_head {
head == te.oid
} else {
submodule_worktree_is_unpopulated_placeholder(&sub_dir)
};
let index_matches_tree = index_gitlink_oid.is_some_and(|oid| oid == te.oid);
let sub_flags = submodule_porcelain_flags(work_tree, path, te.oid);
let dirty_while_aligned =
index_matches_tree && ref_matches && sub_flags.modified;
if !ref_matches || dirty_while_aligned {
let new_oid = if !ref_matches {
sub_head.unwrap_or_else(zero_oid)
} else {
zero_oid()
};
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(te.mode),
old_oid: te.oid,
new_oid,
score: None,
});
}
}
(None, Some(idx_oid)) => {
let new_oid = read_submodule_head_oid(&sub_dir).unwrap_or(idx_oid);
result.push(DiffEntry {
status: DiffStatus::Added,
old_path: Some(path.clone()),
new_path: Some(path.clone()),
old_mode: "000000".to_string(),
new_mode: format_mode(0o160000),
old_oid: zero_oid(),
new_oid,
score: None,
});
}
(None, None) => {}
}
continue;
}
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)),
};
if unmerged_only_paths.contains(path) {
if let (Some(te), Some(meta)) = (tree_entry, wt_meta.as_ref()) {
let file_attrs = file_attrs_for(path);
let wt_oid = hash_worktree_file(
odb,
&file_path,
meta,
&conv,
&file_attrs,
path,
None,
filter_fp,
)?;
let wt_mode = mode_from_metadata(meta);
if wt_oid != te.oid || (trust_filemode && 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,
});
}
}
continue;
}
match (tree_entry, wt_meta) {
(Some(te), Some(ref meta)) => {
let wt_mode = mode_from_metadata(meta);
let Some(ie) = index_entries.get(path.as_bytes()) else {
continue;
};
let index_matches_tree = ie.oid == te.oid && ie.mode == te.mode;
let stat_trusted = stat_matches_with_policy(ie, meta, stat_policy)
&& !entry_is_racy_with_policy(ie, index_mtime, stat_policy);
if index_matches_tree && stat_trusted && (!trust_filemode || wt_mode == te.mode) {
continue;
}
let file_attrs = file_attrs_for(path);
let idx_ent = index_entries.get(path.as_bytes()).copied();
if ie.oid == te.oid && ie.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(ie.mode),
old_oid: te.oid,
new_oid: te.oid,
score: None,
});
continue;
}
if index_matches_tree {
let wt_oid = hash_worktree_file(
odb,
&file_path,
meta,
&conv,
&file_attrs,
path,
None,
filter_fp,
)?;
if wt_oid != te.oid {
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: zero_oid(),
score: None,
});
} else if trust_filemode && 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: te.oid,
score: None,
});
}
continue;
}
let wt_oid = hash_worktree_file(
odb,
&file_path,
meta,
&conv,
&file_attrs,
path,
idx_ent,
filter_fp,
)?;
if wt_oid != te.oid || (trust_filemode && wt_mode != te.mode) {
let new_oid = if wt_oid != te.oid { zero_oid() } else { te.oid };
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,
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 file_attrs = file_attrs_for(path);
let idx_ent = index_entries.get(path.as_bytes()).copied();
let wt_oid = materialize_worktree_blob(
odb,
&file_path,
meta,
&conv,
&file_attrs,
path,
idx_ent,
filter_fp,
)?;
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)
}
fn read_added_entry_bytes(
odb: &Odb,
entry: &DiffEntry,
work_root: Option<&Path>,
) -> Option<Vec<u8>> {
if entry.new_oid != zero_oid() {
if let Ok(obj) = odb.read(&entry.new_oid) {
return Some(obj.data);
}
}
let path = entry.new_path.as_deref()?;
let root = work_root?;
fs::read(root.join(path)).ok()
}
fn modified_as_copy_from_sources(
odb: &Odb,
work_root: Option<&Path>,
e: &DiffEntry,
threshold: u32,
sources: &[(String, ObjectId, bool)],
source_contents: &[Option<Vec<u8>>],
source_tree_entries: &[(String, String, ObjectId)],
) -> Result<Option<DiffEntry>> {
fn regular_file_mode(mode: &str) -> bool {
mode == "100644" || mode == "100755"
}
if e.status != DiffStatus::Modified || !regular_file_mode(&e.new_mode) {
return Ok(None);
}
let Some(new_data) = read_added_entry_bytes(odb, e, work_root) else {
return Ok(None);
};
let new_oid_eff = if e.new_oid != zero_oid() {
e.new_oid
} else {
odb.hash(ObjectKind::Blob, &new_data)?
};
let mut best: Option<(usize, u32)> = None;
for (si, (src_path, src_oid, is_deleted)) in sources.iter().enumerate() {
if *is_deleted {
continue;
}
if e.new_path.as_deref() == Some(src_path.as_str()) {
continue;
}
let src_mode_str = source_tree_entries
.iter()
.find(|(p, _, _)| p == src_path)
.map(|(_, m, _)| m.as_str())
.unwrap_or("100644");
if !regular_file_mode(src_mode_str) {
continue;
}
let score = if *src_oid == new_oid_eff {
100
} else {
match (&source_contents[si], Some(new_data.as_slice())) {
(Some(old_data), Some(nd)) => compute_similarity(old_data, nd),
_ => 0,
}
};
if score >= threshold {
let replace = match best {
None => true,
Some((_, s)) => score > s,
};
if replace {
best = Some((si, score));
}
}
}
let Some((si, score)) = best else {
return Ok(None);
};
let (src_path, src_oid, _) = &sources[si];
let src_mode = source_tree_entries
.iter()
.find(|(p, _, _)| p == src_path)
.map(|(_, m, _)| m.clone())
.unwrap_or_else(|| e.old_mode.clone());
Ok(Some(DiffEntry {
status: DiffStatus::Copied,
old_path: Some(src_path.clone()),
new_path: e.new_path.clone(),
old_mode: src_mode,
new_mode: e.new_mode.clone(),
old_oid: *src_oid,
new_oid: e.new_oid,
score: Some(score),
}))
}
pub fn detect_renames(
odb: &Odb,
work_root: Option<&Path>,
entries: Vec<DiffEntry>,
threshold: u32,
) -> Vec<DiffEntry> {
let config = ConfigSet::new();
let policy = ThreadPolicy::default();
detect_renames_with_policy(odb, work_root, entries, threshold, &config, &policy)
}
#[must_use]
pub fn detect_renames_with_policy(
odb: &Odb,
work_root: Option<&Path>,
entries: Vec<DiffEntry>,
threshold: u32,
config: &ConfigSet,
thread_policy: &ThreadPolicy,
) -> 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);
if !entries_sorted_by_path(&result) {
result.sort_by(|a, b| a.path().cmp(b.path()));
}
return result;
}
fn is_regularish_mode(mode: &str) -> bool {
mode == "100644" || mode == "100755"
}
fn same_path_same_blob(del: &DiffEntry, add: &DiffEntry) -> bool {
del.old_path == add.new_path && del.old_oid == add.new_oid && del.old_mode == add.new_mode
}
fn sort_rename_scores(
scores: &mut [(u32, usize, usize)],
deleted: &[DiffEntry],
added: &[DiffEntry],
) {
scores.sort_by(|a, b| {
let a_noop = same_path_same_blob(&deleted[a.1], &added[a.2]);
let b_noop = same_path_same_blob(&deleted[b.1], &added[b.2]);
let a_same = same_basename(&deleted[a.1], &added[a.2]);
let b_same = same_basename(&deleted[b.1], &added[b.2]);
a_noop
.cmp(&b_noop)
.then_with(|| b_same.cmp(&a_same))
.then_with(|| b.0.cmp(&a.0))
});
}
fn apply_rename_scores(
scores: &[(u32, usize, usize)],
deleted: &[DiffEntry],
added: &[DiffEntry],
used_deleted: &mut [bool],
used_added: &mut [bool],
renames: &mut Vec<DiffEntry>,
) {
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];
if same_path_same_blob(del, add) {
continue;
}
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 used_deleted = vec![false; deleted.len()];
let mut used_added = vec![false; added.len()];
let mut renames: Vec<DiffEntry> = Vec::new();
let mut exact_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 {
exact_scores.push((100, di, ai));
}
}
}
sort_rename_scores(&mut exact_scores, &deleted, &added);
apply_rename_scores(
&exact_scores,
&deleted,
&added,
&mut used_deleted,
&mut used_added,
&mut renames,
);
let remaining_deleted: Vec<usize> = used_deleted
.iter()
.enumerate()
.filter_map(|(i, used)| (!*used).then_some(i))
.collect();
let remaining_added: Vec<usize> = used_added
.iter()
.enumerate()
.filter_map(|(i, used)| (!*used).then_some(i))
.collect();
let rename_limit = parse_rename_limit(config);
if !remaining_deleted.is_empty()
&& !remaining_added.is_empty()
&& !inexact_matrix_exceeds_limit(
remaining_added.len(),
remaining_deleted.len(),
rename_limit,
)
{
let par_opts = diff_rename_parallel_options(config, thread_policy);
let deleted_sizes: Vec<Option<usize>> = remaining_deleted
.iter()
.map(|&di| blob_payload_size(odb, &deleted[di].old_oid))
.collect();
let added_sizes: Vec<Option<usize>> = remaining_added
.iter()
.map(|&ai| added_entry_payload_size(odb, &added[ai], work_root))
.collect();
let mut load_deleted = vec![false; remaining_deleted.len()];
let mut load_added = vec![false; remaining_added.len()];
let mut candidate_pairs: Vec<(usize, usize)> = Vec::new();
for (row, &di) in remaining_deleted.iter().enumerate() {
let del = &deleted[di];
if !is_regularish_mode(&del.old_mode) {
continue;
}
let Some(ds) = deleted_sizes[row] else {
continue;
};
for (col, &ai) in remaining_added.iter().enumerate() {
let add = &added[ai];
if !is_regularish_mode(&add.new_mode) {
continue;
}
let Some(asz) = added_sizes[col] else {
continue;
};
if rename_pair_size_reachable(ds, asz, threshold) {
load_deleted[row] = true;
load_added[col] = true;
candidate_pairs.push((row, col));
}
}
}
if !candidate_pairs.is_empty() {
let loads_needed = load_deleted.iter().filter(|&&b| b).count()
+ load_added.iter().filter(|&&b| b).count();
let load_deleted_blob = |i: usize| -> Option<Vec<u8>> {
if !load_deleted[i] {
return None;
}
odb.read(&deleted[remaining_deleted[i]].old_oid)
.ok()
.map(|obj| normalize_bytes_for_rename(obj.data))
};
let load_added_blob = |i: usize| -> Option<Vec<u8>> {
if !load_added[i] {
return None;
}
read_added_entry_bytes(odb, &added[remaining_added[i]], work_root)
.map(normalize_bytes_for_rename)
};
let (deleted_contents, added_contents) = if loads_needed <= 32 {
(
(0..remaining_deleted.len())
.map(load_deleted_blob)
.collect(),
(0..remaining_added.len()).map(load_added_blob).collect(),
)
} else {
(
parallel_load_optional_blobs(
odb,
remaining_deleted.len(),
par_opts,
0,
load_deleted_blob,
),
parallel_load_optional_blobs(
odb,
remaining_added.len(),
par_opts,
0,
load_added_blob,
),
)
};
let mut inexact_scores: Vec<(u32, usize, usize)> = Vec::new();
for (row, col) in candidate_pairs {
let di = remaining_deleted[row];
let ai = remaining_added[col];
let (Some(old_data), Some(new_data)) =
(&deleted_contents[row], &added_contents[col])
else {
continue;
};
let score = line_hash_similarity_score(old_data, new_data);
if score >= threshold {
inexact_scores.push((score, di, ai));
}
}
sort_rename_scores(&mut inexact_scores, &deleted, &added);
apply_rename_scores(
&inexact_scores,
&deleted,
&added,
&mut used_deleted,
&mut used_added,
&mut renames,
);
}
}
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 = prune_break_deletions_obviated_by_renames(result);
result.sort_by(|a, b| a.path().cmp(b.path()));
result
}
pub fn detect_copies(
odb: &Odb,
work_root: Option<&Path>,
entries: Vec<DiffEntry>,
threshold: u32,
find_copies_harder: bool,
source_tree_entries: &[(String, String, ObjectId)],
) -> Result<Vec<DiffEntry>> {
let config = ConfigSet::new();
let policy = ThreadPolicy::default();
detect_copies_with_policy(
odb,
work_root,
entries,
threshold,
find_copies_harder,
source_tree_entries,
&config,
&policy,
)
}
#[expect(clippy::too_many_arguments)]
pub fn detect_copies_with_policy(
odb: &Odb,
work_root: Option<&Path>,
entries: Vec<DiffEntry>,
threshold: u32,
find_copies_harder: bool,
source_tree_entries: &[(String, String, ObjectId)],
config: &ConfigSet,
thread_policy: &ThreadPolicy,
) -> Result<Vec<DiffEntry>> {
use std::collections::{HashMap, HashSet};
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),
}
}
let mut sources: Vec<(String, ObjectId, bool)> = Vec::new(); let mut deleted_source_idx: HashMap<String, usize> = HashMap::new();
for entry in &deleted {
if let Some(ref path) = entry.old_path {
deleted_source_idx.insert(path.clone(), sources.len());
sources.push((path.clone(), entry.old_oid, true));
}
}
for entry in &others {
if matches!(entry.status, DiffStatus::Modified | DiffStatus::TypeChanged) {
if let Some(ref old_path) = entry.old_path {
if !sources.iter().any(|(p, _, _)| p == old_path) {
sources.push((old_path.clone(), entry.old_oid, false));
}
}
}
}
if find_copies_harder {
for (path, _mode, oid) in source_tree_entries {
if !sources.iter().any(|(p, _, _)| p == path) {
sources.push((path.clone(), *oid, false));
}
}
}
if sources.is_empty() {
let mut result = others;
result.extend(deleted);
result.extend(added);
result.sort_by(|a, b| a.path().cmp(b.path()));
return Ok(result);
}
let par_opts = diff_rename_parallel_options(config, thread_policy);
let rename_limit = parse_rename_limit(config);
fn apply_copy_score_assignments(
scores: &[(u32, usize, usize)],
sources: &[(String, ObjectId, bool)],
added: &[DiffEntry],
source_tree_entries: &[(String, String, ObjectId)],
used_added2: &mut [bool],
renamed_deleted: &mut HashSet<usize>,
result_entries: &mut Vec<DiffEntry>,
) {
let mut used_added = vec![false; added.len()];
let mut source_to_added: HashMap<usize, Vec<(usize, u32)>> = HashMap::new();
for &(score, si, ai) in scores {
if used_added[ai] {
continue;
}
used_added[ai] = true;
source_to_added.entry(si).or_default().push((ai, score));
}
for (&si, assignments_for_src) in &source_to_added {
let (_, _, is_deleted) = &sources[si];
if *is_deleted && !assignments_for_src.is_empty() {
let rename_ai = assignments_for_src
.iter()
.max_by_key(|(ai, _score)| added[*ai].path().to_string())
.map(|(ai, _)| *ai);
for &(ai, score) in assignments_for_src {
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());
let is_rename = Some(ai) == rename_ai;
result_entries.push(DiffEntry {
status: if is_rename {
DiffStatus::Renamed
} else {
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),
});
used_added2[ai] = true;
}
renamed_deleted.insert(si);
} else {
for &(ai, score) in assignments_for_src {
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());
result_entries.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),
});
used_added2[ai] = true;
}
}
}
}
fn added_entry_oid(
odb: &Odb,
add: &DiffEntry,
added_contents: &[Option<Vec<u8>>],
ai: usize,
) -> Result<ObjectId> {
if add.new_oid != zero_oid() {
Ok(add.new_oid)
} else if let Some(ref data) = added_contents[ai] {
odb.hash(ObjectKind::Blob, data)
} else {
Ok(zero_oid())
}
}
let mut result_entries: Vec<DiffEntry> = Vec::new();
let mut renamed_deleted: HashSet<usize> = HashSet::new();
let mut used_added2 = vec![false; added.len()];
let mut source_contents = vec![None; sources.len()];
if !added.is_empty() {
let added_contents = parallel_load_optional_blobs(odb, added.len(), par_opts, 0, |ai| {
read_added_entry_bytes(odb, &added[ai], work_root)
});
let mut exact_scores: Vec<(u32, usize, usize)> = Vec::new();
for (si, (src_path, src_oid, _)) in sources.iter().enumerate() {
for (ai, add) in added.iter().enumerate() {
if add.new_path.as_deref() == Some(src_path.as_str()) {
continue;
}
let add_oid = added_entry_oid(odb, add, &added_contents, ai)?;
if *src_oid == add_oid && add_oid != zero_oid() {
exact_scores.push((100, si, ai));
}
}
}
exact_scores.sort_by_key(|a| std::cmp::Reverse(a.0));
apply_copy_score_assignments(
&exact_scores,
&sources,
&added,
source_tree_entries,
&mut used_added2,
&mut renamed_deleted,
&mut result_entries,
);
let remaining_added: Vec<usize> = used_added2
.iter()
.enumerate()
.filter_map(|(i, used)| (!*used).then_some(i))
.collect();
let remaining_source_count = sources
.iter()
.enumerate()
.filter(|(si, (_, _, is_deleted))| !*is_deleted || !renamed_deleted.contains(si))
.count();
let skip_inexact = remaining_added.is_empty()
|| inexact_matrix_exceeds_limit(
remaining_added.len(),
remaining_source_count,
rename_limit,
);
if !skip_inexact {
source_contents = parallel_load_optional_blobs(odb, sources.len(), par_opts, 0, |si| {
odb.read(&sources[si].1).ok().map(|obj| obj.data)
});
let est_bytes: usize = source_contents
.iter()
.chain(added_contents.iter())
.filter_map(|opt| opt.as_ref().map(|b| b.len()))
.sum();
let score_rows: Vec<Vec<(u32, usize, usize)>> = parallel_map_indices(
odb,
sources.len(),
par_opts,
est_bytes,
|si| -> std::result::Result<Vec<(u32, usize, usize)>, std::convert::Infallible> {
if renamed_deleted.contains(&si) {
return Ok(Vec::new());
}
let (src_path, _, _) = &sources[si];
let mut row: Vec<(u32, usize, usize)> = Vec::new();
for &ai in &remaining_added {
let add = &added[ai];
if add.new_path.as_deref() == Some(src_path.as_str()) {
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 {
row.push((score, si, ai));
}
}
Ok(row)
},
)
.expect("copy score rows");
let mut inexact_scores: Vec<(u32, usize, usize)> =
score_rows.into_iter().flatten().collect();
inexact_scores.sort_by_key(|a| std::cmp::Reverse(a.0));
apply_copy_score_assignments(
&inexact_scores,
&sources,
&added,
source_tree_entries,
&mut used_added2,
&mut renamed_deleted,
&mut result_entries,
);
}
}
for entry in deleted.into_iter() {
if let Some(ref path) = entry.old_path {
if let Some(&si) = deleted_source_idx.get(path) {
if renamed_deleted.contains(&si) {
continue;
}
}
}
result_entries.push(entry);
}
let mut result = others;
result.extend(result_entries);
for (i, entry) in added.into_iter().enumerate() {
if !used_added2[i] {
result.push(entry);
}
}
let mut final_result = Vec::with_capacity(result.len());
for e in result {
if let Some(c) = modified_as_copy_from_sources(
odb,
work_root,
&e,
threshold,
&sources,
&source_contents,
source_tree_entries,
)? {
final_result.push(c);
} else {
final_result.push(e);
}
}
final_result.sort_by(|a, b| a.path().cmp(b.path()));
Ok(final_result)
}
#[derive(Debug, Clone, Copy)]
pub struct DiffPostprocessOptions {
pub break_score: Option<u64>,
pub rename_threshold: u32,
pub copy_threshold: Option<u32>,
pub find_copies_harder: bool,
pub materialize_break_worktree_new_oid: bool,
}
impl Default for DiffPostprocessOptions {
fn default() -> Self {
Self {
break_score: None,
rename_threshold: 100,
copy_threshold: None,
find_copies_harder: false,
materialize_break_worktree_new_oid: false,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct DiffPostprocessWorktree<'a> {
pub repository_git_dir: &'a Path,
pub index: &'a Index,
}
fn blob_bytes(odb: &Odb, oid: &ObjectId) -> Result<Vec<u8>> {
Ok(odb.read(oid)?.data)
}
fn read_new_side_bytes(
odb: &Odb,
entry: &DiffEntry,
work_root: Option<&Path>,
worktree: Option<&DiffPostprocessWorktree<'_>>,
config: &ConfigSet,
) -> Result<Vec<u8>> {
if entry.new_oid != zero_oid() {
return blob_bytes(odb, &entry.new_oid);
}
let path = entry.new_path.as_deref().ok_or_else(|| {
Error::ObjectNotFound("modified entry missing new_path for worktree read".into())
})?;
let root = work_root.ok_or_else(|| {
Error::ObjectNotFound(format!(
"worktree bytes required for {} but no work_root was provided",
path
))
})?;
if let Some(wctx) = worktree {
let mode = u32::from_str_radix(&entry.new_mode, 8).unwrap_or(crate::index::MODE_REGULAR);
let index_entry = wctx.index.get(path.as_bytes(), 0);
return read_worktree_side_bytes_for_diff(odb, root, path, mode, config, index_entry, None);
}
fs::read(root.join(path)).map_err(Error::Io)
}
fn worktree_side_differs_after_normalize(
odb: &Odb,
entry: &DiffEntry,
work_root: &Path,
worktree: &DiffPostprocessWorktree<'_>,
config: &ConfigSet,
) -> Result<bool> {
let path = entry.new_path.as_deref().ok_or_else(|| {
Error::ObjectNotFound("modified entry missing new_path for worktree read".into())
})?;
let raw = fs::read(work_root.join(path)).map_err(Error::Io)?;
let canonical = read_new_side_bytes(odb, entry, Some(work_root), Some(worktree), config)?;
Ok(raw != canonical)
}
fn split_broken_modifications(
odb: &Odb,
work_root: Option<&Path>,
entries: Vec<DiffEntry>,
break_score: u64,
worktree: Option<&DiffPostprocessWorktree<'_>>,
config: &ConfigSet,
) -> Result<Vec<DiffEntry>> {
let mut out = Vec::with_capacity(entries.len());
for e in entries {
if e.status != DiffStatus::Modified {
out.push(e);
continue;
}
let old = blob_bytes(odb, &e.old_oid)?;
let new = read_new_side_bytes(odb, &e, work_root, worktree, config)?;
if should_break_rewrite_pair(&old, &new, break_score) {
let path = e.path().to_owned();
out.push(DiffEntry {
status: DiffStatus::Deleted,
old_path: Some(path.clone()),
new_path: None,
old_mode: e.old_mode.clone(),
new_mode: "000000".to_owned(),
old_oid: e.old_oid,
new_oid: zero_oid(),
score: None,
});
out.push(DiffEntry {
status: DiffStatus::Added,
old_path: None,
new_path: Some(path),
old_mode: "000000".to_owned(),
new_mode: e.new_mode.clone(),
old_oid: zero_oid(),
new_oid: e.new_oid,
score: None,
});
} else {
out.push(e);
}
}
Ok(out)
}
fn annotate_broken_modifications(
odb: &Odb,
work_root: Option<&Path>,
entries: Vec<DiffEntry>,
break_score: u64,
worktree: Option<&DiffPostprocessWorktree<'_>>,
config: &ConfigSet,
materialize_worktree_oid: bool,
) -> Result<Vec<DiffEntry>> {
let mut out = Vec::with_capacity(entries.len());
for mut e in entries {
if e.status == DiffStatus::Modified {
let old = blob_bytes(odb, &e.old_oid)?;
let new = read_new_side_bytes(odb, &e, work_root, worktree, config)?;
if should_break_rewrite_pair(&old, &new, break_score) {
let show_score = if e.new_oid != zero_oid() {
true
} else if materialize_worktree_oid {
match (work_root, worktree) {
(Some(root), Some(wctx)) => {
worktree_side_differs_after_normalize(odb, &e, root, wctx, config)?
}
_ => false,
}
} else {
false
};
if show_score {
e.score = rewrite_dissimilarity_index_percent(&old, &new);
}
}
}
out.push(e);
}
Ok(out)
}
fn materialize_break_worktree_new_oids(
odb: &Odb,
work_root: Option<&Path>,
entries: &mut [DiffEntry],
worktree: Option<&DiffPostprocessWorktree<'_>>,
config: &ConfigSet,
) -> Result<()> {
for e in entries {
if e.new_oid != zero_oid() {
continue;
}
if !matches!(e.status, DiffStatus::Modified | DiffStatus::Added) {
continue;
}
let bytes = read_new_side_bytes(odb, e, work_root, worktree, config)?;
e.new_oid = odb.hash(ObjectKind::Blob, &bytes)?;
}
Ok(())
}
fn prune_break_deletions_obviated_by_renames(entries: Vec<DiffEntry>) -> Vec<DiffEntry> {
use std::collections::HashSet;
let rename_targets: HashSet<String> = entries
.iter()
.filter(|e| e.status == DiffStatus::Renamed)
.filter_map(|e| e.new_path.clone())
.collect();
if rename_targets.is_empty() {
return entries;
}
entries
.into_iter()
.filter(|e| {
if e.status != DiffStatus::Deleted {
return true;
}
e.old_path
.as_ref()
.is_none_or(|p| !rename_targets.contains(p))
})
.collect()
}
#[allow(clippy::too_many_arguments)]
pub fn postprocess_diff_entries(
odb: &Odb,
entries: Vec<DiffEntry>,
source_tree_oid: Option<&ObjectId>,
work_root: Option<&Path>,
worktree: Option<&DiffPostprocessWorktree<'_>>,
config: &ConfigSet,
thread_policy: &ThreadPolicy,
opts: &DiffPostprocessOptions,
) -> Result<Vec<DiffEntry>> {
let mut entries = entries;
if let Some(break_score) = opts.break_score {
let rename_or_copy = opts.rename_threshold < 100 || opts.copy_threshold.is_some();
entries = if rename_or_copy {
split_broken_modifications(odb, work_root, entries, break_score, worktree, config)?
} else {
annotate_broken_modifications(
odb,
work_root,
entries,
break_score,
worktree,
config,
opts.materialize_break_worktree_new_oid,
)?
};
if opts.materialize_break_worktree_new_oid {
materialize_break_worktree_new_oids(odb, work_root, &mut entries, worktree, config)?;
}
}
let source_tree_entries = source_tree_oid
.map(|t| flatten_tree_entries(odb, t))
.transpose()?
.unwrap_or_default();
if opts.copy_threshold.is_some() || opts.find_copies_harder {
let threshold = opts.copy_threshold.unwrap_or(opts.rename_threshold.min(99));
detect_copies_with_policy(
odb,
work_root,
entries,
threshold,
opts.find_copies_harder,
&source_tree_entries,
config,
thread_policy,
)
} else if opts.rename_threshold < 100 {
Ok(detect_renames_with_policy(
odb,
work_root,
entries,
opts.rename_threshold,
config,
thread_policy,
))
} else {
Ok(entries)
}
}
pub fn flatten_tree_entries(
odb: &Odb,
tree_oid: &ObjectId,
) -> Result<Vec<(String, String, ObjectId)>> {
fn walk(
odb: &Odb,
tree_oid: &ObjectId,
prefix: &str,
out: &mut Vec<(String, String, ObjectId)>,
) -> Result<()> {
let obj = odb.read(tree_oid)?;
let oid_len = odb.hash_algo().len();
for e in TreeRefIter::with_oid_len(&obj.data, oid_len) {
let e = e?;
let name = String::from_utf8_lossy(e.name);
let path = if prefix.is_empty() {
name.to_string()
} else {
format!("{prefix}/{name}")
};
if e.mode == 0o040000 {
walk(odb, &e.oid, &path, out)?;
} else {
out.push((path, format!("{:o}", e.mode), e.oid));
}
}
Ok(())
}
let mut out = Vec::new();
walk(odb, tree_oid, "", &mut out)?;
Ok(out)
}
pub fn status_apply_rename_copy_detection(
odb: &Odb,
unstaged_raw: Vec<DiffEntry>,
threshold: u32,
copies: bool,
head_tree: Option<&ObjectId>,
config: &ConfigSet,
thread_policy: &ThreadPolicy,
) -> Result<Vec<DiffEntry>> {
if !copies {
return Ok(detect_renames_with_policy(
odb,
None,
unstaged_raw,
threshold,
config,
thread_policy,
));
}
let source_tree_entries: Vec<(String, String, ObjectId)> = match head_tree {
Some(oid) => flatten_tree(odb, oid, "")?
.into_iter()
.map(|e| (e.path, format_mode(e.mode), e.oid))
.collect(),
None => Vec::new(),
};
detect_copies_with_policy(
odb,
None,
unstaged_raw,
threshold,
false,
&source_tree_entries,
config,
thread_policy,
)
}
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, &b) in suffix_bytes.iter().enumerate().skip(1) {
if b == b'/' {
new_sfx = sfx - i;
break;
}
}
if new_sfx == 0 || new_sfx >= sfx {
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()
}
const RENAME_SCORE_MAX: u32 = 100;
fn normalize_bytes_for_rename(data: Vec<u8>) -> Vec<u8> {
if data.contains(&b'\r') {
crate::crlf::crlf_to_lf(&data)
} else {
data
}
}
fn blob_payload_size(odb: &Odb, oid: &ObjectId) -> Option<usize> {
if oid.is_zero() {
return None;
}
odb.read_info(oid).ok().map(|info| info.size as usize)
}
fn added_entry_payload_size(odb: &Odb, add: &DiffEntry, work_root: Option<&Path>) -> Option<usize> {
if !add.new_oid.is_zero() {
if let Some(size) = blob_payload_size(odb, &add.new_oid) {
return Some(size);
}
}
read_added_entry_bytes(odb, add, work_root).map(|data| data.len())
}
fn rename_pair_size_reachable(src_size: usize, dst_size: usize, minimum_score: u32) -> bool {
let max_size = src_size.max(dst_size);
let min_size = src_size.min(dst_size);
if max_size == 0 {
return minimum_score <= RENAME_SCORE_MAX;
}
(max_size - min_size).saturating_mul(RENAME_SCORE_MAX as usize)
<= max_size.saturating_mul((RENAME_SCORE_MAX - minimum_score) as usize)
}
fn hash_rename_line(line: &[u8]) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
line.hash(&mut hasher);
hasher.finish()
}
fn count_copied_line_bytes(src: &[u8], dst: &[u8]) -> usize {
use std::collections::HashMap;
fn line_weights(data: &[u8]) -> Vec<(u64, usize)> {
let mut out = Vec::new();
if data.is_empty() {
return out;
}
let mut start = 0usize;
for (i, &b) in data.iter().enumerate() {
if b != b'\n' {
continue;
}
let line = &data[start..=i];
let weight = line.len();
out.push((hash_rename_line(line), weight));
start = i + 1;
}
if start < data.len() {
let line = &data[start..];
out.push((hash_rename_line(line), data.len() - start));
}
out
}
let (short, long) = if src.len() <= dst.len() {
(src, dst)
} else {
(dst, src)
};
let short_lines = line_weights(short);
let long_lines = line_weights(long);
let mut counts: HashMap<u64, usize> = HashMap::new();
for (hash, weight) in short_lines {
*counts.entry(hash).or_default() += weight;
}
let mut copied = 0usize;
for (hash, weight) in long_lines {
if let Some(slot) = counts.get_mut(&hash) {
if *slot > 0 {
let take = weight.min(*slot);
copied += take;
*slot -= take;
}
}
}
copied
}
fn line_hash_similarity_score(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 max_size = src_size.max(dst_size);
if max_size == 0 {
return 100;
}
let copied = count_copied_line_bytes(old, new);
((copied * 100) / max_size).min(100) as u32
}
fn compute_similarity(old: &[u8], new: &[u8]) -> u32 {
let old_norm = crate::crlf::crlf_to_lf(old);
let new_norm = crate::crlf::crlf_to_lf(new);
line_hash_similarity_score(&old_norm, &new_norm)
}
#[must_use]
pub fn rename_similarity_score(old: &[u8], new: &[u8]) -> u32 {
compute_similarity(old, new)
}
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),
(DiffStatus::Modified, Some(s)) => format!("M{s:03}"),
(DiffStatus::TypeChanged, Some(s)) => format!("T{s:03}"),
_ => entry.status.letter().to_string(),
};
let (old_hex, new_hex) = raw_oid_hex_pair(&entry.old_oid, &entry.new_oid);
format!(
":{} {} {} {} {}\t{}",
entry.old_mode, entry.new_mode, old_hex, new_hex, status_str, path
)
}
fn raw_oid_hex_pair(old: &ObjectId, new: &ObjectId) -> (String, String) {
let width = if !old.is_zero() {
old.algo().hex_len()
} else if !new.is_zero() {
new.algo().hex_len()
} else {
old.algo().hex_len()
};
let render = |oid: &ObjectId| {
if oid.is_zero() {
"0".repeat(width)
} else {
oid.to_hex()
}
};
(render(old), render(new))
}
pub fn format_raw_abbrev(entry: &DiffEntry, abbrev_len: usize) -> String {
let ellipsis = if crate::environment::Environment::empty()
.var("GIT_PRINT_SHA1_ELLIPSIS")
.as_deref()
== Some("yes")
{
"..."
} else {
""
};
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 = 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{s:03}"),
(DiffStatus::Copied, Some(s)) => format!("C{s:03}"),
(DiffStatus::Modified, Some(s)) => format!("M{s:03}"),
(DiffStatus::TypeChanged, Some(s)) => format!("T{s:03}"),
_ => entry.status.letter().to_string(),
};
format!(
":{} {} {}{} {}{} {}\t{}",
entry.old_mode,
entry.new_mode,
old_abbrev,
ellipsis,
new_abbrev,
ellipsis,
status_str,
path
)
}
pub fn unified_diff(
old_content: &str,
new_content: &str,
old_path: &str,
new_path: &str,
context_lines: usize,
indent_heuristic: bool,
quote_path_fully: bool,
) -> String {
unified_diff_with_prefix(
old_content,
new_content,
old_path,
new_path,
context_lines,
0,
"a/",
"b/",
indent_heuristic,
quote_path_fully,
)
}
#[allow(clippy::too_many_arguments)] pub fn unified_diff_with_prefix(
old_content: &str,
new_content: &str,
old_path: &str,
new_path: &str,
context_lines: usize,
inter_hunk_context: usize,
src_prefix: &str,
dst_prefix: &str,
indent_heuristic: bool,
quote_path_fully: bool,
) -> String {
unified_diff_with_prefix_and_funcname(
old_content,
new_content,
old_path,
new_path,
context_lines,
inter_hunk_context,
src_prefix,
dst_prefix,
None,
indent_heuristic,
quote_path_fully,
)
}
#[allow(clippy::too_many_arguments)]
pub fn unified_diff_with_prefix_and_funcname(
old_content: &str,
new_content: &str,
old_path: &str,
new_path: &str,
context_lines: usize,
inter_hunk_context: usize,
src_prefix: &str,
dst_prefix: &str,
funcname_matcher: Option<&FuncnameMatcher>,
indent_heuristic: bool,
quote_path_fully: bool,
) -> String {
unified_diff_with_prefix_and_funcname_and_algorithm(
old_content,
new_content,
old_path,
new_path,
context_lines,
inter_hunk_context,
src_prefix,
dst_prefix,
funcname_matcher,
similar::Algorithm::Myers,
false,
false,
indent_heuristic,
quote_path_fully,
)
}
#[allow(clippy::too_many_arguments)]
pub fn write_unified_diff_with_prefix_and_funcname_and_algorithm<W: std::io::Write>(
out: &mut W,
old_content: &str,
new_content: &str,
old_path: &str,
new_path: &str,
context_lines: usize,
inter_hunk_context: usize,
src_prefix: &str,
dst_prefix: &str,
funcname_matcher: Option<&FuncnameMatcher>,
algorithm: similar::Algorithm,
function_context: bool,
use_git_histogram: bool,
indent_heuristic: bool,
quote_path_fully: bool,
) -> Result<()> {
if function_context {
let patch = unified_diff_with_function_context(
old_content,
new_content,
old_path,
new_path,
context_lines,
inter_hunk_context,
src_prefix,
dst_prefix,
funcname_matcher,
algorithm,
indent_heuristic,
quote_path_fully,
);
out.write_all(patch.as_bytes())?;
return Ok(());
}
if use_git_histogram {
let patch = unified_diff_histogram_with_prefix_and_funcname_indent(
old_content,
new_content,
old_path,
new_path,
context_lines,
inter_hunk_context,
src_prefix,
dst_prefix,
funcname_matcher,
indent_heuristic,
quote_path_fully,
);
out.write_all(patch.as_bytes())?;
return Ok(());
}
if algorithm == similar::Algorithm::Myers && !function_context {
let patch = unified_diff_git_myers_with_prefix_and_funcname(
old_content,
new_content,
old_path,
new_path,
context_lines,
inter_hunk_context,
src_prefix,
dst_prefix,
funcname_matcher,
indent_heuristic,
quote_path_fully,
);
out.write_all(patch.as_bytes())?;
return Ok(());
}
if algorithm == similar::Algorithm::Lcs && !function_context {
let patch = unified_diff_git_minimal_with_prefix_and_funcname(
old_content,
new_content,
old_path,
new_path,
context_lines,
inter_hunk_context,
src_prefix,
dst_prefix,
funcname_matcher,
indent_heuristic,
quote_path_fully,
);
out.write_all(patch.as_bytes())?;
return Ok(());
}
if algorithm == similar::Algorithm::Patience && !function_context {
let patch = unified_diff_git_patience_with_prefix_and_funcname(
old_content,
new_content,
old_path,
new_path,
context_lines,
inter_hunk_context,
src_prefix,
dst_prefix,
funcname_matcher,
indent_heuristic,
quote_path_fully,
);
out.write_all(patch.as_bytes())?;
return Ok(());
}
use crate::quote_path::format_diff_path_with_prefix;
use similar::{udiff::UnifiedDiffHunk, TextDiff};
let diff = TextDiff::configure()
.algorithm(algorithm)
.diff_lines(old_content, new_content);
let compacted_ops = diff_indent_heuristic::diff_lines_ops_compacted(
old_content,
new_content,
algorithm,
indent_heuristic,
);
if old_path == "/dev/null" {
out.write_all(b"--- /dev/null\n")?;
} else if src_prefix.is_empty() {
writeln!(out, "--- {old_path}")?;
} else {
out.write_all(b"--- ")?;
out.write_all(
format_diff_path_with_prefix(src_prefix, old_path, quote_path_fully).as_bytes(),
)?;
out.write_all(b"\n")?;
}
if new_path == "/dev/null" {
out.write_all(b"+++ /dev/null\n")?;
} else if dst_prefix.is_empty() {
writeln!(out, "+++ {new_path}")?;
} else {
out.write_all(b"+++ ")?;
out.write_all(
format_diff_path_with_prefix(dst_prefix, new_path, quote_path_fully).as_bytes(),
)?;
out.write_all(b"\n")?;
}
let old_lines: Vec<&str> = old_content.lines().collect();
let max_common_gap = context_lines
.saturating_mul(2)
.saturating_add(inter_hunk_context);
let op_groups = group_diff_ops_gap(compacted_ops, context_lines, max_common_gap);
let mut hunk_buf = String::new();
let mut eof_annot = UnifiedDiffNoNewlineAnnotator::new(old_content, new_content);
for ops in op_groups {
if ops.is_empty() {
continue;
}
let hunk = UnifiedDiffHunk::new(ops, &diff, true);
hunk_buf.clear();
use std::fmt::Write as FmtWrite;
let _ = FmtWrite::write_fmt(&mut hunk_buf, format_args!("{hunk}"));
let mut fragment = hunk_buf.clone();
if let Some(first_newline) = hunk_buf.find('\n') {
let header_line = &hunk_buf[..first_newline];
let rest = &hunk_buf[first_newline..];
if let Some(func_ctx) =
extract_function_context(header_line, &old_lines, funcname_matcher, Some(old_path))
{
fragment.clear();
fragment.push_str(header_line);
fragment.push(' ');
fragment.push_str(&func_ctx);
fragment.push_str(rest);
}
}
eof_annot.write_hunk(out, &fragment)?;
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub fn unified_diff_with_prefix_and_funcname_and_algorithm(
old_content: &str,
new_content: &str,
old_path: &str,
new_path: &str,
context_lines: usize,
inter_hunk_context: usize,
src_prefix: &str,
dst_prefix: &str,
funcname_matcher: Option<&FuncnameMatcher>,
algorithm: similar::Algorithm,
function_context: bool,
use_git_histogram: bool,
indent_heuristic: bool,
quote_path_fully: bool,
) -> String {
let mut output = Vec::new();
if write_unified_diff_with_prefix_and_funcname_and_algorithm(
&mut output,
old_content,
new_content,
old_path,
new_path,
context_lines,
inter_hunk_context,
src_prefix,
dst_prefix,
funcname_matcher,
algorithm,
function_context,
use_git_histogram,
indent_heuristic,
quote_path_fully,
)
.is_err()
{
output.clear();
}
String::from_utf8_lossy(&output).into_owned()
}
pub(crate) fn group_diff_ops_gap(
mut ops: Vec<similar::DiffOp>,
context: usize,
max_common_gap: usize,
) -> Vec<Vec<similar::DiffOp>> {
use similar::DiffOp;
if ops.is_empty() {
return vec![];
}
let mut pending_group = Vec::new();
let mut rv = Vec::new();
if let Some(DiffOp::Equal {
old_index,
new_index,
len,
}) = ops.first_mut()
{
let offset = (*len).saturating_sub(context);
*old_index += offset;
*new_index += offset;
*len -= offset;
}
if let Some(DiffOp::Equal { len, .. }) = ops.last_mut() {
*len -= (*len).saturating_sub(context);
}
for op in ops.into_iter() {
if let DiffOp::Equal {
old_index,
new_index,
len,
} = op
{
if len > max_common_gap {
pending_group.push(DiffOp::Equal {
old_index,
new_index,
len: context,
});
rv.push(pending_group);
let offset = len.saturating_sub(context);
pending_group = vec![DiffOp::Equal {
old_index: old_index + offset,
new_index: new_index + offset,
len: len - offset,
}];
continue;
}
}
pending_group.push(op);
}
match &pending_group[..] {
&[] | &[similar::DiffOp::Equal { .. }] => {}
_ => rv.push(pending_group),
}
rv
}
#[expect(clippy::too_many_arguments)]
fn unified_diff_with_function_context(
old_content: &str,
new_content: &str,
old_path: &str,
new_path: &str,
context_lines: usize,
inter_hunk_context: usize,
src_prefix: &str,
dst_prefix: &str,
funcname_matcher: Option<&FuncnameMatcher>,
algorithm: similar::Algorithm,
indent_heuristic: bool,
quote_path_fully: bool,
) -> String {
use crate::quote_path::format_diff_path_with_prefix;
use similar::{udiff::UnifiedDiffHunk, TextDiff};
let diff = TextDiff::configure()
.algorithm(algorithm)
.diff_lines(old_content, new_content);
let old_lines: Vec<&str> = old_content.lines().collect();
let new_lines: Vec<&str> = new_content.lines().collect();
let n_old = old_lines.len();
let n_new = new_lines.len();
let max_common_gap = context_lines
.saturating_mul(2)
.saturating_add(inter_hunk_context);
let all_ops = diff.ops().to_vec();
let op_groups = group_diff_ops_gap(all_ops.clone(), context_lines, max_common_gap);
let mut ranges: Vec<(usize, usize, usize, usize)> = Vec::new();
for ops in op_groups {
if ops.is_empty() {
continue;
}
let i1_anchor = func_context_old_anchor(&ops, n_old);
let i1_end = hunk_old_change_end_exclusive(&ops);
let skip_preimage_pull =
append_with_whole_function_added(&ops, n_old, n_new, &new_lines, funcname_matcher);
let hunk = UnifiedDiffHunk::new(ops, &diff, true);
let hunk_str = format!("{hunk}");
let header_line = hunk_str
.lines()
.next()
.unwrap_or("")
.trim_end_matches(['\r', '\n']);
let Some((base_s1, _base_e1, _base_s2, _base_e2)) =
parse_unified_hunk_header_ranges(header_line)
else {
continue;
};
let ctx = context_lines;
let (s1, e1, s2, e2) = if skip_preimage_pull {
let s = n_old.saturating_sub(ctx);
let s2 = map_old_line_to_new(&all_ops, s, n_new).min(n_new);
(s, n_old, s2, n_new)
} else {
let mut s1 = base_s1.saturating_sub(ctx);
let mut s2 = map_old_line_to_new(&all_ops, s1, n_new).min(n_new);
let base_pre_s1 = i1_anchor.saturating_sub(ctx);
if base_pre_s1 < s1 {
s1 = base_pre_s1;
s2 = map_old_line_to_new(&all_ops, s1, n_new).min(n_new);
}
let fs1 = expand_func_pre_start(s1, i1_anchor, n_old, &old_lines, funcname_matcher);
if fs1 < s1 {
s1 = fs1;
s2 = map_old_line_to_new(&all_ops, s1, n_new).min(n_new);
}
let mut e1 = (i1_end + ctx).min(n_old);
let mut e2 = map_old_line_to_new(&all_ops, e1, n_new).min(n_new);
let fe1 = expand_func_post_end(e1, i1_end, n_old, &old_lines, funcname_matcher);
if fe1 > e1 {
e1 = fe1;
e2 = map_old_line_to_new(&all_ops, e1, n_new).min(n_new);
}
(s1, e1, s2, e2)
};
ranges.push((s1, e1, s2, e2));
}
ranges.sort_by_key(|r| (r.0, r.2));
let mut merged: Vec<(usize, usize, usize, usize)> = Vec::with_capacity(ranges.len());
for (s1, e1, s2, e2) in ranges {
if let Some(last) = merged.last_mut() {
if s1 < last.1 {
last.1 = last.1.max(e1);
last.3 = last.3.max(e2);
continue;
}
}
merged.push((s1, e1, s2, e2));
}
let ranges = merged;
let mut output = String::new();
if old_path == "/dev/null" {
output.push_str("--- /dev/null\n");
} else if src_prefix.is_empty() {
output.push_str(&format!("--- {old_path}\n"));
} else {
output.push_str("--- ");
output.push_str(&format_diff_path_with_prefix(
src_prefix,
old_path,
quote_path_fully,
));
output.push('\n');
}
if new_path == "/dev/null" {
output.push_str("+++ /dev/null\n");
} else if dst_prefix.is_empty() {
output.push_str(&format!("+++ {new_path}\n"));
} else {
output.push_str("+++ ");
output.push_str(&format_diff_path_with_prefix(
dst_prefix,
new_path,
quote_path_fully,
));
output.push('\n');
}
for (s1, e1, s2, e2) in ranges {
if s1 >= e1 && s2 >= e2 {
continue;
}
let old_seg =
line_slice_for_diff_with_eof_nl(&old_lines, s1, e1, old_content.ends_with('\n'));
let new_seg =
line_slice_for_diff_with_eof_nl(&new_lines, s2, e2, new_content.ends_with('\n'));
let inner_ctx = old_seg.lines().count().max(new_seg.lines().count()).max(1);
let piece = unified_diff_with_prefix_and_funcname_and_algorithm(
&old_seg,
&new_seg,
old_path,
new_path,
inner_ctx,
0,
src_prefix,
dst_prefix,
funcname_matcher,
algorithm,
false,
false,
indent_heuristic,
quote_path_fully,
);
let shifted = shift_unified_hunk_headers_to_full_file(&piece, s1, s2);
let with_func =
enrich_unified_hunk_headers_funcname(&shifted, &old_lines, funcname_matcher);
for line in with_func.lines() {
if line.starts_with("--- ") || line.starts_with("+++ ") {
continue;
}
output.push_str(line);
output.push('\n');
}
}
output
}
fn shift_unified_hunk_headers_to_full_file(
patch: &str,
delta_old: usize,
delta_new: usize,
) -> String {
if delta_old == 0 && delta_new == 0 {
return patch.to_owned();
}
let mut out = String::with_capacity(patch.len());
for line in patch.lines() {
if let Some(shifted) = shift_one_unified_hunk_header(line, delta_old, delta_new) {
out.push_str(&shifted);
} else {
out.push_str(line);
}
out.push('\n');
}
out
}
fn shift_one_unified_hunk_header(line: &str, delta_old: usize, delta_new: usize) -> Option<String> {
let rest = line.strip_prefix("@@ ")?;
let (old_chunk, after_plus) = rest.split_once(" +")?;
let old_spec = old_chunk.strip_prefix('-')?;
let (new_spec, suffix) = after_plus.split_once(" @@")?;
let shifted_old = shift_unified_range_spec(old_spec, delta_old)?;
let shifted_new = shift_unified_range_spec(new_spec, delta_new)?;
Some(format!("@@ -{shifted_old} +{shifted_new} @@{suffix}"))
}
fn shift_unified_range_spec(spec: &str, delta: usize) -> Option<String> {
let spec = spec.trim();
if let Some((start_s, count_s)) = spec.split_once(',') {
let start: usize = start_s.parse().ok()?;
let count: usize = count_s.parse().ok()?;
Some(format!("{},{}", start.saturating_add(delta), count))
} else {
let start: usize = spec.parse().ok()?;
Some(format!("{}", start.saturating_add(delta)))
}
}
fn enrich_unified_hunk_headers_funcname(
patch: &str,
full_old_lines: &[&str],
funcname_matcher: Option<&FuncnameMatcher>,
) -> String {
let mut out = String::with_capacity(patch.len());
for line in patch.lines() {
if let Some(fixed) = enrich_one_hunk_header_funcname(line, full_old_lines, funcname_matcher)
{
out.push_str(&fixed);
} else {
out.push_str(line);
}
out.push('\n');
}
out
}
fn enrich_one_hunk_header_funcname(
line: &str,
full_old_lines: &[&str],
funcname_matcher: Option<&FuncnameMatcher>,
) -> Option<String> {
let after_at = line.strip_prefix("@@ ")?;
let idx = after_at.find(" @@")?;
let mid = after_at[..idx].trim();
let tail = after_at[idx + 3..].trim_start();
let header_for_parse = format!("@@ {mid} @@");
let func = extract_function_context(&header_for_parse, full_old_lines, funcname_matcher, None);
Some(if let Some(f) = func {
format!("@@ {mid} @@ {f}")
} else if !tail.is_empty() {
format!("@@ {mid} @@ {tail}")
} else {
format!("@@ {mid} @@")
})
}
fn line_slice_for_diff_with_eof_nl(
lines: &[&str],
start: usize,
end: usize,
full_file_ends_with_newline: bool,
) -> String {
if start >= end {
return String::new();
}
let mut s = lines[start..end].join("\n");
let slice_is_suffix_of_file = end == lines.len();
let need_trailing_nl = if slice_is_suffix_of_file {
full_file_ends_with_newline
} else {
true
};
if need_trailing_nl && !s.ends_with('\n') {
s.push('\n');
}
s
}
fn map_old_line_to_new(ops: &[similar::DiffOp], old_line: usize, n_new: usize) -> usize {
use similar::DiffOp;
let mut n = 0usize;
for op in ops {
match *op {
DiffOp::Equal {
old_index,
new_index,
len,
} => {
if old_index + len <= old_line {
n = new_index + len;
continue;
}
if old_index < old_line {
let take = old_line - old_index;
return (new_index + take).min(n_new);
}
return new_index.min(n_new);
}
DiffOp::Delete {
old_index,
old_len,
new_index,
} => {
if old_index + old_len <= old_line {
n = new_index;
continue;
}
if old_index < old_line {
return new_index.min(n_new);
}
}
DiffOp::Insert {
old_index,
new_index,
new_len,
} => {
if old_index < old_line {
n = new_index + new_len;
continue;
}
if old_index == old_line {
return (new_index + new_len).min(n_new);
}
return new_index.min(n_new);
}
DiffOp::Replace {
old_index,
old_len,
new_index,
new_len,
} => {
if old_index + old_len <= old_line {
n = new_index + new_len;
continue;
}
if old_index < old_line {
let into_old = old_line - old_index;
let mapped = new_index + into_old.min(new_len);
return mapped.min(n_new);
}
return new_index.min(n_new);
}
}
}
n.min(n_new)
}
fn parse_unified_hunk_header_ranges(header: &str) -> Option<(usize, usize, usize, usize)> {
let rest = header.strip_prefix("@@ ")?;
let (old_tok, rest2) = rest.split_once(" +")?;
let old_tok = old_tok.strip_prefix('-')?;
let new_tok = rest2.split_once(" @@").map(|(a, _)| a)?;
fn parse_side(spec: &str) -> Option<(usize, usize)> {
let spec = spec.trim();
let (start_one_based, count) = if let Some((a, b)) = spec.split_once(',') {
(a.parse::<usize>().ok()?, b.parse::<usize>().ok()?)
} else {
let s = spec.parse::<usize>().ok()?;
(s, 1usize)
};
let s0 = start_one_based.saturating_sub(1);
let e0 = s0.saturating_add(count);
Some((s0, e0))
}
let (os, oe) = parse_side(old_tok)?;
let (ns, ne) = parse_side(new_tok)?;
Some((os, oe, ns, ne))
}
fn append_with_whole_function_added(
ops: &[similar::DiffOp],
n_old: usize,
n_new: usize,
new_lines: &[&str],
matcher: Option<&FuncnameMatcher>,
) -> bool {
use similar::DiffOp;
if n_old == 0 {
return false;
}
let mut only_ins_or_eq = true;
let mut min_new_ins = usize::MAX;
for op in ops {
match *op {
DiffOp::Equal { .. } => {}
DiffOp::Insert {
new_index, new_len, ..
} => {
min_new_ins = min_new_ins.min(new_index);
if new_len == 0 {
only_ins_or_eq = false;
}
}
DiffOp::Delete { .. } | DiffOp::Replace { .. } => {
only_ins_or_eq = false;
}
}
}
let mut insert_at_eof = false;
for op in ops {
if let DiffOp::Insert { old_index, .. } = *op {
if old_index == n_old {
insert_at_eof = true;
break;
}
}
}
let append_at_eof = min_new_ins == n_old || insert_at_eof;
if !only_ins_or_eq || !append_at_eof || min_new_ins == usize::MAX {
return false;
}
let mut j = min_new_ins;
while j < n_new {
let line = new_lines[j];
if line.trim().is_empty() {
j += 1;
continue;
}
if let Some(m) = matcher {
if m.match_line(line).is_some() {
return true;
}
} else if inserted_block_starts_with_c_like_function_definition(line) {
return true;
}
j += 1;
}
false
}
fn inserted_block_starts_with_c_like_function_definition(line: &str) -> bool {
let t = line.trim_start();
let Some(open_paren) = t.find('(') else {
return false;
};
let head = &t[..open_paren];
let tokens: Vec<&str> = head.split_whitespace().collect();
if tokens.len() < 2 {
return false;
}
let nameish = tokens.last().copied().unwrap_or("");
let name = nameish.trim_end_matches(['*', '&']);
if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return false;
}
let type_or_modifier = |tok: &str| {
matches!(
tok,
"static"
| "extern"
| "inline"
| "void"
| "int"
| "char"
| "short"
| "long"
| "float"
| "double"
| "unsigned"
| "signed"
| "struct"
| "enum"
| "union"
| "const"
| "volatile"
| "typedef"
)
};
tokens[..tokens.len() - 1]
.iter()
.any(|tok| type_or_modifier(tok))
}
fn hunk_old_change_end_exclusive(ops: &[similar::DiffOp]) -> usize {
use similar::DiffOp;
let mut max_o = 0usize;
for op in ops {
match *op {
DiffOp::Delete {
old_index, old_len, ..
} => {
max_o = max_o.max(old_index + old_len);
}
DiffOp::Replace {
old_index, old_len, ..
} => {
max_o = max_o.max(old_index + old_len);
}
DiffOp::Insert { old_index, .. } => {
max_o = max_o.max(old_index);
}
DiffOp::Equal { .. } => {}
}
}
max_o
}
fn func_context_old_anchor(ops: &[similar::DiffOp], n_old: usize) -> usize {
use similar::DiffOp;
let mut has_delete_or_replace = false;
let mut min_del = usize::MAX;
let mut min_ins_old = usize::MAX;
for op in ops {
match *op {
DiffOp::Delete {
old_index, old_len, ..
} => {
has_delete_or_replace = true;
min_del = min_del.min(old_index);
min_del = min_del.min(old_index + old_len.saturating_sub(1));
}
DiffOp::Replace {
old_index, old_len, ..
} => {
has_delete_or_replace = true;
min_del = min_del.min(old_index);
min_del = min_del.min(old_index + old_len.saturating_sub(1));
}
DiffOp::Insert { old_index, .. } => {
min_ins_old = min_ins_old.min(old_index);
}
DiffOp::Equal { .. } => {}
}
}
let mut i1 = if has_delete_or_replace {
min_del
} else if min_ins_old != usize::MAX {
min_ins_old
} else {
0
};
let pure_insert = ops
.iter()
.all(|op| matches!(op, DiffOp::Insert { .. } | DiffOp::Equal { .. }))
&& ops.iter().any(|op| matches!(op, DiffOp::Insert { .. }));
if pure_insert && i1 >= n_old && n_old > 0 {
i1 = n_old - 1;
}
i1.min(n_old.saturating_sub(1))
}
fn expand_func_pre_start(
s1: usize,
i1: usize,
n_old: usize,
old_lines: &[&str],
matcher: Option<&FuncnameMatcher>,
) -> usize {
if n_old == 0 {
return s1;
}
let i1 = i1.min(n_old.saturating_sub(1));
let mut fs1 = get_func_line_backward(old_lines, i1, matcher).unwrap_or(i1);
while fs1 > 0
&& !is_line_empty_for_func_context(old_lines[fs1 - 1])
&& !is_func_line(old_lines[fs1 - 1], matcher)
{
fs1 -= 1;
}
s1.min(fs1)
}
fn expand_func_post_end(
e1: usize,
i1_end: usize,
n_old: usize,
old_lines: &[&str],
matcher: Option<&FuncnameMatcher>,
) -> usize {
let from = i1_end.min(n_old);
let fe1 = get_func_line_forward(old_lines, from, matcher).unwrap_or(n_old);
let mut fe1_adj = fe1;
while fe1_adj > 0 && is_line_empty_for_func_context(old_lines[fe1_adj - 1]) {
fe1_adj -= 1;
}
e1.max(fe1_adj).min(n_old)
}
fn is_line_empty_for_func_context(line: &str) -> bool {
line.chars().all(|c| c.is_whitespace())
}
pub(crate) fn is_func_line(line: &str, matcher: Option<&FuncnameMatcher>) -> bool {
if let Some(m) = matcher {
return m.match_line(line).is_some();
}
let t = line.trim_end_matches(['\n', '\r']);
if t.is_empty() {
return false;
}
let b = t.as_bytes()[0];
if !(b.is_ascii_alphabetic() || b == b'_' || b == b'$') {
return false;
}
true
}
fn get_func_line_backward(
old_lines: &[&str],
start: usize,
matcher: Option<&FuncnameMatcher>,
) -> Option<usize> {
let mut l = start.min(old_lines.len().saturating_sub(1));
if old_lines.is_empty() {
return None;
}
loop {
if is_func_line(old_lines[l], matcher) {
return Some(l);
}
if l == 0 {
break;
}
l -= 1;
}
None
}
fn get_func_line_forward(
old_lines: &[&str],
start: usize,
matcher: Option<&FuncnameMatcher>,
) -> Option<usize> {
let mut l = start;
while l < old_lines.len() {
if is_func_line(old_lines[l], matcher) {
return Some(l);
}
l += 1;
}
None
}
#[expect(clippy::too_many_arguments)]
pub fn anchored_unified_diff(
old_content: &str,
new_content: &str,
old_path: &str,
new_path: &str,
context_lines: usize,
anchors: &[String],
algorithm: similar::Algorithm,
use_git_histogram: bool,
indent_heuristic: bool,
quote_path_fully: bool,
) -> String {
use crate::quote_path::format_diff_path_with_prefix;
use similar::TextDiff;
let old_lines: Vec<&str> = old_content.lines().collect();
let new_lines: Vec<&str> = new_content.lines().collect();
let mut anchor_pairs: Vec<(usize, usize)> = Vec::new();
for anchor in anchors {
let anchor_str = anchor.as_str();
let old_positions: Vec<usize> = old_lines
.iter()
.enumerate()
.filter(|(_, l)| l.trim_end() == anchor_str)
.map(|(i, _)| i)
.collect();
let new_positions: Vec<usize> = new_lines
.iter()
.enumerate()
.filter(|(_, l)| l.trim_end() == anchor_str)
.map(|(i, _)| i)
.collect();
if old_positions.len() == 1 && new_positions.len() == 1 {
anchor_pairs.push((old_positions[0], new_positions[0]));
}
}
if anchor_pairs.is_empty() {
return unified_diff_with_prefix_and_funcname_and_algorithm(
old_content,
new_content,
old_path,
new_path,
context_lines,
0,
"a/",
"b/",
None,
algorithm,
false,
use_git_histogram,
indent_heuristic,
quote_path_fully,
);
}
anchor_pairs.sort_by_key(|&(old_idx, _)| old_idx);
let mut filtered: Vec<(usize, usize)> = Vec::new();
for &pair in &anchor_pairs {
if filtered.is_empty() || filtered.last().is_some_and(|last| pair.1 > last.1) {
filtered.push(pair);
}
}
let anchor_pairs = filtered;
struct LineDiffOp {
tag: char, line: String,
}
let append_segment_diff =
|ops: &mut Vec<LineDiffOp>, old_seg_input: &str, new_seg_input: &str| {
use similar::ChangeTag;
let old_ls: Vec<&str> = old_seg_input.lines().collect();
let new_ls: Vec<&str> = new_seg_input.lines().collect();
if old_ls.is_empty() && new_ls.is_empty() {
return;
}
let seg_diff = TextDiff::configure()
.algorithm(algorithm)
.diff_slices(&old_ls, &new_ls);
let raw = seg_diff.ops().to_vec();
let compacted = diff_indent_heuristic::apply_change_compact_to_ops(
&raw,
&old_ls,
&new_ls,
indent_heuristic,
);
for op in &compacted {
for ch in op.iter_changes(&old_ls, &new_ls) {
let t = match ch.tag() {
ChangeTag::Equal => ' ',
ChangeTag::Delete => '-',
ChangeTag::Insert => '+',
};
ops.push(LineDiffOp {
tag: t,
line: ch.value().to_string(),
});
}
}
};
let mut ops: Vec<LineDiffOp> = Vec::new();
let mut old_pos = 0usize;
let mut new_pos = 0usize;
for &(old_anchor, new_anchor) in &anchor_pairs {
let old_segment: Vec<&str> = old_lines[old_pos..old_anchor].to_vec();
let new_segment: Vec<&str> = new_lines[new_pos..new_anchor].to_vec();
let old_seg_text = old_segment.join("\n");
let new_seg_text = new_segment.join("\n");
if !old_seg_text.is_empty() || !new_seg_text.is_empty() {
let old_seg_input = if old_seg_text.is_empty() {
String::new()
} else {
format!("{}\n", old_seg_text)
};
let new_seg_input = if new_seg_text.is_empty() {
String::new()
} else {
format!("{}\n", new_seg_text)
};
append_segment_diff(&mut ops, &old_seg_input, &new_seg_input);
}
ops.push(LineDiffOp {
tag: ' ',
line: old_lines[old_anchor].to_string(),
});
old_pos = old_anchor + 1;
new_pos = new_anchor + 1;
}
let old_segment: Vec<&str> = old_lines[old_pos..].to_vec();
let new_segment: Vec<&str> = new_lines[new_pos..].to_vec();
let old_seg_text = old_segment.join("\n");
let new_seg_text = new_segment.join("\n");
if !old_seg_text.is_empty() || !new_seg_text.is_empty() {
let old_seg_input = if old_seg_text.is_empty() {
String::new()
} else {
format!("{}\n", old_seg_text)
};
let new_seg_input = if new_seg_text.is_empty() {
String::new()
} else {
format!("{}\n", new_seg_text)
};
append_segment_diff(&mut ops, &old_seg_input, &new_seg_input);
}
let mut output = String::new();
if old_path == "/dev/null" {
output.push_str("--- /dev/null\n");
} else {
output.push_str("--- ");
output.push_str(&format_diff_path_with_prefix(
"a/",
old_path,
quote_path_fully,
));
output.push('\n');
}
if new_path == "/dev/null" {
output.push_str("+++ /dev/null\n");
} else {
output.push_str("+++ ");
output.push_str(&format_diff_path_with_prefix(
"b/",
new_path,
quote_path_fully,
));
output.push('\n');
}
let total_ops = ops.len();
if total_ops == 0 {
return output;
}
let mut hunks: Vec<(usize, usize)> = Vec::new(); let mut i = 0;
while i < total_ops {
if ops[i].tag != ' ' {
let start = i.saturating_sub(context_lines);
let mut end = i;
while end < total_ops {
if ops[end].tag != ' ' {
end += 1;
continue;
}
let mut next_change = end;
while next_change < total_ops && ops[next_change].tag == ' ' {
next_change += 1;
}
if next_change < total_ops && next_change - end <= context_lines * 2 {
end = next_change + 1;
} else {
end = (end + context_lines).min(total_ops);
break;
}
}
if let Some(last) = hunks.last_mut() {
if start <= last.1 {
last.1 = end;
} else {
hunks.push((start, end));
}
} else {
hunks.push((start, end));
}
i = end;
} else {
i += 1;
}
}
for (start, end) in hunks {
let mut old_start = 1usize;
let mut new_start = 1usize;
for op in &ops[..start] {
match op.tag {
' ' => {
old_start += 1;
new_start += 1;
}
'-' => {
old_start += 1;
}
'+' => {
new_start += 1;
}
_ => {}
}
}
let mut old_count = 0usize;
let mut new_count = 0usize;
for op in &ops[start..end] {
match op.tag {
' ' => {
old_count += 1;
new_count += 1;
}
'-' => {
old_count += 1;
}
'+' => {
new_count += 1;
}
_ => {}
}
}
output.push_str(&format!(
"@@ -{},{} +{},{} @@\n",
old_start, old_count, new_start, new_count
));
for op in &ops[start..end] {
output.push(op.tag);
output.push_str(&op.line);
output.push('\n');
}
}
output
}
fn func_context_line_matches(line: &str, funcname_matcher: Option<&FuncnameMatcher>) -> bool {
if let Some(m) = funcname_matcher {
return m.match_line(line).is_some();
}
is_func_line(line, None)
}
fn extract_function_context(
header: &str,
old_lines: &[&str],
funcname_matcher: Option<&FuncnameMatcher>,
_source_path: Option<&str>,
) -> Option<String> {
let at_pos = header.find("-")?;
let rest = &header[at_pos + 1..];
let comma_or_space = rest.find([',', ' '])?;
let start_str = &rest[..comma_or_space];
let start_line: usize = start_str.parse().ok()?;
let old_token_end = rest.find([' ', '\t']).unwrap_or(rest.len());
let old_token = &rest[..old_token_end];
let old_count: usize = if let Some(comma) = old_token.find(',') {
old_token[comma + 1..].parse().unwrap_or(1)
} else {
1
};
if start_line == 0 {
return None;
}
if start_line == 0 {
return None;
}
let search_end = if old_count == 0 {
start_line.min(old_lines.len())
} else {
if start_line <= 1 {
return None;
}
(start_line - 1).min(old_lines.len())
};
let truncate = |line: &str| {
let mut end = line.len().min(80);
while end > 0 && !line.is_char_boundary(end) {
end -= 1;
}
let mut out = line[..end].to_owned();
let tail = line[end..].trim_start();
if tail.is_empty()
|| (!tail.starts_with('"')
&& !tail.starts_with('\'')
&& !line.starts_with("msgstr")
&& !line.starts_with("msgid"))
{
out = out.trim_end().to_owned();
}
out
};
for i in (0..search_end).rev() {
let line = old_lines[i];
if line.is_empty() {
continue;
}
if func_context_line_matches(line, funcname_matcher) {
if let Some(matcher) = funcname_matcher {
if let Some(matched) = matcher.match_line(line) {
return Some(truncate(&matched));
}
continue;
}
return Some(truncate(line));
}
}
None
}
pub fn format_stat_line(
path: &str,
insertions: usize,
deletions: usize,
max_path_len: usize,
) -> String {
format_stat_line_width(path, insertions, deletions, max_path_len, 0)
}
pub fn format_stat_line_width(
path: &str,
insertions: usize,
deletions: usize,
max_path_len: usize,
count_width: usize,
) -> String {
let mut line =
crate::diffstat::format_single_stat_line(path, insertions, deletions, max_path_len)
.unwrap_or_else(|_| String::new());
if count_width > 0 && line.contains(" | ") {
let total = insertions + deletions;
let cw = count_width;
if let Some((left, rest)) = line.split_once(" | ") {
if let Some((_, bar)) = rest.split_once(' ') {
line = format!(" {left} | {total:>cw$} {bar}", cw = cw);
} else {
line = format!(" {left} | {total:>cw$}", cw = cw);
}
}
}
line
}
fn last_line_index_without_trailing_newline(text: &str) -> Option<usize> {
LineSide::from_text(text).incomplete_last_line_index()
}
fn parse_unified_hunk_starts(header: &str) -> Option<(usize, usize)> {
let rest = header.strip_prefix("@@ ")?;
let (old_part, new_part) = rest.split_once(" +")?;
let old_start = parse_unified_range_start(old_part.strip_prefix('-')?)?;
let new_start = parse_unified_range_start(new_part.split(" @@").next()?)?;
Some((old_start, new_start))
}
fn parse_unified_range_start(spec: &str) -> Option<usize> {
let spec = spec.trim();
if let Some((start, _)) = spec.split_once(',') {
start.parse().ok()
} else {
spec.parse().ok()
}
}
struct UnifiedDiffNoNewlineAnnotator {
old_last: Option<usize>,
new_last: Option<usize>,
old_line: usize,
new_line: usize,
passthrough: bool,
}
impl UnifiedDiffNoNewlineAnnotator {
fn new(old: &str, new: &str) -> Self {
let old_last = last_line_index_without_trailing_newline(old);
let new_last = last_line_index_without_trailing_newline(new);
Self {
old_last,
new_last,
old_line: 1,
new_line: 1,
passthrough: old_last.is_none() && new_last.is_none(),
}
}
fn write_hunk<W: std::io::Write>(&mut self, out: &mut W, patch: &str) -> Result<()> {
if self.passthrough {
out.write_all(patch.as_bytes())?;
return Ok(());
}
let mut lines = patch.lines().peekable();
while let Some(line) = lines.next() {
self.write_annotated_line(out, line, &mut lines)?;
}
Ok(())
}
fn write_annotated_line<W: std::io::Write>(
&mut self,
out: &mut W,
line: &str,
lines: &mut std::iter::Peekable<std::str::Lines<'_>>,
) -> Result<()> {
if let Some((o, n)) = line
.strip_prefix("@@ ")
.and_then(|_| parse_unified_hunk_starts(line))
{
self.old_line = o;
self.new_line = n;
writeln!(out, "{line}")?;
return Ok(());
}
if line.starts_with("--- ")
|| line.starts_with("+++ ")
|| line.starts_with("diff --git ")
|| line.starts_with("diff --cc ")
|| line.starts_with("diff --combined ")
{
writeln!(out, "{line}")?;
return Ok(());
}
if line.starts_with('-') {
writeln!(out, "{line}")?;
let already_marked = lines
.peek()
.is_some_and(|next| *next == "\\ No newline at end of file");
if !already_marked && self.old_last == Some(self.old_line.saturating_sub(1)) {
writeln!(out, "\\ No newline at end of file")?;
}
self.old_line += 1;
return Ok(());
}
if line.starts_with('+') {
writeln!(out, "{line}")?;
let already_marked = lines
.peek()
.is_some_and(|next| *next == "\\ No newline at end of file");
if !already_marked && self.new_last == Some(self.new_line.saturating_sub(1)) {
writeln!(out, "\\ No newline at end of file")?;
}
self.new_line += 1;
return Ok(());
}
if line.starts_with(' ') {
writeln!(out, "{line}")?;
let already_marked = lines
.peek()
.is_some_and(|next| *next == "\\ No newline at end of file");
if !already_marked {
let old_idx = self.old_line.saturating_sub(1);
let new_idx = self.new_line.saturating_sub(1);
if self.old_last == Some(old_idx) || self.new_last == Some(new_idx) {
writeln!(out, "\\ No newline at end of file")?;
}
}
self.old_line += 1;
self.new_line += 1;
return Ok(());
}
writeln!(out, "{line}")?;
Ok(())
}
}
#[must_use]
pub fn annotate_unified_diff_no_newline_at_eof(patch: &str, old: &str, new: &str) -> String {
let mut annotator = UnifiedDiffNoNewlineAnnotator::new(old, new);
if annotator.passthrough {
return patch.to_string();
}
let mut out = Vec::with_capacity(patch.len() + 64);
let mut lines = patch.lines().peekable();
while let Some(line) = lines.next() {
if annotator
.write_annotated_line(&mut out, line, &mut lines)
.is_err()
{
return patch.to_string();
}
}
String::from_utf8(out).unwrap_or_else(|_| patch.to_string())
}
#[must_use]
pub fn normalize_ignore_space_change_line(line: &str) -> String {
let mut result = String::with_capacity(line.len());
let mut in_space = false;
for c in line.chars() {
if c.is_whitespace() {
if !in_space {
result.push(' ');
in_space = true;
}
} else {
result.push(c);
in_space = false;
}
}
while result.ends_with(' ') {
result.pop();
}
result
}
#[must_use]
pub fn normalize_ignore_space_change(content: &str) -> String {
content
.lines()
.map(normalize_ignore_space_change_line)
.collect::<Vec<_>>()
.join("\n")
}
pub fn count_changes(old_content: &str, new_content: &str) -> (usize, usize) {
count_changes_with_algorithm(old_content, new_content, similar::Algorithm::Myers, false)
}
#[must_use]
pub fn count_changes_with_algorithm(
old_content: &str,
new_content: &str,
algorithm: similar::Algorithm,
use_git_histogram: bool,
) -> (usize, usize) {
if use_git_histogram {
use imara_diff::{Algorithm, Diff, InternedInput};
let input = InternedInput::new(old_content, new_content);
let mut d = Diff::compute(Algorithm::Histogram, &input);
d.postprocess_lines(&input);
return (d.count_additions() as usize, d.count_removals() as usize);
}
use similar::{ChangeTag, TextDiff};
let diff = TextDiff::configure()
.algorithm(algorithm)
.diff_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)
}
#[must_use]
pub fn count_changes_git_histogram(old_content: &str, new_content: &str) -> (usize, usize) {
count_changes_with_algorithm(old_content, new_content, similar::Algorithm::Myers, true)
}
#[must_use]
pub fn count_git_lines(data: &[u8]) -> usize {
if data.is_empty() {
return 0;
}
let mut count = 0usize;
let mut nl_just_seen = false;
for &ch in data {
if ch == b'\n' {
count += 1;
nl_just_seen = true;
} else {
nl_just_seen = false;
}
}
if !nl_just_seen {
count += 1;
}
count
}
pub const GIT_DIFF_MAX_SCORE: u64 = 60_000;
const DIFF_MAX_SCORE: u64 = GIT_DIFF_MAX_SCORE;
const DIFF_MINIMUM_BREAK_SIZE: usize = 400;
const DIFF_DEFAULT_BREAK_SCORE: u64 = 30_000;
pub const GIT_DIFF_DEFAULT_BREAK_SCORE: u64 = DIFF_DEFAULT_BREAK_SCORE;
pub const GIT_DIFF_DEFAULT_MERGE_SCORE_AFTER_BREAK: u64 = 36_000;
const DIFF_HASHBASE: u32 = 107_927;
#[derive(Clone, Copy, Default)]
struct SpanSlot {
hashval: u32,
cnt: u32,
}
struct SpanHashTop {
alloc_log2: u8,
free_slots: i32,
data: Vec<SpanSlot>,
}
impl SpanHashTop {
fn new(initial_log2: u8) -> Self {
let cap = 1usize << initial_log2;
Self {
alloc_log2: initial_log2,
free_slots: initial_free(initial_log2),
data: vec![SpanSlot::default(); cap],
}
}
fn len(&self) -> usize {
1usize << self.alloc_log2
}
fn add_span(&mut self, hashval: u32, cnt: u32) {
loop {
let lim = self.len();
let mut bucket = (hashval as usize) & (lim - 1);
loop {
let h = &mut self.data[bucket];
if h.cnt == 0 {
h.hashval = hashval;
h.cnt = cnt;
self.free_slots -= 1;
if self.free_slots < 0 {
self.rehash();
break;
}
return;
}
if h.hashval == hashval {
h.cnt = h.cnt.saturating_add(cnt);
return;
}
bucket += 1;
if bucket >= lim {
bucket = 0;
}
}
}
}
fn rehash(&mut self) {
let old = std::mem::take(&mut self.data);
let old_log = self.alloc_log2;
self.alloc_log2 = old_log.saturating_add(1);
let new_len = 1usize << self.alloc_log2;
self.free_slots = initial_free(self.alloc_log2);
self.data = vec![SpanSlot::default(); new_len];
let old_sz = 1usize << old_log;
for o in old.iter().take(old_sz) {
let o = *o;
if o.cnt == 0 {
continue;
}
self.add_span_after_rehash(o.hashval, o.cnt);
}
}
fn add_span_after_rehash(&mut self, hashval: u32, cnt: u32) {
loop {
let lim = self.len();
let mut bucket = (hashval as usize) & (lim - 1);
loop {
let h = &mut self.data[bucket];
if h.cnt == 0 {
h.hashval = hashval;
h.cnt = cnt;
self.free_slots -= 1;
if self.free_slots < 0 {
self.rehash();
break;
}
return;
}
if h.hashval == hashval {
h.cnt = h.cnt.saturating_add(cnt);
return;
}
bucket += 1;
if bucket >= lim {
bucket = 0;
}
}
}
}
fn sort_by_hashval(&mut self) {
let sz = self.len();
self.data[..sz].sort_by(|a, b| {
if a.cnt == 0 {
return std::cmp::Ordering::Greater;
}
if b.cnt == 0 {
return std::cmp::Ordering::Less;
}
a.hashval.cmp(&b.hashval)
});
}
}
fn initial_free(sz_log2: u8) -> i32 {
let sz = sz_log2 as i32;
((1i32 << sz_log2) * (sz - 3) / sz).max(0)
}
fn hash_blob_spans(buf: &[u8], is_text: bool) -> SpanHashTop {
let mut hash = SpanHashTop::new(9);
let mut n = 0u32;
let mut accum1: u32 = 0;
let mut accum2: u32 = 0;
let mut i = 0usize;
while i < buf.len() {
let c = buf[i] as u32;
let old_1 = accum1;
i += 1;
if is_text && c == b'\r' as u32 && i < buf.len() && buf[i] == b'\n' {
continue;
}
accum1 = accum1.wrapping_shl(7) ^ accum2.wrapping_shr(25);
accum2 = accum2.wrapping_shl(7) ^ old_1.wrapping_shr(25);
accum1 = accum1.wrapping_add(c);
n += 1;
if n < 64 && c != b'\n' as u32 {
continue;
}
let hashval = (accum1.wrapping_add(accum2.wrapping_mul(0x61))) % DIFF_HASHBASE;
hash.add_span(hashval, n);
n = 0;
accum1 = 0;
accum2 = 0;
}
if n > 0 {
let hashval = (accum1.wrapping_add(accum2.wrapping_mul(0x61))) % DIFF_HASHBASE;
hash.add_span(hashval, n);
}
hash.sort_by_hashval();
hash
}
#[must_use]
pub fn diffcore_count_changes(old: &[u8], new: &[u8]) -> (u64, u64) {
crate::trace::trace_span!("grit.diff.blob");
let src_is_text = !crate::merge_file::is_binary(old);
let dst_is_text = !crate::merge_file::is_binary(new);
let src_count = hash_blob_spans(old, src_is_text);
let dst_count = hash_blob_spans(new, dst_is_text);
let mut sc: u64 = 0;
let mut la: u64 = 0;
let mut si = 0usize;
let mut di = 0usize;
let src_len = src_count.len();
let dst_len = dst_count.len();
loop {
if si >= src_len || src_count.data[si].cnt == 0 {
break;
}
let s_hash = src_count.data[si].hashval;
let s_cnt = u64::from(src_count.data[si].cnt);
while di < dst_len && dst_count.data[di].cnt != 0 && dst_count.data[di].hashval < s_hash {
la += u64::from(dst_count.data[di].cnt);
di += 1;
}
let mut dst_cnt = 0u64;
if di < dst_len && dst_count.data[di].cnt != 0 && dst_count.data[di].hashval == s_hash {
dst_cnt = u64::from(dst_count.data[di].cnt);
di += 1;
}
if s_cnt < dst_cnt {
la += dst_cnt - s_cnt;
sc += s_cnt;
} else {
sc += dst_cnt;
}
si += 1;
}
while di < dst_len && dst_count.data[di].cnt != 0 {
la += u64::from(dst_count.data[di].cnt);
di += 1;
}
(sc, la)
}
#[must_use]
pub fn should_break_rewrite_for_stat(old: &[u8], new: &[u8]) -> bool {
should_break_rewrite_inner(old, new, DIFF_DEFAULT_BREAK_SCORE)
}
#[must_use]
pub fn should_break_rewrite_pair(old: &[u8], new: &[u8], break_score: u64) -> bool {
should_break_rewrite_inner(old, new, break_score)
}
#[must_use]
pub fn apply_diff_filter(
entries: Vec<DiffEntry>,
filter: Option<super::filter::DiffFilter>,
) -> Vec<DiffEntry> {
match filter {
Some(f) => f.filter_entries(entries),
None => entries,
}
}
#[must_use]
pub fn parse_diff_rename_threshold_percent(arg: &str) -> Option<u8> {
let token = if arg.is_empty() { "50" } else { arg };
let internal = parse_diff_rename_score_token(token)?;
Some((internal.saturating_mul(100) / GIT_DIFF_MAX_SCORE).min(100) as u8)
}
pub fn parse_diff_rename_score_token(arg: &str) -> Option<u64> {
let mut num: u64 = 0;
let mut scale: u64 = 1;
let mut dot = false;
let mut saw_digit = false;
for ch in arg.chars() {
if !dot && ch == '.' {
scale = 1;
dot = true;
continue;
}
if ch == '%' {
scale = if dot { scale.saturating_mul(100) } else { 100 };
break;
}
if ch.is_ascii_digit() {
saw_digit = true;
if scale < 100_000 {
scale = scale.saturating_mul(10);
num = num.saturating_mul(10) + u64::from(ch as u8 - b'0');
}
} else {
break;
}
}
if !saw_digit {
return None;
}
Some(if num >= scale {
GIT_DIFF_MAX_SCORE
} else {
GIT_DIFF_MAX_SCORE * num / scale
})
}
#[must_use]
pub fn rewrite_merge_score(old: &[u8], new: &[u8]) -> Option<u64> {
if old.is_empty() {
return None;
}
let max_size = old.len().max(new.len());
if max_size < DIFF_MINIMUM_BREAK_SIZE {
return None;
}
let (src_copied, _) = diffcore_count_changes(old, new);
let src_copied = src_copied.min(old.len() as u64);
let src_removed = (old.len() as u64).saturating_sub(src_copied);
Some(src_removed * DIFF_MAX_SCORE / old.len() as u64)
}
#[must_use]
pub fn rewrite_dissimilarity_index_percent(old: &[u8], new: &[u8]) -> Option<u32> {
let score = rewrite_merge_score(old, new)?;
Some((score * 100 / DIFF_MAX_SCORE).min(100) as u32)
}
fn should_break_rewrite_inner(src: &[u8], dst: &[u8], break_score: u64) -> bool {
if src.is_empty() {
return false;
}
let max_size = src.len().max(dst.len());
if max_size < DIFF_MINIMUM_BREAK_SIZE {
return false;
}
let (src_copied, literal_added) = diffcore_count_changes(src, dst);
let src_copied = src_copied.min(src.len() as u64);
let mut literal_added = literal_added;
let dst_len = dst.len() as u64;
if src_copied < dst_len && literal_added + src_copied > dst_len {
literal_added = dst_len.saturating_sub(src_copied);
}
let src_removed = (src.len() as u64).saturating_sub(src_copied);
let merge_score = src_removed * DIFF_MAX_SCORE / src.len() as u64;
if merge_score > break_score {
return true;
}
let delta_size = src_removed.saturating_add(literal_added);
if delta_size * DIFF_MAX_SCORE / (max_size as u64) < break_score {
return false;
}
let s = src.len() as u64;
if (s * break_score < src_removed * DIFF_MAX_SCORE)
&& (literal_added * 20 < src_removed)
&& (literal_added * 20 < src_copied)
{
return false;
}
true
}
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)
}
pub fn head_path_states(
odb: &Odb,
head_tree: Option<&ObjectId>,
) -> Result<std::collections::BTreeMap<String, (u32, ObjectId)>> {
let mut m = std::collections::BTreeMap::new();
let Some(t) = head_tree else {
return Ok(m);
};
for fe in flatten_tree(odb, t, "")? {
m.insert(fe.path, (fe.mode, fe.oid));
}
Ok(m)
}
fn is_tree_mode(mode: u32) -> bool {
mode == 0o040000
}
fn entries_sorted_by_path(entries: &[DiffEntry]) -> bool {
entries
.windows(2)
.all(|pair| pair[0].path().cmp(pair[1].path()) != std::cmp::Ordering::Greater)
}
fn format_path(prefix: &str, name: &str) -> String {
if prefix.is_empty() {
name.to_owned()
} else {
let mut path = String::with_capacity(prefix.len() + 1 + name.len());
path.push_str(prefix);
path.push('/');
path.push_str(name);
path
}
}
pub fn format_mode(mode: u32) -> String {
format!("{mode:06o}")
}
#[must_use]
pub fn read_submodule_head_for_checkout(sub_dir: &Path) -> Option<ObjectId> {
read_submodule_head(sub_dir)
}
#[must_use]
pub fn submodule_commit_subject_line(c: &CommitData) -> String {
let enc = c.encoding.as_deref().unwrap_or("UTF-8");
let is_latin1 = enc.eq_ignore_ascii_case("ISO8859-1")
|| enc.eq_ignore_ascii_case("ISO-8859-1")
|| enc.eq_ignore_ascii_case("LATIN1")
|| enc.eq_ignore_ascii_case("ISO-8859-15");
if let Some(raw) = c.raw_message.as_deref() {
let line = raw.split(|b| *b == b'\n').next().unwrap_or(raw);
if is_latin1 {
return line
.iter()
.map(|&b| b as char)
.collect::<String>()
.trim()
.to_owned();
}
return String::from_utf8_lossy(line).trim().to_string();
}
c.message.lines().next().unwrap_or("").trim().to_owned()
}
fn submodule_worktree_is_unpopulated_placeholder(sub_dir: &Path) -> bool {
match fs::read_dir(sub_dir) {
Ok(mut it) => it.next().is_none(),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => true,
Err(_) => false,
}
}
fn read_submodule_head(sub_dir: &Path) -> Option<ObjectId> {
read_submodule_head_oid(sub_dir)
}
#[must_use]
pub fn submodule_embedded_git_dir(sub_dir: &Path) -> Option<PathBuf> {
let gitfile = sub_dir.join(".git");
if gitfile.is_file() {
let content = fs::read_to_string(&gitfile).ok()?;
let gitdir = content
.lines()
.find_map(|l| l.strip_prefix("gitdir: "))?
.trim();
Some(if Path::new(gitdir).is_absolute() {
PathBuf::from(gitdir)
} else {
sub_dir.join(gitdir)
})
} else if gitfile.is_dir() {
Some(gitfile)
} else {
None
}
}
#[must_use]
pub(crate) fn is_unregistered_embedded_git_repo(index: &Index, rel: &str, abs: &Path) -> bool {
if rel.is_empty() {
return false;
}
if index
.get(rel.as_bytes(), 0)
.is_some_and(|e| e.mode == crate::index::MODE_GITLINK)
{
return false;
}
let Some(git_dir) = submodule_embedded_git_dir(abs) else {
return false;
};
crate::submodule_gitdir::git_directory_for_embedded_repo(&git_dir)
}
fn find_superproject_git(sub_dir: &Path) -> Option<(PathBuf, PathBuf)> {
let mut cur = sub_dir.parent()?;
loop {
let git_path = cur.join(".git");
if git_path.exists() {
let gd = if git_path.is_file() {
let content = fs::read_to_string(&git_path).ok()?;
let line = content
.lines()
.find_map(|l| l.strip_prefix("gitdir: "))?
.trim();
if Path::new(line).is_absolute() {
PathBuf::from(line)
} else {
cur.join(line)
}
} else {
git_path
};
return Some((cur.to_path_buf(), gd));
}
cur = cur.parent()?;
}
}
pub fn read_submodule_head_oid(sub_dir: &Path) -> Option<ObjectId> {
let mut git_dir = submodule_embedded_git_dir(sub_dir)?;
if let Some((super_wt, super_git_dir)) = find_superproject_git(sub_dir) {
let rel = sub_dir.strip_prefix(&super_wt).ok()?;
let rel_str = rel.to_string_lossy().replace('\\', "/");
let local_mod = super_git_dir
.join("modules")
.join(rel_str.trim_start_matches('/'));
if local_mod.join("HEAD").exists() {
let sg = super_git_dir.canonicalize().unwrap_or(super_git_dir);
let cur = git_dir.canonicalize().unwrap_or_else(|_| git_dir.clone());
if !cur.starts_with(&sg) {
git_dir = local_mod;
}
}
}
let head_content = fs::read_to_string(git_dir.join("HEAD")).ok()?;
let head_trimmed = head_content.trim();
if head_trimmed.starts_with("ref: ") {
match crate::refs::resolve_ref(&git_dir, "HEAD") {
Ok(oid) => Some(oid),
Err(_) => {
let mut found = None;
for branch in ["main", "master"] {
let p = git_dir.join("refs/heads").join(branch);
if let Ok(s) = fs::read_to_string(&p) {
if let Ok(o) = ObjectId::from_hex(s.trim()) {
found = Some(o);
break;
}
}
}
found
}
}
} else {
ObjectId::from_hex(head_trimmed).ok()
}
}
#[must_use]
pub fn submodule_head_object_broken(sub_dir: &Path) -> bool {
let Some(sub_git_dir) = submodule_embedded_git_dir(sub_dir) else {
return false;
};
let Some(head_oid) = read_submodule_head_oid(sub_dir) else {
return false;
};
let odb = Odb::new(&sub_git_dir.join("objects"));
match odb.read(&head_oid) {
Ok(obj) => parse_commit(&obj.data).is_err(),
Err(_) => true,
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct SubmodulePorcelainFlags {
pub new_commits: bool,
pub modified: bool,
pub untracked: bool,
}
pub fn submodule_porcelain_flags(
super_worktree: &Path,
rel_path: &str,
recorded_oid: ObjectId,
) -> SubmodulePorcelainFlags {
let sub_dir = super_worktree.join(rel_path);
let Some(sub_git_dir) = submodule_embedded_git_dir(&sub_dir) else {
return SubmodulePorcelainFlags::default();
};
let Some(sub_head) = read_submodule_head_oid(&sub_dir) else {
return SubmodulePorcelainFlags::default();
};
let new_commits = sub_head != recorded_oid;
let index_path = sub_git_dir.join("index");
let mut sub_index = match crate::index::Index::load(&index_path) {
Ok(ix) => ix,
Err(_) => {
return SubmodulePorcelainFlags {
new_commits,
..Default::default()
}
}
};
let tracked: std::collections::BTreeSet<String> = sub_index
.entries
.iter()
.filter(|e| e.stage() == 0)
.map(|e| String::from_utf8_lossy(&e.path).into_owned())
.collect();
let untracked = submodule_dir_has_untracked_inner(&sub_dir, &sub_dir, &tracked, &sub_index);
let objects_dir = sub_git_dir.join("objects");
let odb = Odb::new(&objects_dir);
let sub_head_tree = (|| -> Option<ObjectId> {
let h = fs::read_to_string(sub_git_dir.join("HEAD")).ok()?;
let h_str = h.trim();
let commit_oid = if let Some(r) = h_str.strip_prefix("ref: ") {
let oid_hex = fs::read_to_string(sub_git_dir.join(r)).ok()?;
ObjectId::from_hex(oid_hex.trim()).ok()?
} else {
ObjectId::from_hex(h_str).ok()?
};
let obj = odb.read(&commit_oid).ok()?;
let commit = parse_commit(&obj.data).ok()?;
Some(commit.tree)
})();
let staged_dirty = sub_head_tree
.as_ref()
.map(|t| diff_index_to_tree(&odb, &sub_index, Some(t), false).map(|v| !v.is_empty()))
.unwrap_or(Ok(false));
let staged_dirty = staged_dirty.unwrap_or(false);
let unstaged_dirty = diff_index_to_worktree(&odb, &mut sub_index, &sub_dir, false, true)
.map(|v| !v.is_empty())
.unwrap_or(false);
let mut modified = staged_dirty || unstaged_dirty;
for e in &sub_index.entries {
if e.stage() != 0 || e.mode != 0o160000 {
continue;
}
let child = String::from_utf8_lossy(&e.path).into_owned();
let full_rel = if rel_path.is_empty() {
child
} else {
format!("{rel_path}/{child}")
};
let nested = submodule_porcelain_flags(super_worktree, &full_rel, e.oid);
modified |= nested.modified;
}
SubmodulePorcelainFlags {
new_commits,
modified,
untracked,
}
}
fn submodule_dir_has_untracked_inner(
dir: &Path,
root: &Path,
tracked: &std::collections::BTreeSet<String>,
owning_index: &Index,
) -> bool {
let entries = match fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return false,
};
let mut sorted: Vec<_> = entries.filter_map(|e| e.ok()).collect();
sorted.sort_by_key(|e| e.file_name());
for entry in sorted {
let name = entry.file_name().to_string_lossy().to_string();
if name == ".git" {
continue;
}
let path = entry.path();
let rel = path
.strip_prefix(root)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| name.clone());
let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
if is_dir {
let is_gitlink = owning_index
.get(rel.as_bytes(), 0)
.is_some_and(|e| e.mode == 0o160000);
if is_gitlink {
let Some(nested_git) = submodule_embedded_git_dir(&path) else {
continue;
};
let nested_index_path = nested_git.join("index");
let Ok(nested_ix) = crate::index::Index::load(&nested_index_path) else {
continue;
};
let nested_tracked: std::collections::BTreeSet<String> = nested_ix
.entries
.iter()
.filter(|e| e.stage() == 0)
.map(|e| String::from_utf8_lossy(&e.path).into_owned())
.collect();
if submodule_dir_has_untracked_inner(&path, &path, &nested_tracked, &nested_ix) {
return true;
}
} else if submodule_dir_has_untracked_inner(&path, root, tracked, owning_index) {
return true;
}
} else if !tracked.contains(&rel) {
return true;
}
}
false
}
pub fn apply_orderfile_entries(
entries: Vec<DiffEntry>,
order_path: &str,
cwd: &Path,
) -> Result<Vec<DiffEntry>> {
apply_orderfile(entries, order_path, cwd)
}
fn apply_orderfile(
mut entries: Vec<DiffEntry>,
order_path: &str,
cwd: &Path,
) -> Result<Vec<DiffEntry>> {
let patterns = read_orderfile_patterns(order_path, cwd)?;
let sort_key = |entry: &DiffEntry| -> usize {
let path = entry
.new_path
.as_ref()
.or(entry.old_path.as_ref())
.cloned()
.unwrap_or_default();
for (i, pat) in patterns.iter().enumerate() {
if orderfile_pattern_matches(pat, &path) {
return i;
}
}
patterns.len()
};
entries.sort_by_key(|e| sort_key(e));
Ok(entries)
}
pub fn read_orderfile_patterns(order_path: &str, cwd: &Path) -> Result<Vec<String>> {
let path = Path::new(order_path);
let resolved = if path.is_absolute() {
path.to_path_buf()
} else {
cwd.join(path)
};
let _meta = std::fs::metadata(&resolved)
.map_err(|e| Error::Message(format!("could not read orderfile {order_path}: {e}")))?;
let mut f = std::fs::File::open(&resolved)
.map_err(|e| Error::Message(format!("could not read orderfile {order_path}: {e}")))?;
let mut content = String::new();
std::io::Read::read_to_string(&mut f, &mut content)
.map_err(|e| Error::Message(format!("could not read orderfile {order_path}: {e}")))?;
Ok(content
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.collect())
}
pub fn apply_rotate_skip_entries(
mut entries: Vec<DiffEntry>,
rotate_to: Option<&str>,
skip_to: Option<&str>,
) -> Result<Vec<DiffEntry>> {
let Some(needle) = rotate_to.or(skip_to) else {
return Ok(entries);
};
let needle = needle.trim();
if needle.is_empty() {
return Ok(entries);
}
let idx = entries
.iter()
.position(|e| e.path() == needle)
.ok_or_else(|| {
Error::DiffPath(crate::error::DiffPathError::NoSuchPathInDiff {
path: needle.to_owned(),
})
})?;
if rotate_to.is_some() {
entries.rotate_left(idx);
}
if let Some(skip) = skip_to.filter(|s| !s.trim().is_empty()) {
let pos = entries
.iter()
.position(|e| e.path() == skip)
.ok_or_else(|| {
Error::DiffPath(crate::error::DiffPathError::NoSuchPathInDiff {
path: skip.to_owned(),
})
})?;
entries.drain(..pos);
}
Ok(entries)
}
pub fn apply_rotate_skip_log_entries(
odb: &Odb,
commit_tree: &ObjectId,
entries: Vec<DiffEntry>,
rotate_to: Option<&str>,
skip_to: Option<&str>,
) -> Result<Vec<DiffEntry>> {
fn trimmed(s: Option<&str>) -> Option<&str> {
s.map(str::trim).filter(|t| !t.is_empty())
}
if trimmed(rotate_to).is_none() && trimmed(skip_to).is_none() {
return Ok(entries);
}
let tree_paths = crate::merge_diff::all_blob_paths_in_tree_order(odb, commit_tree);
apply_rotate_skip_ordered_paths(&tree_paths, entries, rotate_to, skip_to)
}
fn apply_rotate_skip_ordered_paths(
tree_paths: &[String],
entries: Vec<DiffEntry>,
rotate_to: Option<&str>,
skip_to: Option<&str>,
) -> Result<Vec<DiffEntry>> {
let rotate = rotate_to.and_then(|s| {
let t = s.trim();
(!t.is_empty()).then_some(t)
});
let skip = skip_to.and_then(|s| {
let t = s.trim();
(!t.is_empty()).then_some(t)
});
if rotate.is_none() && skip.is_none() {
return Ok(entries);
}
use std::collections::HashMap;
let mut by_path: HashMap<String, DiffEntry> = HashMap::new();
for e in entries {
by_path.insert(e.path().to_string(), e);
}
if rotate.is_none() {
let Some(skip_path) = skip else {
return Ok(by_path.into_values().collect());
};
let idx = tree_paths
.iter()
.position(|p| p == skip_path)
.ok_or_else(|| {
Error::DiffPath(crate::error::DiffPathError::NoSuchPathInDiff {
path: skip_path.to_owned(),
})
})?;
let mut out = Vec::new();
for p in tree_paths.iter().skip(idx) {
if let Some(e) = by_path.remove(p) {
out.push(e);
}
}
return Ok(out);
}
let Some(needle) = rotate else {
return Ok(by_path.into_values().collect());
};
let idx = tree_paths.iter().position(|p| p == needle).ok_or_else(|| {
Error::DiffPath(crate::error::DiffPathError::NoSuchPathInDiff {
path: needle.to_owned(),
})
})?;
let mut order: Vec<String> = tree_paths.to_vec();
order.rotate_left(idx);
if let Some(skip_path) = skip {
let pos = order.iter().position(|p| p == skip_path).ok_or_else(|| {
Error::DiffPath(crate::error::DiffPathError::NoSuchPathInDiff {
path: skip_path.to_owned(),
})
})?;
order.drain(..pos);
}
let mut out = Vec::new();
for p in order {
if let Some(e) = by_path.remove(&p) {
out.push(e);
}
}
Ok(out)
}
pub fn orderfile_pattern_matches(pattern: &str, path: &str) -> bool {
let name = path.rsplit('/').next().unwrap_or(path);
orderfile_glob_match(pattern, name) || orderfile_glob_match(pattern, path)
}
fn orderfile_glob_match(pattern: &str, text: &str) -> bool {
let mut pi = 0;
let mut ti = 0;
let pb = pattern.as_bytes();
let tb = text.as_bytes();
let mut star_pi = usize::MAX;
let mut star_ti = 0;
while ti < tb.len() {
if pi < pb.len() && (pb[pi] == b'?' || pb[pi] == tb[ti]) {
pi += 1;
ti += 1;
} else if pi < pb.len() && pb[pi] == b'*' {
star_pi = pi;
star_ti = ti;
pi += 1;
} else if star_pi != usize::MAX {
pi = star_pi + 1;
star_ti += 1;
ti = star_ti;
} else {
return false;
}
}
while pi < pb.len() && pb[pi] == b'*' {
pi += 1;
}
pi == pb.len()
}
#[cfg(test)]
mod diff_index_to_tree_cache_tree_tests {
use std::rc::Rc;
use std::sync::atomic::{AtomicUsize, Ordering};
use super::*;
use crate::index::{Index, IndexEntry, MODE_EXECUTABLE, MODE_GITLINK, MODE_REGULAR};
use crate::objects::ObjectKind;
use crate::write_tree::{build_cache_tree_from_index, write_tree_from_index};
use tempfile::TempDir;
struct TreeReadCounter {
counter: Rc<AtomicUsize>,
}
impl TreeReadCounter {
fn start() -> Self {
let counter = Rc::new(AtomicUsize::new(0));
set_diff_tree_read_hook_for_tests(Some(counter.clone()));
Self { counter }
}
fn get(&self) -> usize {
self.counter.load(Ordering::Relaxed)
}
}
impl Drop for TreeReadCounter {
fn drop(&mut self) {
set_diff_tree_read_hook_for_tests(None);
}
}
fn diff_index_to_tree_flatten_reference(
odb: &Odb,
index: &Index,
head_tree: &ObjectId,
ignore_submodules: bool,
) -> Result<Vec<DiffEntry>> {
let mut flat_index = index.clone();
flat_index.clear_cache_tree();
diff_index_to_tree(odb, &flat_index, Some(head_tree), ignore_submodules)
}
fn index_entry(path: &str, mode: u32, oid: ObjectId) -> IndexEntry {
IndexEntry {
ctime_sec: 0,
ctime_nsec: 0,
mtime_sec: 0,
mtime_nsec: 0,
dev: 0,
ino: 0,
mode,
uid: 0,
gid: 0,
size: 0,
oid,
flags: path.len().min(0xfff) as u16,
flags_extended: None,
path: path.as_bytes().to_vec(),
base_index_pos: 0,
}
}
fn repo_with_tree(depth: usize, width: usize) -> (TempDir, Odb, ObjectId, Index) {
let temp = TempDir::new().expect("tempdir");
let odb = Odb::new(temp.path());
let mut index = Index::new();
for d in 0..depth {
for w in 0..width {
let path = format!("unchanged/d{d}/f{w}.txt");
let oid = odb
.write(ObjectKind::Blob, format!("body-{d}-{w}").as_bytes())
.expect("blob");
index.add_or_replace(index_entry(&path, MODE_REGULAR, oid));
}
}
let head_tree = write_tree_from_index(&odb, &index, "").expect("write tree");
let cache = build_cache_tree_from_index(&odb, &index).expect("cache-tree");
index.set_cache_tree(cache);
index.cache_tree_root = Some(head_tree);
(temp, odb, head_tree, index)
}
#[test]
fn diff_index_to_tree_cache_tree_fast_path_matches_slow() {
let (_temp, odb, head_tree, mut index) = repo_with_tree(4, 5);
assert!(index.cache_tree.as_ref().is_some_and(|c| c.is_valid()));
let mut state: u64 = 0xDEAD_BEEF;
for _ in 0..24 {
state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
let d = (state % 4) as usize;
state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
let w = (state % 5) as usize;
let path = format!("unchanged/d{d}/f{w}.txt");
index.invalidate_cache_tree_for_path(path.as_bytes());
assert!(
index.cache_tree_root.is_none(),
"partial invalidation clears cache_tree_root like Index::add"
);
assert!(
index.cache_tree.as_ref().is_some_and(|r| !r.is_valid()),
"root cache node must be invalid after partial invalidation"
);
if let Some(e) = index.get_mut(path.as_bytes(), 0) {
let new_oid = odb
.write(ObjectKind::Blob, format!("new-{d}-{w}").as_bytes())
.expect("blob");
e.oid = new_oid;
}
let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("fast");
let flat = diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, false)
.expect("flat");
assert_eq!(fast, flat, "mismatch after mutating {path}");
}
}
#[test]
fn diff_index_to_tree_skips_unchanged_subtrees() {
let (_temp, odb, head_tree, index) = repo_with_tree(5, 6);
let reads = TreeReadCounter::start();
diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("cached diff");
let cached_reads = reads.get();
let mut no_cache = index.clone();
no_cache.clear_cache_tree();
let reads = TreeReadCounter::start();
diff_index_to_tree(&odb, &no_cache, Some(&head_tree), false).expect("flat diff");
let flat_reads = reads.get();
assert!(
cached_reads < flat_reads,
"expected fewer tree reads with cache-tree (cached={cached_reads}, flat={flat_reads})"
);
assert_eq!(
cached_reads, 1,
"unchanged repo should only read the root tree"
);
}
#[test]
fn diff_index_to_tree_partial_invalidation_skips_sibling_subtree() {
let (_temp, odb, head_tree, mut index) = repo_with_tree(5, 6);
let path = "unchanged/d1/f2.txt";
index.invalidate_cache_tree_for_path(path.as_bytes());
let new_oid = odb.write(ObjectKind::Blob, b"changed").expect("blob");
index.get_mut(path.as_bytes(), 0).expect("entry").oid = new_oid;
let flat =
diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, false).expect("flat");
let reads = TreeReadCounter::start();
let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("fast");
let cached_reads = reads.get();
assert_eq!(fast, flat);
let reads = TreeReadCounter::start();
diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, false).expect("flat");
let flat_reads = reads.get();
assert!(
cached_reads < flat_reads,
"partial cache-tree should skip sibling dirs (cached={cached_reads}, flat={flat_reads})"
);
}
fn cache_leaf(name: &str) -> CacheTreeNode {
CacheTreeNode::valid(name.as_bytes().to_vec(), 1, ObjectId::zero(), Vec::new())
}
#[test]
fn cache_children_lookup_matches_linear_scan_on_wide_unsorted_nodes() {
let mut children: Vec<CacheTreeNode> = (0..CACHE_CHILD_LINEAR_SCAN_MAX * 3)
.rev()
.map(|i| cache_leaf(&format!("d{i:03}")))
.collect();
let mut first_dup = cache_leaf("d007");
first_dup.entry_count = 7;
children.insert(3, first_dup);
let root = CacheTreeNode::valid(Vec::new(), 0, ObjectId::zero(), children);
let lookup = CacheChildren::new(Some(&root));
assert!(
!lookup.sorted.is_empty(),
"wide nodes must use the sorted lookup"
);
for name in ["d000", "d007", "d020", "d047", "zzz", "d", ""] {
let linear = root.children.iter().find(|c| c.name == name.as_bytes());
let fast = lookup.get(name.as_bytes());
assert_eq!(
fast.map(|c| (&c.name, c.entry_count)),
linear.map(|c| (&c.name, c.entry_count)),
"lookup of {name:?}"
);
}
assert_eq!(lookup.get(b"d007").map(|c| c.entry_count), Some(7));
let narrow = CacheTreeNode::valid(
Vec::new(),
0,
ObjectId::zero(),
vec![cache_leaf("b"), cache_leaf("a")],
);
let lookup = CacheChildren::new(Some(&narrow));
assert!(lookup.sorted.is_empty());
assert_eq!(lookup.get(b"a").map(|c| c.name.as_slice()), Some(&b"a"[..]));
assert!(lookup.get(b"c").is_none());
assert!(CacheChildren::new(None).get(b"a").is_none());
}
#[test]
fn index_relative_in_dir_requires_a_component_boundary() {
assert_eq!(index_relative_in_dir(b"a/b", b""), Some(&b"a/b"[..]));
assert_eq!(index_relative_in_dir(b"a/b", b"a"), Some(&b"b"[..]));
assert_eq!(index_relative_in_dir(b"a", b"a"), Some(&b""[..]));
assert_eq!(index_relative_in_dir(b"ab/c", b"a"), None);
assert_eq!(index_relative_in_dir(b"a.c", b"a"), None);
assert_eq!(index_relative_in_dir(b"b", b"a"), None);
assert_eq!(join_index_path(b"", b"x"), b"x".to_vec());
assert_eq!(join_index_path(b"a/b", b"x"), b"a/b/x".to_vec());
}
#[test]
fn diff_index_to_tree_wide_unsorted_cache_tree_matches_flatten() {
let temp = TempDir::new().expect("tempdir");
let odb = Odb::new(temp.path());
let mut index = Index::new();
let width = CACHE_CHILD_LINEAR_SCAN_MAX * 4;
for d in 0..width {
for f in 0..2 {
let path = format!("dir{d:03}/sub/f{f}.txt");
let oid = odb.write(ObjectKind::Blob, path.as_bytes()).expect("blob");
index.add_or_replace(index_entry(&path, MODE_REGULAR, oid));
}
}
let head_tree = write_tree_from_index(&odb, &index, "").expect("write tree");
let mut cache = build_cache_tree_from_index(&odb, &index).expect("cache-tree");
cache.children.reverse();
index.set_cache_tree(cache);
index.cache_tree_root = Some(head_tree);
let changed = [
"dir003/sub/f1.txt",
"dir031/sub/f0.txt",
"dir063/sub/f1.txt",
];
for path in changed {
index.invalidate_cache_tree_for_path(path.as_bytes());
let oid = odb.write(ObjectKind::Blob, b"changed").expect("blob");
index.get_mut(path.as_bytes(), 0).expect("entry").oid = oid;
}
for path in ["dir010/sub/new.txt", "dir999/new.txt", "top.txt"] {
index.invalidate_cache_tree_for_path(path.as_bytes());
let oid = odb.write(ObjectKind::Blob, path.as_bytes()).expect("blob");
index.add_or_replace(index_entry(path, MODE_REGULAR, oid));
}
for path in [
"dir020/sub/f0.txt",
"dir020/sub/f1.txt",
"dir021/sub/f0.txt",
] {
index.invalidate_cache_tree_for_path(path.as_bytes());
index.remove(path.as_bytes());
}
let flat =
diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, false).expect("flat");
let reads = TreeReadCounter::start();
let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("fast");
let cached_reads = reads.get();
assert_eq!(fast, flat);
assert_eq!(fast.len(), changed.len() + 3 + 3);
assert!(
cached_reads <= 1 + 2 * 6,
"unchanged siblings must be skipped through the cache-tree (reads={cached_reads})"
);
}
#[test]
fn diff_index_to_tree_cache_tree_handles_non_utf8_directory_names() {
let temp = TempDir::new().expect("tempdir");
let odb = Odb::new(temp.path());
let mut index = Index::new();
let raw_dirs: [&[u8]; 3] = [b"caf\xe9", b"caf\xef\xbf\xbd", b"plain"];
for dir in raw_dirs {
for f in [&b"a.txt"[..], b"b.txt"] {
let path = [dir, b"/", f].concat();
let oid = odb.write(ObjectKind::Blob, &path).expect("blob");
let mut entry = index_entry("x", MODE_REGULAR, oid);
entry.flags = path.len().min(0xfff) as u16;
entry.path = path;
index.add_or_replace(entry);
}
}
let head_tree = write_tree_from_index(&odb, &index, "").expect("write tree");
let cache = build_cache_tree_from_index(&odb, &index).expect("cache-tree");
index.set_cache_tree(cache);
index.cache_tree_root = Some(head_tree);
let changed: &[u8] = b"caf\xe9/b.txt";
index.invalidate_cache_tree_for_path(changed);
let oid = odb.write(ObjectKind::Blob, b"changed").expect("blob");
index.get_mut(changed, 0).expect("entry").oid = oid;
let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("fast");
assert_eq!(fast.len(), 1, "{fast:?}");
assert_eq!(fast[0].status, DiffStatus::Modified);
assert_eq!(
fast[0].old_oid,
odb.write(ObjectKind::Blob, changed).expect("blob")
);
assert_eq!(fast[0].new_oid, oid);
}
#[test]
fn diff_index_to_tree_reports_mode_gitlink_and_intent_to_add() {
let temp = TempDir::new().expect("tempdir");
let odb = Odb::new(temp.path());
let blob = odb.write(ObjectKind::Blob, b"x").expect("blob");
let mut index = Index::new();
index.add_or_replace(index_entry("tracked.txt", MODE_REGULAR, blob));
let head_tree = write_tree_from_index(&odb, &index, "").expect("tree");
let cache = build_cache_tree_from_index(&odb, &index).expect("cache");
index.set_cache_tree(cache);
let mode_entry = index_entry("tracked.txt", MODE_EXECUTABLE, blob);
index.add_or_replace(mode_entry);
index.invalidate_cache_tree_for_path(b"tracked.txt");
let diffs = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("diff");
assert_eq!(diffs.len(), 1);
assert_eq!(diffs[0].status, DiffStatus::Modified);
assert_eq!(diffs[0].old_mode, format_mode(MODE_REGULAR));
assert_eq!(diffs[0].new_mode, format_mode(MODE_EXECUTABLE));
let mut ita = index_entry("new.txt", MODE_REGULAR, blob);
ita.set_intent_to_add(true);
index.add_or_replace(ita);
let diffs = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("diff");
assert!(
diffs.iter().all(|d| d.path() != "new.txt"),
"intent-to-add must not appear in staged diff"
);
let gitlink: ObjectId = "855827c583bc30645ba427885caa40c5b81764d2"
.parse()
.expect("oid");
let mut sub_index = Index::new();
sub_index.add_or_replace(index_entry("sub", MODE_GITLINK, gitlink));
let sub_tree = write_tree_from_index(&odb, &sub_index, "").expect("tree");
let sub_cache = build_cache_tree_from_index(&odb, &sub_index).expect("cache");
sub_index.set_cache_tree(sub_cache);
let diffs =
diff_index_to_tree(&odb, &sub_index, Some(&sub_tree), true).expect("gitlink diff");
assert!(
diffs.is_empty(),
"ignored gitlink should not diff: {diffs:?}"
);
}
#[test]
fn ignore_submodules_skips_gitlink_in_added_directory_cache_walk() {
let temp = TempDir::new().expect("tempdir");
let odb = Odb::new(temp.path());
let gitlink: ObjectId = "855827c583bc30645ba427885caa40c5b81764d2"
.parse()
.expect("oid");
let head_tree = write_tree_from_index(&odb, &Index::new(), "").expect("empty tree");
let mut index = Index::new();
index.add_or_replace(index_entry("newdir/sub", MODE_GITLINK, gitlink));
let cache = build_cache_tree_from_index(&odb, &index).expect("cache");
index.set_cache_tree(cache);
index.invalidate_cache_tree_for_path(b"newdir/sub");
let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), true).expect("fast");
let flat =
diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, true).expect("flat");
assert_eq!(fast, flat);
assert!(
fast.is_empty(),
"nested gitlinks in added dirs must be ignored: {fast:?}"
);
}
#[test]
fn ignore_submodules_skips_gitlink_in_deleted_directory_cache_walk() {
let temp = TempDir::new().expect("tempdir");
let odb = Odb::new(temp.path());
let blob = odb.write(ObjectKind::Blob, b"keep").expect("blob");
let gitlink: ObjectId = "855827c583bc30645ba427885caa40c5b81764d2"
.parse()
.expect("oid");
let mut index = Index::new();
index.add_or_replace(index_entry("keep.txt", MODE_REGULAR, blob));
index.add_or_replace(index_entry("olddir/sub", MODE_GITLINK, gitlink));
let head_tree = write_tree_from_index(&odb, &index, "").expect("head");
let cache = build_cache_tree_from_index(&odb, &index).expect("cache");
index.set_cache_tree(cache);
index
.entries
.retain(|e| !String::from_utf8_lossy(&e.path).starts_with("olddir/"));
index.invalidate_cache_tree_for_path(b"olddir/sub");
let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), true).expect("fast");
let flat =
diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, true).expect("flat");
assert_eq!(fast, flat);
assert!(
fast.is_empty(),
"nested gitlinks in deleted dirs must be ignored: {fast:?}"
);
}
#[test]
fn diff_trees_skips_identical_subtree_oids() {
let temp = TempDir::new().expect("tempdir");
let odb = Odb::new(temp.path());
let leaf = odb.write(ObjectKind::Blob, b"leaf").expect("blob");
let mut deep = Index::new();
for i in 0..20 {
deep.add_or_replace(index_entry(&format!("deep/n{i}.txt"), MODE_REGULAR, leaf));
}
let tree = write_tree_from_index(&odb, &deep, "").expect("tree");
let reads = TreeReadCounter::start();
let entries = diff_trees(&odb, Some(&tree), Some(&tree), "").expect("diff");
assert!(entries.is_empty());
assert_eq!(
reads.get(),
0,
"identical tree oids should short-circuit without reading tree objects"
);
}
#[test]
fn diff_tree_path_change_count_matches_diff_trees() {
let temp = TempDir::new().expect("tempdir");
let odb = Odb::new(temp.path());
let a = odb.write(ObjectKind::Blob, b"a").expect("blob");
let b = odb.write(ObjectKind::Blob, b"b").expect("blob");
let mut left = Index::new();
left.add_or_replace(index_entry("f.txt", MODE_REGULAR, a));
let mut right = Index::new();
right.add_or_replace(index_entry("f.txt", MODE_REGULAR, b));
right.add_or_replace(index_entry("g.txt", MODE_REGULAR, a));
let old_tree = write_tree_from_index(&odb, &left, "").expect("old");
let new_tree = write_tree_from_index(&odb, &right, "").expect("new");
let entries = diff_trees(&odb, Some(&old_tree), Some(&new_tree), "").expect("diff");
let count =
diff_tree_path_change_count(&odb, Some(&old_tree), Some(&new_tree), "").expect("count");
assert_eq!(count, entries.len());
assert_eq!(count, 2);
}
}
#[cfg(test)]
mod gitlink_tree_worktree_tests {
use std::fs;
use crate::index::{Index, IndexEntry, MODE_GITLINK};
use crate::objects::{ObjectId, ObjectKind};
use crate::repo::init_repository;
use super::diff_tree_to_worktree;
fn encode_tree_gitlink(name: &str, oid: &ObjectId) -> Vec<u8> {
let mut data = format!("160000 {name}\0").into_bytes();
data.extend_from_slice(oid.as_bytes());
data
}
#[test]
fn unpopulated_gitlink_matching_index_produces_no_diff_entry() {
let dir = tempfile::tempdir().expect("tempdir");
let work_tree = dir.path();
fs::create_dir(work_tree.join("sub")).expect("sub placeholder");
let repo = init_repository(
work_tree,
false,
"main",
None,
crate::RefStorageFormat::Files,
)
.expect("init");
let gitlink: ObjectId = "855827c583bc30645ba427885caa40c5b81764d2"
.parse()
.expect("gitlink oid");
let tree_oid = repo
.odb()
.write(ObjectKind::Tree, &encode_tree_gitlink("sub", &gitlink))
.expect("write tree");
let path = b"sub".to_vec();
let mut index = Index::new();
index.add_or_replace(IndexEntry {
ctime_sec: 0,
ctime_nsec: 0,
mtime_sec: 0,
mtime_nsec: 0,
dev: 0,
ino: 0,
mode: MODE_GITLINK,
uid: 0,
gid: 0,
size: 0,
oid: gitlink,
flags: (path.len().min(0xfff)) as u16,
flags_extended: None,
path,
base_index_pos: 0,
});
let entries = diff_tree_to_worktree(&repo.odb(), Some(&tree_oid), work_tree, &index)
.expect("diff_tree_to_worktree");
assert!(
entries.is_empty(),
"clean uninitialized gitlink should not appear in tree→worktree diff: {entries:?}"
);
}
#[test]
fn untracked_only_in_checked_out_submodule_produces_no_diff_entry() {
use std::process::Command;
let dir = tempfile::tempdir().expect("tempdir");
let work_tree = dir.path();
fs::create_dir(work_tree.join("sub")).expect("sub dir");
let repo = init_repository(
work_tree,
false,
"main",
None,
crate::RefStorageFormat::Files,
)
.expect("init");
let sub = work_tree.join("sub");
for args in [
&["init", "-q"][..],
&["config", "user.name", "T"][..],
&["config", "user.email", "t@e.com"][..],
] {
let status = Command::new("git")
.current_dir(&sub)
.args(args)
.status()
.expect("git");
assert!(status.success(), "git {args:?} failed");
}
fs::write(sub.join("tracked.txt"), b"v1\n").expect("write");
for args in [&["add", "tracked.txt"][..], &["commit", "-qm", "sub"][..]] {
let status = Command::new("git")
.current_dir(&sub)
.args(args)
.status()
.expect("git");
assert!(status.success());
}
let head_out = Command::new("git")
.current_dir(&sub)
.args(["rev-parse", "HEAD"])
.output()
.expect("rev-parse");
let gitlink = std::str::from_utf8(&head_out.stdout)
.expect("utf8")
.trim()
.parse::<ObjectId>()
.expect("oid");
let tree_oid = repo
.odb()
.write(ObjectKind::Tree, &encode_tree_gitlink("sub", &gitlink))
.expect("tree");
let path = b"sub".to_vec();
let mut index = Index::new();
index.add_or_replace(IndexEntry {
ctime_sec: 0,
ctime_nsec: 0,
mtime_sec: 0,
mtime_nsec: 0,
dev: 0,
ino: 0,
mode: MODE_GITLINK,
uid: 0,
gid: 0,
size: 0,
oid: gitlink,
flags: (path.len().min(0xfff)) as u16,
flags_extended: None,
path,
base_index_pos: 0,
});
fs::write(sub.join("untracked.txt"), b"extra\n").expect("untracked");
let entries = diff_tree_to_worktree(&repo.odb(), Some(&tree_oid), work_tree, &index)
.expect("diff_tree_to_worktree");
assert!(
entries.is_empty(),
"untracked-only submodule dirt must not appear in default tree→worktree diff: {entries:?}"
);
}
}
#[cfg(test)]
mod racy_index_mtime_diff_tests {
use super::*;
use crate::index::{entry_from_metadata, Index, MODE_REGULAR};
use crate::objects::{HashAlgo, ObjectKind};
use filetime::FileTime;
use std::fs;
use std::path::PathBuf;
const INDEX_MTIME: (u32, u32) = (1_700_000_000, 123_456_789);
fn pin_mtime(path: &Path, sec: u32, nsec: u32) {
filetime::set_file_mtime(path, FileTime::from_unix_time(i64::from(sec), nsec))
.expect("set mtime");
}
fn minimal_sha256_repo(work_tree: &Path) -> (PathBuf, Odb) {
let git_dir = work_tree.join(".git");
fs::create_dir_all(git_dir.join("objects")).expect("objects dir");
fs::write(
git_dir.join("config"),
"[extensions]\n\tobjectformat = sha256\n",
)
.expect("config");
let odb = Odb::new(&git_dir.join("objects")).with_config_git_dir(git_dir.clone());
(git_dir, odb)
}
#[test]
fn detects_same_size_change_when_entry_mtime_matches_index_mtime() {
let td = tempfile::tempdir().expect("tempdir");
let wt = td.path();
let content_a = b"same length!!";
let content_b = b"same length??";
assert_eq!(content_a.len(), content_b.len());
fs::write(wt.join("f.txt"), content_a).expect("write");
pin_mtime(&wt.join("f.txt"), INDEX_MTIME.0, INDEX_MTIME.1);
let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
let git_dir = wt.join(".git");
fs::create_dir_all(git_dir.join("objects")).expect("objects dir");
let odb = Odb::new(&git_dir.join("objects"));
let oid_a = odb.hash(ObjectKind::Blob, content_a).expect("hash");
let entry = entry_from_metadata(&meta, b"f.txt", oid_a, MODE_REGULAR);
let mut index = Index::new();
index.source_mtime = Some(INDEX_MTIME);
index.entries.push(entry);
fs::write(wt.join("f.txt"), content_b).expect("rewrite");
pin_mtime(&wt.join("f.txt"), INDEX_MTIME.0, INDEX_MTIME.1);
let diff = diff_index_to_worktree(&odb, &mut index, wt, false, false).expect("diff");
assert_eq!(
diff.len(),
1,
"racy entry must be re-hashed, not stat-trusted"
);
assert_eq!(diff[0].path(), "f.txt");
}
#[test]
#[cfg(unix)]
fn non_utf8_symlink_clean_racy_equal_mtime_reports_no_diff() {
use crate::index::MODE_SYMLINK;
use std::os::unix::ffi::OsStrExt as _;
use std::os::unix::fs::symlink;
let td = tempfile::tempdir().expect("tempdir");
let wt = td.path();
let target = std::ffi::OsStr::from_bytes(b"bad-\xff-target");
let link_path = wt.join("link");
symlink(target, &link_path).expect("symlink");
let ft = FileTime::from_unix_time(i64::from(INDEX_MTIME.0), INDEX_MTIME.1);
filetime::set_symlink_file_times(&link_path, ft, ft).expect("set symlink mtime");
let meta = fs::symlink_metadata(&link_path).expect("stat");
let git_dir = wt.join(".git");
fs::create_dir_all(git_dir.join("objects")).expect("objects dir");
let odb = Odb::new(&git_dir.join("objects"));
let oid = odb.hash(ObjectKind::Blob, target.as_bytes()).expect("hash");
let entry = entry_from_metadata(&meta, b"link", oid, MODE_SYMLINK);
let mut index = Index::new();
index.source_mtime = Some(INDEX_MTIME);
index.entries.push(entry);
let diff = diff_index_to_worktree(&odb, &mut index, wt, false, false).expect("diff");
assert!(
diff.is_empty(),
"clean non-UTF-8 symlink must not be reported modified: {diff:?}"
);
}
#[test]
fn sha256_clean_racy_equal_mtime_reports_no_diff() {
let td = tempfile::tempdir().expect("tempdir");
let wt = td.path();
let (_git_dir, odb) = minimal_sha256_repo(wt);
assert_eq!(odb.hash_algo(), HashAlgo::Sha256);
let bytes = b"sha256 width!!";
fs::write(wt.join("f.txt"), bytes).expect("write");
pin_mtime(&wt.join("f.txt"), INDEX_MTIME.0, INDEX_MTIME.1);
let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
let oid = odb.hash(ObjectKind::Blob, bytes).expect("hash");
let entry = entry_from_metadata(&meta, b"f.txt", oid, MODE_REGULAR);
let mut index = Index::new();
index.hash_algo = HashAlgo::Sha256;
index.source_mtime = Some(INDEX_MTIME);
index.entries.push(entry);
let diff = diff_index_to_worktree(&odb, &mut index, wt, false, false).expect("diff");
assert!(
diff.is_empty(),
"clean SHA-256 racy entry must not be reported modified: {diff:?}"
);
}
}
#[cfg(test)]
mod smudge_racily_clean_tests {
use super::*;
use crate::config::ConfigSet;
use crate::crlf::{self, FileAttrs};
use crate::index::{entry_from_metadata, Index, MODE_REGULAR};
use crate::objects::{HashAlgo, ObjectKind};
use filetime::FileTime;
use std::fs;
use std::path::PathBuf;
const INDEX_MTIME_PIN: (u32, u32) = (1_700_000_000, 123_456_789);
fn mtime_from_metadata(meta: &fs::Metadata) -> (u32, u32) {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
(meta.mtime() as u32, meta.mtime_nsec() as u32)
}
#[cfg(not(unix))]
{
use std::time::UNIX_EPOCH;
let mtime = meta
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.unwrap_or_default();
(mtime.as_secs() as u32, mtime.subsec_nanos())
}
}
fn pin_mtime_and_sample(path: &Path, sec: u32, nsec: u32) -> (u32, u32) {
filetime::set_file_mtime(path, FileTime::from_unix_time(i64::from(sec), nsec))
.expect("set mtime");
let meta = fs::symlink_metadata(path).expect("stat file");
mtime_from_metadata(&meta)
}
fn setup_index_mtime(work_tree: &Path) -> (u32, u32) {
let probe = work_tree.join(".grit-index-mtime-probe");
fs::write(&probe, b"").expect("probe file");
pin_mtime_and_sample(&probe, INDEX_MTIME_PIN.0, INDEX_MTIME_PIN.1)
}
fn minimal_repo(work_tree: &Path, config: &str) -> (PathBuf, Odb) {
let git_dir = work_tree.join(".git");
fs::create_dir_all(git_dir.join("objects")).expect("objects dir");
fs::write(git_dir.join("config"), config).expect("config");
let odb = Odb::new(&git_dir.join("objects")).with_config_git_dir(git_dir.clone());
(git_dir, odb)
}
fn staged_entry_for_file(
odb: &Odb,
work_tree: &Path,
rel: &str,
bytes: &[u8],
mtime: (u32, u32),
) -> IndexEntry {
let path = work_tree.join(rel);
fs::write(&path, bytes).expect("write file");
pin_mtime_and_sample(&path, mtime.0, mtime.1);
let meta = fs::symlink_metadata(&path).expect("stat file");
let oid = odb.hash(ObjectKind::Blob, bytes).expect("hash");
entry_from_metadata(&meta, rel.as_bytes(), oid, MODE_REGULAR)
}
fn staged_entry_with_oid(
work_tree: &Path,
rel: &str,
worktree_bytes: &[u8],
oid: crate::objects::ObjectId,
mtime: (u32, u32),
) -> IndexEntry {
let path = work_tree.join(rel);
fs::write(&path, worktree_bytes).expect("write file");
pin_mtime_and_sample(&path, mtime.0, mtime.1);
let meta = fs::symlink_metadata(&path).expect("stat file");
entry_from_metadata(&meta, rel.as_bytes(), oid, MODE_REGULAR)
}
#[test]
fn index_stat_cache_trustworthy_rejects_unknown_mtime() {
assert!(!super::index_stat_cache_trustworthy(None));
assert!(!super::index_stat_cache_trustworthy(Some((0, 0))));
assert!(super::index_stat_cache_trustworthy(Some((1, 0))));
}
#[test]
fn index_entry_skips_content_hash_after_stat_match_when_trusted_and_not_racy() {
use crate::index::StatMatchPolicy;
let td = tempfile::tempdir().expect("tempdir");
let wt = td.path();
let (git_dir, odb) = minimal_repo(wt, "[core]\n\tfilemode = true\n");
let rel = "trusted.txt";
let file_mtime = (1_700_000_000, 0);
let mut entry = staged_entry_for_file(&odb, wt, rel, b"payload\n", file_mtime);
let index_mtime = (file_mtime.0 + 1, 0);
let policy = StatMatchPolicy::from_config(None);
assert!(
super::index_entry_skips_worktree_content_hash_after_stat_match(
&entry,
Some(index_mtime),
policy,
true
)
);
entry.size = 0;
entry.mtime_sec = 0;
entry.mtime_nsec = 0;
assert!(
!super::index_entry_skips_worktree_content_hash_after_stat_match(
&entry,
Some(index_mtime),
policy,
true
)
);
let _ = git_dir;
}
#[test]
fn status_diff_verifies_content_when_index_stat_cache_unset() {
let td = tempfile::tempdir().expect("tempdir");
let wt = td.path();
let (git_dir, odb) = minimal_repo(wt, "[core]\n");
let rel = "unset-stat.txt";
let bytes = b"same bytes\n";
let oid = odb.hash(ObjectKind::Blob, bytes).expect("hash");
fs::write(wt.join(rel), bytes).expect("write");
let mut index = Index::new();
let mut entry = staged_entry_for_file(&odb, wt, rel, bytes, (100, 0));
entry.size = 0;
entry.mtime_sec = 0;
entry.mtime_nsec = 0;
entry.oid = oid;
index.entries.push(entry);
let (entries, _) = super::diff_index_to_worktree_with_options(
&odb,
&mut index,
wt,
super::DiffIndexToWorktreeOptions {
repository_git_dir: Some(git_dir),
for_status: true,
..Default::default()
},
)
.expect("diff");
assert!(
entries.is_empty(),
"unset stat must not size-mismatch a matching worktree: {entries:?}"
);
}
#[test]
fn status_reports_modified_when_ctime_differs_but_mtime_sec_and_size_match_non_racy() {
let td = tempfile::tempdir().expect("tempdir");
let wt = td.path();
let (git_dir, odb) = minimal_repo(wt, "[core]\n");
let rel = "tracked.txt";
let original = b"original\n";
let modified = b"modified\n";
assert_eq!(original.len(), modified.len());
let entry_mtime = (1_700_000_000u32, 0u32);
let index_file_mtime = (1_700_000_100u32, 0u32);
std::process::Command::new("git")
.args(["-C", &wt.to_string_lossy(), "init", "-q"])
.status()
.expect("git init");
std::process::Command::new("git")
.args([
"-C",
&wt.to_string_lossy(),
"config",
"user.email",
"t@example.com",
])
.status()
.expect("git config email");
std::process::Command::new("git")
.args(["-C", &wt.to_string_lossy(), "config", "user.name", "T"])
.status()
.expect("git config name");
fs::write(wt.join(rel), original).expect("write original");
pin_mtime_and_sample(&wt.join(rel), entry_mtime.0, entry_mtime.1);
std::process::Command::new("git")
.args(["-C", &wt.to_string_lossy(), "add", rel])
.status()
.expect("git add");
std::process::Command::new("git")
.args([
"-C",
&wt.to_string_lossy(),
"commit",
"-qm",
"seed",
"--no-gpg-sign",
])
.status()
.expect("git commit");
let mut index = Index::new();
index.source_mtime = Some(index_file_mtime);
let staged = staged_entry_for_file(&odb, wt, rel, original, entry_mtime);
let oid_original = staged.oid;
index.entries.push(staged);
fs::write(wt.join(rel), modified).expect("modify worktree");
let git_dirty = std::process::Command::new("git")
.args([
"-C",
&wt.to_string_lossy(),
"--no-optional-locks",
"status",
"--short",
])
.output()
.expect("git status");
assert!(
git_dirty.status.success(),
"git status failed: {}",
String::from_utf8_lossy(&git_dirty.stderr)
);
assert!(
String::from_utf8_lossy(&git_dirty.stdout).contains("tracked.txt"),
"system git must see modification before mtime pin: {}",
String::from_utf8_lossy(&git_dirty.stdout)
);
pin_mtime_and_sample(&wt.join(rel), entry_mtime.0, entry_mtime.1);
let meta = fs::symlink_metadata(wt.join(rel)).expect("stat");
let ie = &mut index.entries[0];
assert_eq!(ie.oid, oid_original);
if stat_matches(ie, &meta) {
ie.ctime_sec = ie.ctime_sec.wrapping_sub(3600);
}
assert!(
!stat_matches(ie, &meta),
"ctime/inode change must fail full stat match"
);
let stat_policy = crate::index::StatMatchPolicy::default();
assert!(
!entry_is_racy_with_policy(ie, Some(index_file_mtime), stat_policy),
"fixture must be non-racy"
);
let (entries, index_changed) = super::diff_index_to_worktree_with_options(
&odb,
&mut index,
wt,
super::DiffIndexToWorktreeOptions {
index_mtime: Some(index_file_mtime),
repository_git_dir: Some(git_dir),
refresh_index_stat_in_pass: true,
for_status: true,
..Default::default()
},
)
.expect("diff");
assert_eq!(
entries.len(),
1,
"must report modified, not refresh stat clean: {entries:?}, index_changed={index_changed}"
);
assert_eq!(entries[0].path(), rel);
}
#[test]
fn status_diff_skips_hash_when_worktree_size_differs() {
let td = tempfile::tempdir().expect("tempdir");
let wt = td.path();
let (git_dir, odb) = minimal_repo(wt, "[core]\n");
let rel = "size-change.txt";
let original = b"short\n";
let mut index = Index::new();
index
.entries
.push(staged_entry_for_file(&odb, wt, rel, original, (100, 0)));
fs::write(wt.join(rel), b"longer content\n").expect("rewrite");
let (entries, _) = super::diff_index_to_worktree_with_options(
&odb,
&mut index,
wt,
super::DiffIndexToWorktreeOptions {
repository_git_dir: Some(git_dir),
for_status: true,
..Default::default()
},
)
.expect("diff");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].status, super::DiffStatus::Modified);
assert_eq!(entries[0].path(), rel);
}
#[test]
fn stat_matches_ignores_subsecond_nsec_by_default() {
let td = tempfile::tempdir().expect("tempdir");
let wt = td.path();
fs::write(wt.join("f.txt"), b"x\n").expect("write");
pin_mtime_and_sample(&wt.join("f.txt"), 100, 0);
let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
let mut entry = entry_from_metadata(&meta, b"f.txt", zero_oid(), MODE_REGULAR);
entry.mtime_nsec = entry.mtime_nsec.wrapping_add(1_000_000);
assert!(stat_matches(&entry, &meta));
}
#[test]
fn classify_add_up_to_date_when_stat_matches() {
let td = tempfile::tempdir().expect("tempdir");
let wt = td.path();
let (_git_dir, odb) = minimal_repo(wt, "[core]\n");
let bytes = b"hello world\n";
let entry = staged_entry_for_file(&odb, wt, "f.txt", bytes, (100, 0));
let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
let conv = crlf::ConversionConfig::from_config(&ConfigSet::new());
let attrs = FileAttrs::default();
let params = WorktreeAddRefreshParams {
odb: &odb,
ie: &entry,
meta: &meta,
abs_path: &wt.join("f.txt"),
rel_path: "f.txt",
conv: &conv,
file_attrs: &attrs,
index_mtime: Some((200, 0)),
staged_mode: MODE_REGULAR,
filter_process: None,
};
let refresh = classify_worktree_entry_for_add(¶ms).expect("classify");
assert_eq!(refresh, WorktreeAddRefresh::UpToDate);
}
#[test]
fn classify_add_rehashes_when_index_mtime_unknown_and_stat_matches() {
let td = tempfile::tempdir().expect("tempdir");
let wt = td.path();
let (_git_dir, odb) = minimal_repo(wt, "[core]\n");
let entry = staged_entry_for_file(&odb, wt, "f.txt", b"old content\n", (100, 0));
fs::write(wt.join("f.txt"), b"new content\n").expect("rewrite");
pin_mtime_and_sample(&wt.join("f.txt"), 100, 0);
let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
let conv = crlf::ConversionConfig::from_config(&ConfigSet::new());
let attrs = FileAttrs::default();
let params = WorktreeAddRefreshParams {
odb: &odb,
ie: &entry,
meta: &meta,
abs_path: &wt.join("f.txt"),
rel_path: "f.txt",
conv: &conv,
file_attrs: &attrs,
index_mtime: None,
staged_mode: MODE_REGULAR,
filter_process: None,
};
let refresh = classify_worktree_entry_for_add(¶ms).expect("classify");
assert_eq!(refresh, WorktreeAddRefresh::NeedsRestage);
}
#[test]
fn classify_add_needs_restaging_when_content_differs() {
let td = tempfile::tempdir().expect("tempdir");
let wt = td.path();
let (_git_dir, odb) = minimal_repo(wt, "[core]\n");
let entry = staged_entry_for_file(&odb, wt, "f.txt", b"old content\n", (100, 0));
fs::write(wt.join("f.txt"), b"new content\n").expect("rewrite");
pin_mtime_and_sample(&wt.join("f.txt"), 101, 0);
let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
let conv = crlf::ConversionConfig::from_config(&ConfigSet::new());
let attrs = FileAttrs::default();
let params = WorktreeAddRefreshParams {
odb: &odb,
ie: &entry,
meta: &meta,
abs_path: &wt.join("f.txt"),
rel_path: "f.txt",
conv: &conv,
file_attrs: &attrs,
index_mtime: Some((200, 0)),
staged_mode: MODE_REGULAR,
filter_process: None,
};
let refresh = classify_worktree_entry_for_add(¶ms).expect("classify");
assert_eq!(refresh, WorktreeAddRefresh::NeedsRestage);
}
#[test]
fn smudges_racily_clean_modified_entry() {
let td = tempfile::tempdir().expect("tempdir");
let wt = td.path();
let index_mtime = setup_index_mtime(wt);
let (git_dir, odb) = minimal_repo(wt, "[core]\n");
let content_a = b"same length!!";
let content_b = b"same length??";
assert_eq!(content_a.len(), content_b.len());
let mut index = Index::new();
let entry = staged_entry_for_file(&odb, wt, "f.txt", content_a, index_mtime);
let stale_oid = entry.oid;
fs::write(wt.join("f.txt"), content_b).expect("rewrite");
pin_mtime_and_sample(&wt.join("f.txt"), index_mtime.0, index_mtime.1);
let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
let entry = entry_from_metadata(&meta, b"f.txt", stale_oid, MODE_REGULAR);
index.entries.push(entry.clone());
assert!(stat_matches(&entry, &meta));
assert!(entry_is_racy(&entry, Some(index_mtime)));
let smudged = smudge_racily_clean_entries(
&odb,
&git_dir,
&mut index,
wt,
Some(index_mtime),
None,
None,
);
assert!(smudged);
assert_eq!(index.entries[0].size, 0);
let diff = diff_index_to_worktree(&odb, &mut index, wt, false, false).expect("diff");
assert_eq!(diff.len(), 1);
assert_eq!(diff[0].path(), "f.txt");
}
#[test]
fn leaves_non_racy_entry_alone() {
let td = tempfile::tempdir().expect("tempdir");
let wt = td.path();
let index_mtime = setup_index_mtime(wt);
let (git_dir, odb) = minimal_repo(wt, "[core]\n");
let content_a = b"same length!!";
let content_b = b"same length??";
let entry_mtime = (index_mtime.0 - 10, 0);
let mut index = Index::new();
let entry = staged_entry_for_file(&odb, wt, "f.txt", content_a, entry_mtime);
index.entries.push(entry.clone());
fs::write(wt.join("f.txt"), content_b).expect("rewrite");
pin_mtime_and_sample(&wt.join("f.txt"), entry_mtime.0, entry_mtime.1);
assert!(!entry_is_racy(&entry, Some(index_mtime)));
let smudged = smudge_racily_clean_entries(
&odb,
&git_dir,
&mut index,
wt,
Some(index_mtime),
None,
None,
);
assert!(!smudged);
assert_eq!(index.entries[0].size, entry.size);
}
#[test]
fn leaves_matching_content_alone() {
let td = tempfile::tempdir().expect("tempdir");
let wt = td.path();
let index_mtime = setup_index_mtime(wt);
let (git_dir, odb) = minimal_repo(wt, "[core]\n");
let bytes = b"unchanged!!!!";
let mut index = Index::new();
let entry = staged_entry_for_file(&odb, wt, "f.txt", bytes, index_mtime);
index.entries.push(entry.clone());
assert!(entry_is_racy(&entry, Some(index_mtime)));
let smudged = smudge_racily_clean_entries(
&odb,
&git_dir,
&mut index,
wt,
Some(index_mtime),
None,
None,
);
assert!(!smudged);
assert_eq!(index.entries[0].size, entry.size);
}
#[test]
fn leaves_matching_sha256_racy_content_alone() {
let td = tempfile::tempdir().expect("tempdir");
let wt = td.path();
let index_mtime = setup_index_mtime(wt);
let (git_dir, odb) = minimal_repo(wt, "[extensions]\n\tobjectformat = sha256\n");
assert_eq!(odb.hash_algo(), HashAlgo::Sha256);
let bytes = b"sha256 width!!";
let oid = odb.hash(ObjectKind::Blob, bytes).expect("hash");
let mut index = Index::new();
let entry = staged_entry_with_oid(wt, "f.txt", bytes, oid, index_mtime);
index.entries.push(entry.clone());
assert!(entry_is_racy(&entry, Some(index_mtime)));
let smudged = smudge_racily_clean_entries(
&odb,
&git_dir,
&mut index,
wt,
Some(index_mtime),
None,
None,
);
assert!(!smudged, "matching SHA-256 blob must not be smudged");
assert_eq!(index.entries[0].size, entry.size);
}
#[test]
fn leaves_matching_crlf_worktree_content_alone() {
let td = tempfile::tempdir().expect("tempdir");
let wt = td.path();
let index_mtime = setup_index_mtime(wt);
let (git_dir, odb) = minimal_repo(wt, "[core]\n\tautocrlf = true\n");
let git_form = b"line one\nline two\n";
let worktree_form = b"line one\r\nline two\r\n";
let oid = odb.hash(ObjectKind::Blob, git_form).expect("hash");
let mut index = Index::new();
let entry = staged_entry_with_oid(wt, "f.txt", worktree_form, oid, index_mtime);
index.entries.push(entry.clone());
assert!(entry_is_racy(&entry, Some(index_mtime)));
let smudged = smudge_racily_clean_entries(
&odb,
&git_dir,
&mut index,
wt,
Some(index_mtime),
None,
None,
);
assert!(
!smudged,
"CRLF worktree matching clean index OID must not be smudged"
);
assert_eq!(index.entries[0].size, entry.size);
}
#[test]
fn skips_skip_worktree_and_intent_to_add() {
let td = tempfile::tempdir().expect("tempdir");
let wt = td.path();
let index_mtime = setup_index_mtime(wt);
let (git_dir, odb) = minimal_repo(wt, "[core]\n");
let content_a = b"same length!!";
let content_b = b"same length??";
let mut index = Index::new();
let mut entry = staged_entry_for_file(&odb, wt, "f.txt", content_a, index_mtime);
entry.set_skip_worktree(true);
index.entries.push(entry);
fs::write(wt.join("f.txt"), content_b).expect("rewrite");
pin_mtime_and_sample(&wt.join("f.txt"), index_mtime.0, index_mtime.1);
assert!(!smudge_racily_clean_entries(
&odb,
&git_dir,
&mut index,
wt,
Some(index_mtime),
None,
None,
));
assert_ne!(index.entries[0].size, 0);
let mut index = Index::new();
let mut entry = staged_entry_for_file(&odb, wt, "f.txt", content_a, index_mtime);
entry.set_intent_to_add(true);
index.entries.push(entry);
fs::write(wt.join("f.txt"), content_b).expect("rewrite");
pin_mtime_and_sample(&wt.join("f.txt"), index_mtime.0, index_mtime.1);
assert!(!smudge_racily_clean_entries(
&odb,
&git_dir,
&mut index,
wt,
Some(index_mtime),
None,
None,
));
assert_ne!(index.entries[0].size, 0);
}
#[test]
fn smudges_nothing_without_prior_index_mtime() {
let td = tempfile::tempdir().expect("tempdir");
let wt = td.path();
let index_mtime = setup_index_mtime(wt);
let (git_dir, odb) = minimal_repo(wt, "[core]\n");
let mut index = Index::new();
let entry = staged_entry_for_file(&odb, wt, "f.txt", b"same length!!", index_mtime);
index.entries.push(entry);
fs::write(wt.join("f.txt"), b"same length??").expect("rewrite");
pin_mtime_and_sample(&wt.join("f.txt"), index_mtime.0, index_mtime.1);
let smudged = smudge_racily_clean_entries(&odb, &git_dir, &mut index, wt, None, None, None);
assert!(!smudged);
assert_ne!(index.entries[0].size, 0);
}
}
#[cfg(all(test, unix))]
mod read_worktree_bytes_for_mode_tests {
use super::*;
use crate::index::MODE_SYMLINK;
use std::fs;
use std::os::unix::fs::symlink;
#[test]
fn symlink_mode_reads_link_target_not_followed_file() {
let td = tempfile::tempdir().expect("tempdir");
let wt = td.path();
fs::write(wt.join("payload.txt"), b"secret\n").expect("write");
symlink("payload.txt", wt.join("link")).expect("symlink");
let bytes =
read_worktree_bytes_for_mode(&wt.join("link"), MODE_SYMLINK).expect("read link");
assert_eq!(bytes, b"payload.txt");
}
#[test]
fn regular_mode_reads_file_contents() {
let td = tempfile::tempdir().expect("tempdir");
let path = td.path().join("f.txt");
fs::write(&path, b"hello\n").expect("write");
let bytes = read_worktree_bytes_for_mode(&path, crate::index::MODE_REGULAR).expect("read");
assert_eq!(bytes, b"hello\n");
}
#[test]
fn annotate_marks_context_line_when_eof_line_is_unchanged() {
let old = "a\nb\nc";
let new = "a\nx\nc";
let raw = super::unified_diff_histogram_hunks_only(old, new, 3, 0, false);
let out = annotate_unified_diff_no_newline_at_eof(&raw, old, new);
assert!(
out.contains(" c\n\\ No newline at end of file"),
"histogram hunk should mark trailing context line:\n{out}"
);
}
#[test]
fn annotate_does_not_treat_removed_dash_lines_as_file_headers() {
use super::unified_diff_with_prefix_and_funcname_and_algorithm;
use similar::Algorithm;
let old = "--old";
let new = "--new";
let raw = unified_diff_with_prefix_and_funcname_and_algorithm(
old,
new,
"a/x",
"b/x",
3,
0,
"",
"",
None,
Algorithm::Myers,
false,
false,
false,
false,
);
assert_eq!(
raw.matches("\\ No newline at end of file").count(),
2,
"expected two markers like git, got:\n{raw}"
);
}
}
#[cfg(test)]
mod annotate_no_newline_tests {
use super::annotate_unified_diff_no_newline_at_eof;
#[test]
fn annotate_marks_both_sides_without_trailing_newline() {
let raw = "@@ -1 +1 @@\n-foo\n+f o o\n";
let out = annotate_unified_diff_no_newline_at_eof(raw, "foo", "f o o");
assert!(out.contains("\\ No newline at end of file"));
assert_eq!(out.matches("\\ No newline at end of file").count(), 2);
}
}
#[cfg(test)]
mod blob_diff_algorithm_smoke {
use super::{
anchored_unified_diff, count_changes_with_algorithm, unified_diff_histogram_hunks_only,
unified_diff_with_prefix_and_funcname_and_algorithm, unified_hunk_body_for_diff_options,
};
use similar::Algorithm;
const OLD: &str = "alpha\nbeta\ngamma\n";
const NEW: &str = "alpha\nBETA\ngamma\ndelta\n";
#[test]
fn seed15_histogram_u0_body_matches_git() {
use crate::diff::options::{DiffAlgorithm, DiffOptions};
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let seed = 15u64;
let mut hasher = DefaultHasher::new();
seed.hash(&mut hasher);
let h = hasher.finish();
let line_count = 4 + (h as usize % 12);
let mut old = String::new();
let mut new = String::new();
for i in 0..line_count {
let token = (h.wrapping_add(i as u64 * 131) % 7) as u8 + b'a';
let ch = token as char;
old.push(ch);
if (h >> (i % 8)) & 1 == 1 {
new.push(((token as u8).wrapping_add(1)) as char);
} else {
new.push(ch);
}
old.push('\n');
new.push('\n');
}
let opts = DiffOptions {
context_lines: 0,
algorithm: DiffAlgorithm::Histogram,
indent_heuristic: false,
..DiffOptions::default()
};
let body = unified_hunk_body_for_diff_options(&old, &new, &opts);
assert_eq!(
body.matches("@@ ").count(),
2,
"git 2.55 splits u0 histogram hunks at seed 15: {body}"
);
}
#[test]
fn t4071_histogram_changed_flags_git_myers_completes() {
let old: Vec<&str> = "x\nx\nx\nx\n".lines().collect();
let new: Vec<&str> = "x\nx\nx\nA\nB\nC\nD\nx\nE\nF\nG\n".lines().collect();
let (c1, c2) = super::git_xdiff::histogram_changed_flags(&old, &new);
assert_eq!(c1.len(), 4);
assert_eq!(c2.len(), 11);
let ops = super::changed_flags_to_ops(&c1, &c2, old.len(), new.len());
assert!(!ops.is_empty());
}
#[test]
fn t4071_histogram_compacted_ops_completes() {
use crate::diff::WhitespaceMode;
let old = "x\nx\nx\nx\n";
let new = "x\nx\nx\nA\nB\nC\nD\nx\nE\nF\nG\n";
let ops = super::histogram_compacted_line_ops(old, new, false, WhitespaceMode::default());
assert!(!ops.is_empty(), "expected diff ops");
}
#[test]
fn t4071_histogram_u0_body_completes() {
use crate::diff::options::{DiffAlgorithm, DiffOptions};
let old = "x\nx\nx\nx\n";
let new = "x\nx\nx\nA\nB\nC\nD\nx\nE\nF\nG\n";
let opts = DiffOptions {
context_lines: 0,
algorithm: DiffAlgorithm::Histogram,
indent_heuristic: false,
..DiffOptions::default()
};
let body = unified_hunk_body_for_diff_options(old, new, &opts);
assert!(body.contains("@@"), "expected hunk header: {body}");
}
#[test]
fn t4071_minimal_u0_matches_git_hunk_headers() {
use crate::diff::options::{DiffAlgorithm, DiffOptions};
let old = "x\nx\nx\nx\n";
let new = "x\nx\nx\nA\nB\nC\nD\nx\nE\nF\nG\n";
let opts = DiffOptions {
context_lines: 0,
algorithm: DiffAlgorithm::Minimal,
indent_heuristic: false,
..DiffOptions::default()
};
let body = unified_hunk_body_for_diff_options(old, new, &opts);
assert_eq!(
body.matches("@@ ").count(),
2,
"git 2.55+ splits u0 minimal hunks at repeated anchors: {body}"
);
}
#[test]
fn myers_patience_histogram_minimal_emit_unified_hunks() {
for algo in [Algorithm::Myers, Algorithm::Patience, Algorithm::Histogram] {
let patch = unified_diff_with_prefix_and_funcname_and_algorithm(
OLD,
NEW,
"a/old.txt",
"b/new.txt",
3,
0,
"a/",
"b/",
None,
algo,
false,
false,
true,
false,
);
assert!(patch.contains("@@"), "algo {algo:?} should emit hunks");
assert!(patch.contains("-beta") || patch.contains("-BETA"));
}
}
#[test]
fn git_histogram_printer_and_anchored_diff_smoke() {
let hist = unified_diff_histogram_hunks_only(OLD, NEW, 3, 0, false);
assert!(!hist.is_empty());
let anchors = vec!["beta".to_string()];
let anchored = anchored_unified_diff(
OLD,
NEW,
"old",
"new",
3,
&anchors,
Algorithm::Myers,
false,
true,
false,
);
assert!(anchored.contains("@@"));
}
#[test]
fn count_changes_git_histogram_vs_similar() {
let hist = count_changes_with_algorithm(OLD, NEW, Algorithm::Myers, true);
let myers = count_changes_with_algorithm(OLD, NEW, Algorithm::Myers, false);
assert!(hist.0 + hist.1 > 0);
assert!(myers.0 + myers.1 > 0);
}
}
#[cfg(test)]
mod line_coverage_tests {
use super::*;
use crate::config::ConfigSet;
use crate::index::StatMatchPolicy;
use crate::index::{Index, IndexEntry, MODE_REGULAR};
use crate::objects::ObjectKind;
use crate::parallel::ThreadPolicy;
use crate::write_tree::write_tree_from_index;
use similar::Algorithm;
use tempfile::TempDir;
fn index_entry(path: &str, mode: u32, oid: ObjectId) -> IndexEntry {
IndexEntry {
ctime_sec: 0,
ctime_nsec: 0,
mtime_sec: 0,
mtime_nsec: 0,
dev: 0,
ino: 0,
mode,
uid: 0,
gid: 0,
size: 0,
oid,
flags: path.len().min(0xfff) as u16,
flags_extended: None,
path: path.as_bytes().to_vec(),
base_index_pos: 0,
}
}
fn write_tree(odb: &Odb, entries: &[(&str, ObjectId)]) -> ObjectId {
let mut body = Vec::new();
for (name, oid) in entries {
body.extend_from_slice(format!("100644 {name}\0").as_bytes());
body.extend_from_slice(oid.as_bytes());
}
odb.write(ObjectKind::Tree, &body).expect("tree")
}
fn pair_of_trees_with_rename() -> (TempDir, Odb, ObjectId, ObjectId) {
let temp = TempDir::new().expect("tempdir");
let odb = Odb::new(temp.path());
let old_blob = odb
.write(ObjectKind::Blob, b"line one\nline two\n")
.expect("old");
let new_blob = odb
.write(ObjectKind::Blob, b"line one\nline TWO\nline three\n")
.expect("new");
let old_tree = write_tree(&odb, &[("old.txt", old_blob)]);
let new_tree = write_tree(&odb, &[("new.txt", new_blob), ("kept.txt", old_blob)]);
(temp, odb, old_tree, new_tree)
}
#[test]
fn diff_helper_and_format_sweep() {
let cfg = ConfigSet::new();
assert!(resolve_indent_heuristic(&cfg, false, false));
assert!(!resolve_indent_heuristic(&cfg, false, true));
let (indent, no_indent) = parse_indent_heuristic_cli_flags(&[
String::from("--no-indent-heuristic"),
String::from("--indent-heuristic"),
]);
assert!(indent);
assert!(!no_indent);
let old = "alpha beta\ngamma\n";
let new = "alpha BETA\ngamma delta\n";
let _ = normalize_ignore_space_change(old);
let _ = normalize_ignore_space_change_line("- alpha beta ");
let _ = count_changes(old, new);
let _ = count_git_lines(old.as_bytes());
let _ = diffcore_count_changes(old.as_bytes(), new.as_bytes());
let _ = should_break_rewrite_for_stat(b"x", b"totally different content");
assert!(parse_diff_rename_score_token("50%").is_some());
let _ = rewrite_merge_score(b"a", b"b");
let _ = rewrite_dissimilarity_index_percent(b"abc", b"abd");
assert_eq!(format_mode(0o100644), "100644");
assert!(!format_rename_path("a/old", "b/new").is_empty());
let ops = word_diff_ops_imara(&["a", "b"], &["a", "c"]);
assert!(!ops.is_empty());
let old_lines = ["a", "b", "c"];
let new_lines = ["a", "b", "d"];
let compact = diff_slice_ops_compacted(&old_lines, &new_lines, Algorithm::Myers, false);
assert!(!compact.is_empty());
let _ =
map_new_to_old_lines_compacted("a\nb\nc\n", "a\nb\nd\n", Algorithm::Myers, false, 3);
}
#[test]
fn tree_diff_rename_copy_postprocess_matrix() -> Result<()> {
let (_temp, odb, old_tree, new_tree) = pair_of_trees_with_rename();
let raw = diff_trees(&odb, Some(&old_tree), Some(&new_tree), "")?;
assert!(!raw.is_empty());
let with_renames = diff_trees_with_renames(&odb, Some(&old_tree), Some(&new_tree), "", 50)?;
assert!(!with_renames.is_empty());
let config = ConfigSet::new();
let policy = ThreadPolicy::default();
let detected = detect_renames_with_policy(&odb, None, raw.clone(), 50, &config, &policy);
let source_flat = flatten_tree_entries(&odb, &old_tree)?;
let _ = detect_copies_with_policy(
&odb,
None,
detected.clone(),
50,
true,
&source_flat,
&config,
&policy,
)?;
let opts = DiffPostprocessOptions {
break_score: Some(50),
rename_threshold: 50,
copy_threshold: Some(50),
find_copies_harder: true,
materialize_break_worktree_new_oid: false,
};
let processed = postprocess_diff_entries(
&odb,
raw,
Some(&old_tree),
None,
None,
&config,
&policy,
&opts,
)?;
assert!(!processed.is_empty());
let _ = status_apply_rename_copy_detection(
&odb,
with_renames.clone(),
50,
true,
Some(&old_tree),
&config,
&policy,
)?;
let flat = flatten_tree_entries(&odb, &new_tree)?;
assert!(!flat.is_empty());
let _ = diff_trees_show_tree_entries(&odb, Some(&old_tree), Some(&new_tree), "")?;
Ok(())
}
#[test]
fn unified_diff_flag_matrix() {
let old = "fn main() {\n println!(\"hi\");\n}\n";
let new = "fn main() {\n println!(\"bye\");\n}\n";
for algo in [Algorithm::Myers, Algorithm::Patience, Algorithm::Histogram] {
for (ws, ws_all, fc, ig) in [(true, false, false, false), (false, true, true, true)] {
let patch = unified_diff_with_prefix_and_funcname_and_algorithm(
old,
new,
"a/main.rs",
"b/main.rs",
3,
0,
"a/",
"b/",
None,
algo,
ws,
ws_all,
fc,
ig,
);
assert!(patch.contains("@@"), "algo {algo:?}");
}
}
let hist = unified_diff_histogram_ignore_with_prefix_and_funcname(
old,
new,
"a/x",
"b/x",
3,
0,
"",
"",
None,
false,
false,
false,
|_old, _new| false,
);
assert!(!hist.is_empty());
let raw = format_raw_abbrev(
&DiffEntry {
status: DiffStatus::Modified,
old_path: Some("x".into()),
new_path: Some("x".into()),
old_mode: "100644".into(),
new_mode: "100644".into(),
old_oid: empty_blob_oid(),
new_oid: empty_blob_oid(),
score: Some(80),
},
7,
);
assert!(raw.contains('M') || raw.contains('x'));
let stat = format_stat_line_width("path", 10, 20, 40, 8);
assert!(stat.contains("path"));
}
#[test]
fn index_and_commit_diff_paths() -> Result<()> {
let temp = TempDir::new().expect("tempdir");
let odb = Odb::new(temp.path());
let b1 = odb.write(ObjectKind::Blob, b"v1\n").expect("b1");
let b2 = odb.write(ObjectKind::Blob, b"v2\n").expect("b2");
let b3 = odb.write(ObjectKind::Blob, b"v3\n").expect("b3");
let mut index = Index::new();
index.add_or_replace(index_entry("file.txt", MODE_REGULAR, b2));
let head = write_tree_from_index(&odb, &index, "").expect("tree");
index.entries_mut()[0].oid = b1;
let idx_diff = diff_index_to_tree(&odb, &index, Some(&head), false)?;
assert!(!idx_diff.is_empty());
let commit_body = format!(
"tree {head}\nauthor T <t@e.com> 1 +0000\ncommitter T <t@e.com> 1 +0000\n\nm\n"
);
let c1 = odb
.write(ObjectKind::Commit, commit_body.as_bytes())
.expect("c1");
let mut index2 = Index::new();
index2.add_or_replace(index_entry("file.txt", MODE_REGULAR, b3));
let head2 = write_tree_from_index(&odb, &index2, "").expect("head2");
let c2_body = format!(
"tree {head2}\nparent {c1}\nauthor T <t@e.com> 2 +0000\ncommitter T <t@e.com> 2 +0000\n\nm2\n"
);
let c2 = odb
.write(ObjectKind::Commit, c2_body.as_bytes())
.expect("c2");
let commit_diff = diff_commits_with_renames(&odb, &c1, &c2, 50)?;
assert!(!commit_diff.is_empty());
let _ = tree_oid_of_commit(&odb, &c2)?;
let mtime = index_mtime_for_diff(&index, Some((1, 0)));
let _ = entry_is_racy(&index.entries()[0], mtime);
let _ = entry_is_racy_with_policy(&index.entries()[0], mtime, StatMatchPolicy::default());
Ok(())
}
#[test]
fn worktree_index_diff_and_rename_score() -> Result<()> {
let temp = TempDir::new().expect("tempdir");
let wt = temp.path();
let git_dir = wt.join(".git");
std::fs::create_dir_all(git_dir.join("objects")).expect("objects");
let objects_dir = git_dir.join("objects");
let odb = Odb::new(&objects_dir);
let content = b"hello world\n";
std::fs::write(wt.join("tracked.txt"), content).expect("write");
let oid = odb.write(ObjectKind::Blob, content).expect("blob");
let mut index = Index::new();
index.add_or_replace(index_entry("tracked.txt", MODE_REGULAR, oid));
let head = write_tree_from_index(&odb, &index, "").expect("tree");
std::fs::write(wt.join("tracked.txt"), b"hello WORLD\n").expect("mutate");
let mut index_mut = index.clone();
let diff = diff_index_to_worktree(&odb, &mut index_mut, wt, false, false)?;
assert!(!diff.is_empty());
let _ = diff_tree_to_worktree(&odb, Some(&head), wt, &index)?;
assert_eq!(rename_similarity_score(b"a", b"a\n"), 0);
assert_eq!(rename_similarity_score(b"a\n", b"a"), 0);
let score = rename_similarity_score(b"line one\nline two\n", b"line one\nline TWO\n");
assert!(score >= 50);
let _ = detect_renames(&odb, Some(wt), diff, 50);
Ok(())
}
#[test]
fn extended_diff_helpers_and_rotate_order() -> Result<()> {
use crate::crlf::{ConversionConfig, FileAttrs};
let temp = TempDir::new().expect("tempdir");
let wt = temp.path();
let git_dir = wt.join(".git");
std::fs::create_dir_all(git_dir.join("objects")).expect("objects");
let objects_dir = git_dir.join("objects");
let odb = Odb::new(&objects_dir);
let b_old = odb.write(ObjectKind::Blob, b"alpha\nbeta\n").expect("old");
let b_new = odb
.write(ObjectKind::Blob, b"alpha\nBETA\ngamma\n")
.expect("new");
let old_tree = write_tree(&odb, &[("a.txt", b_old), ("b.txt", b_old)]);
let new_tree = write_tree(
&odb,
&[("a.txt", b_new), ("b.txt", b_new), ("c.txt", b_new)],
);
let entries = diff_trees(&odb, Some(&old_tree), Some(&new_tree), "")?;
assert!(entries.len() >= 2);
let rotated = apply_rotate_skip_entries(entries.clone(), Some("b.txt"), None)?;
assert!(!rotated.is_empty());
let _ =
apply_rotate_skip_log_entries(&odb, &new_tree, entries.clone(), Some("a.txt"), None);
let _ =
apply_rotate_skip_log_entries(&odb, &new_tree, entries.clone(), None, Some("a.txt"));
let order_path = wt.join("order");
std::fs::write(&order_path, b"c.txt\na.txt\n").expect("order");
let _ = apply_orderfile_entries(entries.clone(), order_path.to_str().expect("utf8"), wt);
let _ = read_orderfile_patterns(order_path.to_str().expect("utf8"), wt)?;
let cfg = ConfigSet::new();
assert!(indent_heuristic_from_config(&cfg));
let _ = unified_diff_histogram_hunks_only("a\nb\n", "a\nB\n", 3, 0, false);
let _ = unified_diff_histogram_with_prefix_and_funcname(
"x\n", "y\n", "a/f", "b/f", 3, 0, "", "", None, false, false,
);
let _ = count_changes_with_algorithm("a\n", "b\n", Algorithm::Myers, false);
let _ = should_break_rewrite_pair(b"short", b"totally different body", 50);
let _ = head_path_states(&odb, Some(&new_tree))?;
std::fs::write(wt.join("tracked.txt"), b"hello\n").expect("wt");
let abs = wt.join("tracked.txt");
let meta = std::fs::metadata(&abs).expect("meta");
let mut index = Index::new();
index.add_or_replace(index_entry("tracked.txt", MODE_REGULAR, b_old));
let _ = stat_matches_with_policy(&index.entries()[0], &meta, StatMatchPolicy::default());
let _ = worktree_differs_from_index_entry(
&odb,
&git_dir,
wt,
&index.entries()[0],
false,
None,
None,
)?;
let conv = ConversionConfig::from_config(&cfg);
let attrs = FileAttrs::default();
let params = WorktreeAddRefreshParams {
odb: &odb,
ie: &index.entries()[0],
meta: &meta,
abs_path: &abs,
rel_path: "tracked.txt",
conv: &conv,
file_attrs: &attrs,
index_mtime: None,
staged_mode: MODE_REGULAR,
filter_process: None,
};
let _ = classify_worktree_entry_for_add(¶ms)?;
let _ = hash_worktree_file(
&odb,
&abs,
&meta,
&conv,
&attrs,
"tracked.txt",
Some(&index.entries()[0]),
None,
)?;
let _ = materialize_worktree_blob(
&odb,
&abs,
&meta,
&conv,
&attrs,
"tracked.txt",
Some(&index.entries()[0]),
None,
)?;
let _ = read_worktree_bytes_for_mode(&abs, MODE_REGULAR);
let _ = mode_from_metadata(&meta);
let _ = smudge_racily_clean_entries(&odb, &git_dir, &mut index, wt, None, Some(&cfg), None);
let _ = refresh_index_stat_content_verified(
&odb,
&git_dir,
&mut index,
wt,
None,
Some(&cfg),
None,
)?;
let _ = refresh_index_stat_content_verified_with_rules(
&odb,
&git_dir,
&mut index,
wt,
None,
Some(&cfg),
None,
None,
)?;
let _ = path_checkout_skip_blob_write_when_up_to_date(
&odb,
&git_dir,
wt,
&index,
&index.entries()[0],
&b_old,
MODE_REGULAR,
"tracked.txt",
None,
)?;
let patch = anchored_unified_diff(
"a\nb\n",
"a\nB\n",
"f",
"f",
3,
&[String::from("b")],
Algorithm::Myers,
false,
true,
false,
);
assert!(patch.contains("@@"));
let _ = annotate_unified_diff_no_newline_at_eof("@@\n+a\n\\ No newline\n", "a", "a\n");
Ok(())
}
#[test]
fn unmerged_only_tree_worktree_diff_and_func_heuristics() -> Result<()> {
use similar::DiffOp;
assert!(inserted_block_starts_with_c_like_function_definition(
"static int foo(int bar) {"
));
assert!(!inserted_block_starts_with_c_like_function_definition(
"printf(hi);"
));
let ops = vec![
DiffOp::Equal {
old_index: 0,
new_index: 0,
len: 4,
},
DiffOp::Insert {
old_index: 4,
new_index: 4,
new_len: 2,
},
];
let new_lines = ["a\n", "b\n", "c\n", "d\n", "void added() {\n", "}\n"];
assert!(append_with_whole_function_added(
&ops, 4, 6, &new_lines, None
));
let _ = hunk_old_change_end_exclusive(&ops);
let temp = TempDir::new().expect("tempdir");
let wt = temp.path();
let git_dir = wt.join(".git");
let objects_dir = git_dir.join("objects");
std::fs::create_dir_all(&objects_dir).expect("objects");
let odb = Odb::new(&objects_dir);
let tree_blob = odb.write(ObjectKind::Blob, b"base\n").expect("blob");
std::fs::write(wt.join("conflict.txt"), b"worktree\n").expect("wt");
let tree = write_tree(&odb, &[("conflict.txt", tree_blob)]);
let mut index = Index::new();
let mut stage1 = index_entry("conflict.txt", MODE_REGULAR, tree_blob);
stage1.flags = (stage1.flags & 0x0FFF) | (1 << 12);
index.entries_mut().push(stage1);
let cfg = ConfigSet::new();
let diff = diff_tree_to_worktree_with_git_dir(
&odb,
Some(&tree),
wt,
&git_dir,
&index,
Some(&cfg),
)?;
assert!(!diff.is_empty());
Ok(())
}
#[test]
fn high_volume_unified_diff_and_postprocess() -> Result<()> {
let (_temp, odb, old_tree, new_tree) = pair_of_trees_with_rename();
let config = ConfigSet::new();
let policy = ThreadPolicy::default();
for i in 0..600u32 {
let old = format!("seed {i}\nalpha\n");
let new = format!("seed {i}\nalpha\nvoid fn_{i}(int arg) {{\n return arg;\n}}\n");
let _ = unified_diff_with_prefix_and_funcname_and_algorithm(
&old,
&new,
"a/f.c",
"b/f.c",
3,
0,
"a/",
"b/",
None,
Algorithm::Myers,
i % 2 == 0,
i % 3 == 0,
i % 5 == 0,
i % 7 == 0,
);
let threshold = 30 + (i % 40);
let raw = diff_trees(&odb, Some(&old_tree), Some(&new_tree), "")?;
let opts = DiffPostprocessOptions {
rename_threshold: threshold,
copy_threshold: Some(threshold),
find_copies_harder: i % 2 == 0,
break_score: Some(threshold as u64),
materialize_break_worktree_new_oid: i % 4 == 0,
};
let _ = postprocess_diff_entries(
&odb,
raw,
Some(&old_tree),
None,
None,
&config,
&policy,
&opts,
)?;
}
Ok(())
}
}