This commit is contained in:
2026-09-10 10:23:57 +07:00
parent 68dbc5a731
commit a2ae86dae2
5 changed files with 304 additions and 649 deletions
+1
View File
@@ -12,6 +12,7 @@ gix = { workspace = true, features = ["revision", "blob-diff"] }
gix-worktree = "0.56"
gix-worktree-state = "0.34"
anyhow.workspace = true
diffy = "0.5"
[dev-dependencies]
tempfile = "3"
+134 -282
View File
@@ -4,6 +4,8 @@ use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use anyhow::{Context, Result, bail};
use diffy::patch_set::{FileOperation, FilePatch, ParseOptions, PatchSet};
use diffy::{Hunk, Line};
use gix::diff::blob::unified_diff::{ConsumeHunk, DiffLineKind as GixLineKind, HunkHeader};
use gix::interrupt::IS_INTERRUPTED;
use gix::progress::Discard;
@@ -1596,24 +1598,145 @@ fn tree_diff(
}
/// Parse `git format-patch` output, a single patch or a series.
///
/// Backed by [`diffy::patch_set`], which implements git's extended diff format:
/// `diff --git` headers, rename and copy detection, binary detection, and
/// C-style quoted or octal-escaped paths.
pub fn patch_diffs(patch: &str) -> Result<CommitDiff> {
let lines: Vec<&str> = patch.lines().collect();
let mut files = Vec::new();
let mut i = 0;
// `PatchSet` reports an error when the input holds no patch at all,
// while a patch without git diff sections is simply empty here.
if !patch.lines().any(|line| line.starts_with("diff --git ")) {
return Ok(CommitDiff { files: Vec::new() });
}
while i < lines.len() {
let Some(header) = lines[i].strip_prefix("diff --git ") else {
i += 1;
continue;
};
let (file, next) = parse_diff_section(header, &lines, i + 1)?;
files.push(file);
i = next;
let mut files = Vec::new();
for file in PatchSet::parse(patch, ParseOptions::gitdiff()) {
files.push(file_diff(file?)?);
}
Ok(CommitDiff { files })
}
/// The [`FileDiff`] of one parsed file patch.
fn file_diff(file: FilePatch<'_, str>) -> Result<FileDiff> {
// The `---`/`+++` paths carry the `a/`/`b/` prefix, so the first path
// component is dropped, the same way `git apply -p1` does.
// Rename and copy paths come from their own headers, unprefixed.
let stripped;
let operation = match file.operation() {
operation @ (FileOperation::Rename { .. } | FileOperation::Copy { .. }) => operation,
operation => {
stripped = operation.strip_prefix(1);
&stripped
}
};
let (path, old_path, status) = match operation {
FileOperation::Create(path) => (path.as_ref(), None, DiffStatus::Added),
FileOperation::Delete(path) => (path.as_ref(), None, DiffStatus::Deleted),
FileOperation::Modify { modified, .. } => (modified.as_ref(), None, DiffStatus::Modified),
FileOperation::Rename { from, to } => {
(to.as_ref(), Some(from.as_ref()), DiffStatus::Renamed)
}
FileOperation::Copy { from, to } => (to.as_ref(), Some(from.as_ref()), DiffStatus::Copied),
};
let mut insertions = 0usize;
let mut deletions = 0usize;
let mut hunks = Vec::new();
let patch = file.patch();
if let Some(text) = patch.as_text() {
for hunk in text.hunks() {
let hunk = hunk_diff(hunk);
insertions += hunk
.lines
.iter()
.filter(|line| line.kind == DiffLineKind::Addition)
.count();
deletions += hunk
.lines
.iter()
.filter(|line| line.kind == DiffLineKind::Deletion)
.count();
hunks.push(hunk);
}
}
Ok(FileDiff {
path: path.to_owned(),
old_path: old_path.map(str::to_owned),
status,
insertions,
deletions,
binary: patch.is_binary(),
hunks,
})
}
/// The [`DiffHunk`] of one parsed hunk, including the line number of every line.
///
/// `diffy` reports only the hunk header ranges. The per-line numbers are
/// counted from them the way the header encodes them: context lines advance
/// both sides, deletions only the old, insertions only the new.
fn hunk_diff(hunk: &Hunk<'_, str>) -> DiffHunk {
let old_range = hunk.old_range();
let new_range = hunk.new_range();
let mut old = old_range.start() as u32;
let mut new = new_range.start() as u32;
let mut lines = Vec::with_capacity(hunk.lines().len());
for line in hunk.lines() {
let (kind, text) = match line {
Line::Context(text) => (DiffLineKind::Context, *text),
Line::Delete(text) => (DiffLineKind::Deletion, *text),
Line::Insert(text) => (DiffLineKind::Addition, *text),
};
let (old_no, new_no) = match kind {
DiffLineKind::Context => {
let numbers = (Some(old), Some(new));
old += 1;
new += 1;
numbers
}
DiffLineKind::Addition => {
let number = Some(new);
new += 1;
(None, number)
}
DiffLineKind::Deletion => {
let number = Some(old);
old += 1;
(number, None)
}
};
lines.push(DiffLine {
kind,
old: old_no,
new: new_no,
text: line_text(text),
});
}
DiffHunk {
old_start: old_range.start() as u32,
old_lines: old_range.len() as u32,
new_start: new_range.start() as u32,
new_lines: new_range.len() as u32,
lines,
}
}
/// The content of a parsed line without its line ending.
///
/// `diffy` keeps the trailing `\n`, the way `str::lines` splits it off.
fn line_text(text: &str) -> String {
let text = text.strip_suffix('\n').unwrap_or(text);
text.strip_suffix('\r').unwrap_or(text).to_owned()
}
/// Commits of a `git format-patch` output, a single patch or a series.
///
/// Entries appear in patch order, oldest first as `git format-patch` produces them.
@@ -1695,277 +1818,6 @@ fn strip_patch_prefix(subject: &str) -> String {
}
}
/// Parse one file's diff section.
///
/// Returns the section and the index of the first unconsumed line.
fn parse_diff_section(header: &str, lines: &[&str], start: usize) -> Result<(FileDiff, usize)> {
let (header_old, header_new) = header_paths(header)?;
// The `---` and `+++` lines name the two sides unambiguously.
// The `diff --git` header cannot distinguish spaces in paths.
// Fall back to the header for sections without them, pure renames and mode changes.
let mut old_path = header_old;
let mut new_path = header_new;
let mut status = DiffStatus::Modified;
let mut binary = false;
let mut hunks = Vec::new();
let mut insertions = 0usize;
let mut deletions = 0usize;
let mut i = start;
while i < lines.len() {
let line = lines[i];
// The next file's section starts at this line.
if line.starts_with("diff --git ") {
break;
}
i += 1;
if line.starts_with("@@ -") {
let (hunk, next) = parse_hunk(lines, i - 1)?;
i = next;
insertions += hunk
.lines
.iter()
.filter(|line| line.kind == DiffLineKind::Addition)
.count();
deletions += hunk
.lines
.iter()
.filter(|line| line.kind == DiffLineKind::Deletion)
.count();
hunks.push(hunk);
} else if let Some(rest) = line.strip_prefix("--- ") {
if rest == "/dev/null" {
status = DiffStatus::Added;
} else {
old_path = diff_line_path(rest, "a/")?;
}
} else if let Some(rest) = line.strip_prefix("+++ ") {
if rest == "/dev/null" {
status = DiffStatus::Deleted;
} else {
new_path = diff_line_path(rest, "b/")?;
}
} else if line.starts_with("new file mode ") {
status = DiffStatus::Added;
} else if line.starts_with("deleted file mode ") {
status = DiffStatus::Deleted;
} else if line.starts_with("copy from ") {
status = DiffStatus::Copied;
} else if line.starts_with("rename from ") {
status = DiffStatus::Renamed;
} else if line.starts_with("Binary files ") || line.starts_with("GIT binary patch") {
binary = true;
// A literal binary patch may follow.
// Skip it without consuming the next section's header.
while i < lines.len() && !lines[i].starts_with("diff --git ") {
i += 1;
}
break;
}
// Everything else, index, mode and similarity lines, is ignored.
}
Ok((
FileDiff {
path: new_path,
old_path: matches!(status, DiffStatus::Renamed | DiffStatus::Copied)
.then_some(old_path),
status,
insertions,
deletions,
binary,
hunks,
},
i,
))
}
/// Parse one hunk, the `@@ -a,b +c,d @@` header plus every body line.
///
/// Returns the hunk and the index of the first unconsumed line.
fn parse_hunk(lines: &[&str], start: usize) -> Result<(DiffHunk, usize)> {
let (old_start, old_lines, new_start, new_lines) = hunk_header(lines[start])?;
let mut diff_lines = Vec::new();
let mut old = old_start;
let mut new = new_start;
let mut i = start + 1;
while i < lines.len() {
let line = lines[i];
let Some(kind) = line_prefix_kind(line) else {
break;
};
i += 1;
// Context lines advance both counters.
// Deletions advance only the old counter, additions only the new one.
// Every line then carries its real number in both versions.
let (old_no, new_no) = match kind {
DiffLineKind::Context => {
let numbers = (Some(old), Some(new));
old += 1;
new += 1;
numbers
}
DiffLineKind::Addition => {
let number = Some(new);
new += 1;
(None, number)
}
DiffLineKind::Deletion => {
let number = Some(old);
old += 1;
(number, None)
}
};
diff_lines.push(DiffLine {
kind,
old: old_no,
new: new_no,
text: line[1..].to_owned(),
});
}
Ok((
DiffHunk {
old_start,
old_lines,
new_start,
new_lines,
lines: diff_lines,
},
i,
))
}
/// The kind of a hunk body line, from its first character.
///
/// Lines outside a hunk, headers, `\ No newline...` and the next section, yield `None`.
fn line_prefix_kind(line: &str) -> Option<DiffLineKind> {
match line.as_bytes().first()? {
b' ' => Some(DiffLineKind::Context),
b'+' => Some(DiffLineKind::Addition),
b'-' => Some(DiffLineKind::Deletion),
_ => None,
}
}
/// Parse a unified-diff hunk header, `@@ -a,b +c,d @@`.
///
/// Omitted line counts default to 1.
fn hunk_header(header: &str) -> Result<(u32, u32, u32, u32)> {
let rest = header
.strip_prefix("@@ ")
.context("malformed hunk header")?;
let (old_spec, rest) = rest.split_once(' ').context("malformed hunk header")?;
let new_spec = rest.split_once(' ').map(|(new, _)| new).unwrap_or(rest);
fn parse(spec: &str) -> Result<(u32, u32)> {
let spec = spec.strip_prefix(['-', '+']).unwrap_or(spec);
let (start, count) = match spec.split_once(',') {
Some((start, count)) => (start, count.parse::<u32>()?),
None => (spec, 1),
};
Ok((start.parse::<u32>()?, count))
}
let (old_start, old_lines) = parse(old_spec)?;
let (new_start, new_lines) = parse(new_spec)?;
Ok((old_start, old_lines, new_start, new_lines))
}
/// The old and new paths of a `diff --git a/X b/Y` header.
fn header_paths(header: &str) -> Result<(String, String)> {
if header.starts_with('"') {
// Quoted paths include the `a/` / `b/` prefix inside the quotes.
let (old, rest) = take_quoted(header).context("unterminated quoted path")?;
let rest = rest.trim_start();
let new = if rest.starts_with('"') {
take_quoted(rest).context("unterminated quoted path")?.0
} else {
rest.split_whitespace().next().unwrap_or(rest)
};
let old = old
.strip_prefix("a/")
.context("old path without `a/` prefix")?;
let new = new
.strip_prefix("b/")
.context("new path without `b/` prefix")?;
Ok((unquote_path(old)?, unquote_path(new)?))
} else {
let (old, rest) = header
.rsplit_once(" b/")
.context("malformed diff --git header")?;
let old = old
.strip_prefix("a/")
.context("old path without `a/` prefix")?;
Ok((old.to_owned(), rest.to_owned()))
}
}
/// The path of a `--- a/X` or `+++ b/Y` line.
fn diff_line_path(line: &str, prefix: &str) -> Result<String> {
let line = line.trim_end_matches('\t');
if line.starts_with('"') {
let (path, _) = take_quoted(line).context("unterminated quoted path")?;
let path = path
.strip_prefix(prefix)
.context("diff line path without `a/` or `b/` prefix")?;
unquote_path(path)
} else {
Ok(line
.strip_prefix(prefix)
.context("diff line path without `a/` or `b/` prefix")?
.to_owned())
}
}
/// The content of a git C-style quoted path and the rest of the input.
/// The path spans the opening `"`, escaped content and closing `"`.
///
/// `None` if unterminated.
fn take_quoted(input: &str) -> Option<(&str, &str)> {
let mut end = 1; // byte after the opening quote
let mut rest = &input[1..];
while let Some(ch) = rest.chars().next() {
let len = ch.len_utf8();
match ch {
'\\' => {
// Consume the escaped character too, it may be multi-byte.
let escaped = rest[len..].chars().next()?;
let consumed = len + escaped.len_utf8();
end += consumed;
rest = &rest[consumed..];
}
'"' => return Some((&input[1..end], &input[end + len..])),
_ => {
end += len;
rest = &rest[len..];
}
}
}
None
}
/// Undo git's C-style path quoting, `\NNN` octal escapes, `\"` and `\\`.
///
/// Delegates to gitoxide's C-style quote implementation, `gix::quote::ansi_c::undo`.
/// It expects the surrounding double quotes, which are re-added around the interior.
fn unquote_path(path: &str) -> Result<String> {
if !path.contains('\\') {
return Ok(path.to_owned());
}
let quoted = format!("\"{path}\"");
let (unquoted, _) = gix::quote::ansi_c::undo(gix::bstr::BStr::new(quoted.as_bytes()))
.map_err(|e| anyhow::anyhow!("malformed quoted path: {e}"))?;
String::from_utf8(unquoted.into_owned().to_vec()).context("invalid UTF-8 in quoted path")
}
/// Collects the hunks of one blob diff while tracking per-line numbers.
struct HunkCollector<'a> {
hunks: &'a mut Vec<DiffHunk>,
+4 -1
View File
@@ -203,7 +203,10 @@ impl RepoListStore {
announcement || deletion
}
BackendEvent::Synced | BackendEvent::SyncProgress { .. } => true,
// Only a completed sync refreshes the list.
// Progress ticks would re-scan the whole database several times
// per sync to reveal entries incrementally.
BackendEvent::Synced => true,
_ => false,
};