add pull request viewer
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user