merge remote module into repo

This commit is contained in:
2026-10-07 09:16:16 +07:00
parent a706fadafa
commit d212f0fa90
11 changed files with 1615 additions and 1495 deletions
+59 -121
View File
@@ -1,17 +1,14 @@
use std::io::Write;
use std::process::Stdio;
use anyhow::{Context, Result, bail};
use anyhow::Result;
use diffy::patch_set::{FileOperation, FilePatch, ParseOptions, PatchSet};
use diffy::{Hunk, Line};
use crate::diff::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileDiff};
use crate::history::FileCommit;
use crate::repo::Repo;
pub struct PatchParser;
impl PatchParser {
/// Splits an mbox series into the raw text of each message.
pub fn split_patch_series(patch: &str) -> Vec<&str> {
Self::envelopes(patch)
.into_iter()
@@ -19,44 +16,49 @@ impl PatchParser {
.collect()
}
/// Parses a git diff into file diffs, returning an empty diff without one.
pub fn patch_diffs(patch: &str) -> Result<CommitDiff> {
if !patch.lines().any(|line| line.starts_with("diff --git ")) {
return Ok(CommitDiff { files: Vec::new() });
return Ok(CommitDiff::new(Vec::new()));
}
let mut files = Vec::new();
for file in PatchSet::parse(patch, ParseOptions::gitdiff()) {
files.push(Self::file_diff(file?)?);
files.push(Self::file_diff(file?));
}
Ok(CommitDiff { files })
Ok(CommitDiff::new(files))
}
/// Lists the commits represented by the mbox messages that carry an id.
pub fn patch_commits(patch: &str) -> Vec<FileCommit> {
Self::envelopes(patch)
.into_iter()
.filter(|message| !message.id.is_empty())
.map(|message| FileCommit {
id: message.id.to_string(),
summary: message
.header("Subject")
.map(Self::strip_patch_prefix)
.unwrap_or_default(),
description: None,
author: message
.header("From")
.map(Self::name_from_address)
.unwrap_or_default(),
time: message
.header("Date")
.and_then(|value| gix::date::parse(value.trim(), None).ok())
.map(|time| time.seconds)
.unwrap_or(0),
.map(|message| {
FileCommit::new(
message.id.to_string(),
message
.header("Subject")
.map(Self::strip_patch_prefix)
.unwrap_or_default(),
None,
message
.header("From")
.map(Self::name_from_address)
.unwrap_or_default(),
message
.header("Date")
.and_then(|value| gix::date::parse(value.trim(), None).ok())
.map(|time| time.seconds)
.unwrap_or(0),
)
})
.collect()
}
/// Splits `patch` on `From <40-hex> <date>` envelopes, falling back to one message.
fn envelopes(patch: &str) -> Vec<Envelope<'_>> {
let mut messages: Vec<Envelope<'_>> = Vec::new();
let mut current: Option<(usize, &str, Vec<&str>)> = None;
@@ -74,11 +76,7 @@ impl PatchParser {
if is_envelope {
if let Some((start, id, headers)) = current.take() {
messages.push(Envelope {
text: &patch[start..],
id,
headers,
});
messages.push(Envelope::new(&patch[start..], id, headers));
}
let id = line
@@ -104,24 +102,17 @@ impl PatchParser {
}
if let Some((start, id, headers)) = current.take() {
messages.push(Envelope {
text: &patch[start..],
id,
headers,
});
messages.push(Envelope::new(&patch[start..], id, headers));
}
if messages.is_empty() {
messages.push(Envelope {
text: patch,
id: "",
headers: Vec::new(),
});
messages.push(Envelope::new(patch, "", Vec::new()));
}
messages
}
/// Extracts the display name from a `From` header value.
fn name_from_address(from: &str) -> String {
match from.trim().find('<') {
Some(ix) => from[..ix].trim().to_string(),
@@ -129,7 +120,7 @@ impl PatchParser {
}
}
/// Matches `[PATCH]`, `[PATCH 1/2]`, `[RFC PATCH]`, etc.
/// Strips mailing list prefixes like `[PATCH]` or `[RFC PATCH 1/2]`.
fn strip_patch_prefix(subject: &str) -> String {
let trimmed = subject.trim();
let Some(rest) = trimmed.strip_prefix('[') else {
@@ -145,7 +136,8 @@ impl PatchParser {
}
}
fn file_diff(file: FilePatch<'_, str>) -> Result<FileDiff> {
/// Converts one parsed file patch, keeping rename and copy paths unstripped.
fn file_diff(file: FilePatch<'_, str>) -> FileDiff {
let stripped;
let operation = match file.operation() {
operation @ (FileOperation::Rename { .. } | FileOperation::Copy { .. }) => operation,
@@ -192,17 +184,18 @@ impl PatchParser {
}
}
Ok(FileDiff {
path: path.to_owned(),
old_path: old_path.map(str::to_owned),
FileDiff::new(
path.to_owned(),
old_path.map(str::to_owned),
status,
insertions,
deletions,
binary: patch.is_binary(),
patch.is_binary(),
hunks,
})
)
}
/// Converts a diffy hunk into a `DiffHunk` with per-line old and new numbers.
fn hunk_diff(hunk: &Hunk<'_, str>) -> DiffHunk {
let old_range = hunk.old_range();
let new_range = hunk.new_range();
@@ -237,37 +230,38 @@ impl PatchParser {
}
};
lines.push(DiffLine {
kind,
old: old_no,
new: new_no,
text: text
.strip_suffix('\n')
.unwrap_or(text)
.strip_suffix('\r')
.unwrap_or(text)
.to_owned(),
});
let text = text
.strip_suffix('\n')
.unwrap_or(text)
.strip_suffix('\r')
.unwrap_or(text);
lines.push(DiffLine::new(kind, old_no, new_no, text.to_owned()));
}
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,
DiffHunk::new(
old_range.start() as u32,
old_range.len() as u32,
new_range.start() as u32,
new_range.len() as u32,
lines,
}
)
}
}
/// A `git format-patch` mbox message, split on its `From <40-hex> <date>` envelope.
struct Envelope<'a> {
text: &'a str,
id: &'a str,
headers: Vec<&'a str>,
}
impl Envelope<'_> {
impl<'a> Envelope<'a> {
/// Creates an envelope from its text, commit id, and header lines.
fn new(text: &'a str, id: &'a str, headers: Vec<&'a str>) -> Self {
Self { text, id, headers }
}
/// Returns the first header line matching `name: `.
fn header(&self, name: &str) -> Option<&str> {
let prefix = format!("{name}: ");
self.headers
@@ -276,59 +270,3 @@ impl Envelope<'_> {
.map(|line| &line[prefix.len()..])
}
}
impl Repo {
pub fn apply_patch(&self, patch: &str) -> Result<()> {
let workdir = self
.inner
.workdir()
.context("repository has no worktree")?
.to_path_buf();
let mut child = Repo::git_command(&workdir)
.args(["am"])
.stdin(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("failed to spawn `git am`")?;
child
.stdin
.as_mut()
.context("git am has no stdin pipe")?
.write_all(patch.as_bytes())?;
let output = child.wait_with_output()?;
if !output.status.success() {
bail!("git am failed: {}", String::from_utf8_lossy(&output.stderr));
}
Ok(())
}
// Fails when the range has no commits. The mbox is returned untrimmed;
// trailing newlines are part of the format.
pub fn format_patch_between(&self, base: &str, tip: &str) -> Result<String> {
let output = Repo::run_git(
self.workdir_or_dot(),
&["format-patch", "--stdout", &format!("{base}..{tip}")],
"git format-patch",
)?;
if !output.status.success() {
bail!(
"git format-patch failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
let patch = String::from_utf8_lossy(&output.stdout).into_owned();
if patch.trim().is_empty() {
bail!("no commits between {base} and {tip}");
}
Ok(patch)
}
}