add pull request viewer

This commit is contained in:
2026-08-22 20:38:22 +07:00
parent 385718d74d
commit 7fb7275233
9 changed files with 1737 additions and 187 deletions
+293 -8
View File
@@ -61,7 +61,12 @@ impl GitCache {
for url in clone_urls {
match clone(url, &path) {
Ok(repo) => return Ok(repo),
Ok(repo) => {
// The initial clone uses the default refspecs; also
// fetch the `refs/nostr/*` PR refs.
fetch_all(&repo).ok();
return Ok(repo);
}
Err(e) => last_err = Some(e),
}
}
@@ -73,11 +78,23 @@ impl GitCache {
}
}
/// Fetch all configured refspecs from `origin`.
/// Fetch all configured refspecs from `origin`, plus the `refs/nostr/*`
/// namespace where GRASP mirrors serve pull request branches (one ref per
/// PR event id, as used by ngit).
pub fn fetch_all(repo: &gix::Repository) -> Result<()> {
let options = gix::remote::ref_map::Options {
extra_refspecs: vec![
gix::refspec::parse(
gix::bstr::BStr::new("+refs/nostr/*:refs/nostr/*"),
gix::refspec::parse::Operation::Fetch,
)?
.to_owned(),
],
..Default::default()
};
repo.find_remote("origin")?
.connect(gix::remote::Direction::Fetch)?
.prepare_fetch(Discard, Default::default())?
.prepare_fetch(Discard, options)?
.receive(Discard, &IS_INTERRUPTED)?;
Ok(())
}
@@ -483,10 +500,6 @@ pub fn worktree_commit_diff(workdir: &Path, id: &str) -> Result<CommitDiff> {
}
fn commit_diff(repo: &gix::Repository, id: &str) -> Result<CommitDiff> {
use gix::diff::blob::platform::prepare_diff::Operation;
use gix::object::tree::diff::Change;
use gix::objs::tree::EntryKind;
let commit_id = repo.rev_parse_single(id.as_bytes())?;
let commit = commit_id.object()?.into_commit();
let new_tree = commit.tree()?;
@@ -494,8 +507,65 @@ fn commit_diff(repo: &gix::Repository, id: &str) -> Result<CommitDiff> {
Some(parent) => Some(parent.object()?.into_commit().tree()?),
None => None,
};
tree_diff(repo, old_tree.as_ref(), &new_tree)
}
let changes = repo.diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), None)?;
/// The changes between two commits (`base`..`tip`), like `git diff base tip`.
/// Same file handling as [`worktree_commit_diff`] (directories and
/// submodules are skipped, files are sorted by path).
pub fn worktree_commit_range_diff(workdir: &Path, base: &str, tip: &str) -> Result<CommitDiff> {
let repo = open_with_cache(workdir)?;
let base_tree = repo
.rev_parse_single(base.as_bytes())?
.object()?
.into_commit()
.tree()?;
let tip_tree = repo
.rev_parse_single(tip.as_bytes())?
.object()?
.into_commit()
.tree()?;
tree_diff(&repo, Some(&base_tree), &tip_tree)
}
/// Commits in the range `base`..`tip`, newest first, like `git log base..tip`.
pub fn worktree_commit_range_commits(
workdir: &Path,
base: &str,
tip: &str,
) -> Result<Vec<FileCommit>> {
use gix::traverse::commit::simple::CommitTimeOrder;
let repo = open_with_cache(workdir)?;
let base_id = repo.rev_parse_single(base.as_bytes())?;
let tip_id = repo.rev_parse_single(tip.as_bytes())?;
let walk = repo
.rev_walk([tip_id])
.sorting(gix::revision::walk::Sorting::ByCommitTime(
CommitTimeOrder::NewestFirst,
))
.with_hidden([base_id]);
let mut commits = Vec::new();
for info in walk.all()? {
let info = info?;
commits.push(file_commit(&info.object()?, false)?);
}
Ok(commits)
}
/// The changes between two trees, used by both [`commit_diff`] and
/// [`worktree_commit_range_diff`].
fn tree_diff(
repo: &gix::Repository,
old_tree: Option<&gix::Tree<'_>>,
new_tree: &gix::Tree<'_>,
) -> Result<CommitDiff> {
use gix::diff::blob::platform::prepare_diff::Operation;
use gix::object::tree::diff::Change;
use gix::objs::tree::EntryKind;
let changes = repo.diff_tree_to_tree(old_tree, Some(new_tree), None)?;
let mut cache = repo.diff_resource_cache_for_tree_diff()?;
let mut files = Vec::new();
@@ -630,6 +700,88 @@ pub fn patch_diffs(patch: &str) -> Result<CommitDiff> {
Ok(CommitDiff { files })
}
/// Commits of a `git format-patch` output (a single patch or a patch
/// series), parsed from the mbox envelope headers of each patch: commit id,
/// author, summary and author time. Entries appear in patch order (oldest
/// first, as produced by `git format-patch`).
pub fn patch_commits(patch: &str) -> Vec<FileCommit> {
let lines: Vec<&str> = patch.lines().collect();
let mut commits = Vec::new();
let mut i = 0;
while i < lines.len() {
// A patch starts with its `From <id> <date>` envelope line.
let Some(rest) = lines[i].strip_prefix("From ") else {
i += 1;
continue;
};
let Some(id) = rest.split_whitespace().next() else {
i += 1;
continue;
};
if id.len() != 40 {
i += 1;
continue;
}
let mut author = String::new();
let mut summary = String::new();
let mut time = 0i64;
// Envelope headers of this patch, up to the blank line separating
// them from the commit message.
i += 1;
while i < lines.len() && !lines[i].is_empty() {
let header = lines[i];
if let Some(value) = header.strip_prefix("From: ") {
author = name_from_address(value);
} else if let Some(value) = header.strip_prefix("Subject: ") {
summary = strip_patch_prefix(value);
} else if let Some(value) = header.strip_prefix("Date: ") {
time = gix::date::parse(value.trim(), None)
.map(|t| t.seconds)
.unwrap_or(0);
}
i += 1;
}
commits.push(FileCommit {
id: id.to_string(),
summary,
description: None,
author,
time,
});
}
commits
}
/// The name part of a `From: Name <email>` header value.
fn name_from_address(from: &str) -> String {
match from.trim().find('<') {
Some(ix) => from[..ix].trim().to_string(),
None => from.trim().to_string(),
}
}
/// Strip the `[PATCH]`, `[PATCH 1/2]`, `[RFC PATCH]` … prefix from a patch
/// `Subject:` header.
fn strip_patch_prefix(subject: &str) -> String {
let trimmed = subject.trim();
let Some(rest) = trimmed.strip_prefix('[') else {
return trimmed.to_string();
};
let Some(end) = rest.find(']') else {
return trimmed.to_string();
};
if rest[..end].to_ascii_lowercase().contains("patch") {
rest[end + 1..].trim().to_string()
} else {
trimmed.to_string()
}
}
/// Parse one file's diff section: everything after its `diff --git` header
/// up to the next section (or the end of the patch). Returns the section
/// and the index of the first unconsumed line.
@@ -1650,6 +1802,52 @@ mod tests {
assert_eq!(deleted.hunks[0].lines[0].new, None);
}
#[test]
fn commit_range_diff_lists_changes_between_two_commits() {
let (dir, repo) = fixture(&[("a.txt", b"a\n"), ("b.txt", b"b\n")]);
commit_all(&repo, "first");
let base = repo.head_id().expect("head").to_string();
std::fs::write(dir.path().join("a.txt"), b"changed\n").expect("write");
std::fs::write(dir.path().join("c.txt"), b"new\n").expect("write");
commit_all(&repo, "second");
let tip = repo.head_id().expect("head").to_string();
let diff = worktree_commit_range_diff(dir.path(), &base, &tip).expect("diff");
let by_path: HashMap<&str, &FileDiff> = diff
.files
.iter()
.map(|file| (file.path.as_str(), file))
.collect();
assert_eq!(by_path.len(), 2);
assert_eq!(by_path["a.txt"].status, DiffStatus::Modified);
assert_eq!(by_path["a.txt"].insertions, 1);
assert_eq!(by_path["a.txt"].deletions, 1);
assert_eq!(by_path["c.txt"].status, DiffStatus::Added);
// b.txt is unchanged between the two commits.
assert!(diff.files.iter().all(|file| file.path != "b.txt"));
}
#[test]
fn commit_range_commits_lists_only_new_commits_newest_first() {
let (dir, repo) = fixture(&[("a.txt", b"one\n")]);
commit_all(&repo, "one");
let base = repo.head_id().expect("head").to_string();
std::fs::write(dir.path().join("a.txt"), b"two\n").expect("write");
commit_all(&repo, "two");
std::fs::write(dir.path().join("a.txt"), b"three\n").expect("write");
commit_all(&repo, "three");
let tip = repo.head_id().expect("head").to_string();
let commits = worktree_commit_range_commits(dir.path(), &base, &tip).expect("commits");
assert_eq!(commits.len(), 2);
assert_eq!(commits[0].summary, "three");
assert_eq!(commits[1].summary, "two");
}
#[test]
fn commit_diff_reports_binary_files_without_hunks() {
let (_dir, repo) = fixture(&[("blob.bin", b"\x00\x01\x02")]);
@@ -1881,6 +2079,93 @@ index 3..4 100644
assert_eq!(diff.files[1].deletions, 1);
}
#[test]
fn patch_commits_lists_every_patch_in_order() {
let patch = r#"From 1111111111111111111111111111111111111111 Mon Sep 17 00:00:00 2001
From: Alice <alice@example.com>
Date: Tue, 1 Aug 2023 10:00:00 +0200
Subject: [PATCH 1/2] first
body one
---
a.txt | 1 +
1 file changed, 1 insertion(+)
diff --git a/a.txt b/a.txt
@@ -1 +1,2 @@
a
+b
From 2222222222222222222222222222222222222222 Mon Sep 17 00:00:00 2001
From: Bob <bob@example.com>
Date: Wed, 2 Aug 2023 11:30:00 +0000
Subject: [PATCH 2/2] second
body two
---
b.txt | 1 +
1 file changed, 1 insertion(+)
diff --git a/b.txt b/b.txt
@@ -1 +1,2 @@
x
+y
"#;
let commits = patch_commits(patch);
assert_eq!(commits.len(), 2);
assert_eq!(commits[0].id, "1111111111111111111111111111111111111111");
assert_eq!(commits[0].summary, "first");
assert_eq!(commits[0].author, "Alice");
assert_eq!(commits[0].time, 1690876800);
assert_eq!(commits[1].id, "2222222222222222222222222222222222222222");
assert_eq!(commits[1].summary, "second");
assert_eq!(commits[1].author, "Bob");
assert_eq!(commits[1].time, 1690975800);
}
#[test]
fn patch_commits_strips_patch_subject_prefixes() {
let patch = r#"From 1111111111111111111111111111111111111111 Mon Sep 17 00:00:00 2001
From: A <a@b.c>
Subject: [RFC PATCH v3 4/7] the real title
---
"#;
let commits = patch_commits(patch);
assert_eq!(commits.len(), 1);
assert_eq!(commits[0].summary, "the real title");
}
#[test]
fn patch_commits_handles_missing_headers() {
// A hand-written patch without author/date headers still lists a
// commit; time stays 0 and the author falls back to the raw value.
let patch = r#"From 1111111111111111111111111111111111111111 Mon Sep 17 00:00:00 2001
Subject: [PATCH] plain
---
"#;
let commits = patch_commits(patch);
assert_eq!(commits.len(), 1);
assert_eq!(commits[0].summary, "plain");
assert_eq!(commits[0].author, "");
assert_eq!(commits[0].time, 0);
}
#[test]
fn patch_commits_ignores_non_patch_lines() {
assert!(patch_commits("").is_empty());
assert!(patch_commits("just some text\nFrom 123\n").is_empty());
// A diff-only body (no mbox envelope) has no commits.
let patch = "diff --git a/x b/x\n--- a/x\n+++ b/x\n";
assert!(patch_commits(patch).is_empty());
}
#[test]
fn marks_binary_sections() {
let patch = r#"diff --git a/img.png b/img.png