improve: show whole-diff totals alongside file stats

This commit is contained in:
Valdemar Lorenzen
2026-10-20 12:29:06 +02:00
parent a5a246db77
commit 759947736e
15 changed files with 394 additions and 48 deletions
+136 -4
View File
@@ -2817,7 +2817,7 @@ impl Repository {
}
fn build_comparison(
&self,
&mut self,
prepared: &mut PreparedComparison,
same: bool,
) -> Result<Comparison, String> {
@@ -2835,6 +2835,7 @@ impl Repository {
}
files.push(ChangedFile {
review_revisions: None,
stats: None,
path: path.clone(),
old_path: None,
status: if a.is_none() {
@@ -2887,11 +2888,18 @@ impl Repository {
Ok(self.comparison_catalog(prepared))
}
fn comparison_catalog(&self, prepared: &PreparedComparison) -> Comparison {
fn comparison_catalog(&mut self, prepared: &PreparedComparison) -> Comparison {
let stats = if prepared.files.is_empty() {
Ok(Vec::new())
} else {
self.ensure_private()
.and_then(|()| self.comparison_stats(prepared))
};
let files = prepared
.files
.iter()
.map(|(path, old_path, status)| ChangedFile {
.enumerate()
.map(|(index, (path, old_path, status))| ChangedFile {
review_revisions: Some([
review_file_revision(prepared.left.get(old_path.as_ref().unwrap_or(path))),
review_file_revision(prepared.right.get(path)),
@@ -2900,12 +2908,16 @@ impl Repository {
old_path: old_path.clone(),
status: *status,
loaded: false,
stats: stats.as_ref().ok().and_then(|stats| stats[index]),
before: vec![],
after: vec![],
before_eof: false,
after_eof: false,
hunks: vec![],
notice: None,
notice: stats
.as_ref()
.err()
.map(|error| format!("Line statistics unavailable: {error}")),
})
.collect();
let editable_paths = if prepared.editable_after {
@@ -2934,6 +2946,125 @@ impl Repository {
}
}
fn comparison_stats(
&self,
prepared: &PreparedComparison,
) -> Result<Vec<Option<(usize, usize)>>, String> {
// ponytail: unopened counts use Git's text/Myers rules; share the renderer's
// counter if exact binary-detection and pathological-diff parity is needed.
let store = &self.private.as_ref().unwrap().1;
let sides: Vec<_> = prepared
.files
.iter()
.map(|(path, old_path, _)| {
[
prepared.left.get(old_path.as_ref().unwrap_or(path)),
prepared.right.get(path),
]
})
.collect();
let objects: BTreeSet<_> = sides
.iter()
.flatten()
.flatten()
.filter(|file| file.projection.is_none())
.filter_map(|file| file.object.as_ref().map(|(_, id)| id.as_str()))
.collect();
let mut sizes = BTreeMap::new();
if !objects.is_empty() {
let mut input = Vec::new();
for id in objects {
writeln!(&mut input, "{id}").unwrap();
}
let mut command = git_command(store);
command.arg("cat-file").arg("--batch-check");
let output = text(checked(run(command, input, &self.cancelled)?)?)?;
for line in output.lines() {
let fields: Vec<_> = line.split(' ').collect();
if fields.len() != 3 || fields[1] != "blob" {
return Err("Invalid statistics blob metadata".into());
}
let size = fields[2]
.parse::<usize>()
.map_err(|_| "Invalid statistics blob size")?;
sizes.insert(fields[0].to_owned(), size);
}
}
let mut stats = vec![Some((0, 0)); sides.len()];
let mut trees = [Vec::new(), Vec::new()];
for (index, files) in sides.iter().enumerate() {
self.check_cancelled()?;
if files.iter().flatten().any(|file| {
file.projection.is_some()
|| file.object.as_ref().is_some_and(|(_, id)| {
sizes
.get(id)
.is_none_or(|size| *size > COMPARISON_FILE_LIMIT)
})
}) {
stats[index] = None;
continue;
}
for (side, file) in files.iter().enumerate() {
if let Some((_, id)) = file.and_then(|file| file.object.as_ref()) {
// Flat synthetic paths avoid attributes, quoting and rename detection.
// Normalize modes: the renderer compares link targets as ordinary text.
write!(&mut trees[side], "100644 blob {id}\t{index}\0").unwrap();
}
}
}
if trees.iter().all(Vec::is_empty) {
return Ok(stats);
}
let mut ids = Vec::new();
for tree in trees {
let mut command = git_command(store);
command.args(["mktree", "-z"]);
ids.push(text(checked(run(command, tree, &self.cancelled)?)?)?);
}
let mut command = git_command(store);
command
.args(["-c", "core.attributesFile=/dev/null"])
.args([
"diff-tree",
"--numstat",
"-z",
"--no-renames",
"--no-ext-diff",
"--no-textconv",
"--diff-algorithm=myers",
"--no-indent-heuristic",
])
.args(ids.iter().map(|id| id.trim()))
.env("GIT_ATTR_NOSYSTEM", "1")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null");
let output = checked(run(command, Vec::new(), &self.cancelled)?)?;
for record in output.split(|byte| *byte == 0).filter(|r| !r.is_empty()) {
let record = std::str::from_utf8(record).map_err(|_| "Invalid line statistics")?;
let fields: Vec<_> = record.split('\t').collect();
if fields.len() != 3 {
return Err("Invalid line statistics".into());
}
let index = fields[2]
.parse::<usize>()
.ok()
.filter(|index| *index < stats.len())
.ok_or("Invalid line statistics index")?;
stats[index] = Some(if fields[0] == "-" && fields[1] == "-" {
(0, 0)
} else {
(
fields[0].parse().map_err(|_| "Invalid added line count")?,
fields[1]
.parse()
.map_err(|_| "Invalid removed line count")?,
)
});
}
Ok(stats)
}
/// Prepare one catalog entry using its original comparison's frozen sources.
pub fn load_file(
&mut self,
@@ -3044,6 +3175,7 @@ impl Repository {
notices.dedup();
Ok(ChangedFile {
review_revisions: None,
stats: None,
path: path.to_path_buf(),
old_path: None,
status,