refactor
This commit is contained in:
@@ -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
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user