.
This commit is contained in:
+154
-44
@@ -2,6 +2,7 @@
|
||||
//!
|
||||
//! All functions may block; call them inside `cx.background_spawn`.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
@@ -140,6 +141,11 @@ fn sanitize_path_component(id: &str) -> String {
|
||||
sanitized
|
||||
}
|
||||
|
||||
/// In-memory object cache for history walks (see [`open_with_cache`]).
|
||||
/// Without one, every walk re-decodes the same commit objects from the
|
||||
/// object database.
|
||||
const OBJECT_CACHE_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
/// Metadata of a commit, as shown in the repository browser's file header.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileCommit {
|
||||
@@ -222,6 +228,26 @@ pub fn find_readme(repo: &gix::Repository) -> Result<Option<PathBuf>> {
|
||||
.and_then(|path| path.strip_prefix(workdir).ok().map(Path::to_path_buf)))
|
||||
}
|
||||
|
||||
/// Open the repository at `workdir` with an in-memory object cache sized for
|
||||
/// history walks.
|
||||
fn open_with_cache(workdir: &Path) -> Result<gix::Repository> {
|
||||
let mut repo = gix::open(workdir)?;
|
||||
repo.object_cache_size_if_unset(OBJECT_CACHE_BYTES);
|
||||
Ok(repo)
|
||||
}
|
||||
|
||||
/// A [`FileCommit`] from a walk commit: author, message title and shortened id.
|
||||
fn file_commit(commit: &gix::Commit<'_>) -> Result<FileCommit> {
|
||||
let author = commit.author()?;
|
||||
let message = commit.message()?;
|
||||
Ok(FileCommit {
|
||||
id: commit.id().shorten_or_id().to_string(),
|
||||
summary: String::from_utf8_lossy(message.title).trim().to_string(),
|
||||
author: String::from_utf8_lossy(author.name).trim().to_string(),
|
||||
time: author.time()?.seconds,
|
||||
})
|
||||
}
|
||||
|
||||
/// Find the most recent commit that changed `rel` (a path relative to the
|
||||
/// worktree), like `git log -1 -- <rel>` does for non-merge commits.
|
||||
///
|
||||
@@ -230,56 +256,110 @@ pub fn find_readme(repo: &gix::Repository) -> Result<Option<PathBuf>> {
|
||||
/// changed the file through its second parent is therefore not reported.
|
||||
/// Returns `Ok(None)` if no commit touched the file (e.g. untracked files).
|
||||
pub fn last_commit(repo: &gix::Repository, rel: &Path) -> Result<Option<FileCommit>> {
|
||||
let rel = rel.to_path_buf();
|
||||
Ok(last_commits(repo, std::slice::from_ref(&rel))?
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|(_, commit)| commit))
|
||||
}
|
||||
|
||||
/// Newest commit touching each of `rels` (relative to the worktree), like
|
||||
/// `git log -1 -- <rel>` per path, found in a single history walk: every
|
||||
/// commit is decoded once and shared across all paths. Paths without any
|
||||
/// commit (e.g. untracked files) are absent from the result.
|
||||
pub fn worktree_last_commits(
|
||||
workdir: &Path,
|
||||
rels: &[PathBuf],
|
||||
) -> Result<Vec<(PathBuf, FileCommit)>> {
|
||||
last_commits(&open_with_cache(workdir)?, rels)
|
||||
}
|
||||
|
||||
/// The walk behind [`last_commit`] and [`worktree_last_commits`], stopping as
|
||||
/// soon as every pending path has its commit.
|
||||
fn last_commits(repo: &gix::Repository, rels: &[PathBuf]) -> Result<Vec<(PathBuf, FileCommit)>> {
|
||||
use gix::traverse::commit::simple::CommitTimeOrder;
|
||||
|
||||
let head = repo.head_id()?;
|
||||
let Some(head) = repo.head_id().ok() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
// De-duplicate while preserving order.
|
||||
let mut pending: Vec<PathBuf> = Vec::with_capacity(rels.len());
|
||||
let mut seen: HashSet<&Path> = HashSet::with_capacity(rels.len());
|
||||
for rel in rels {
|
||||
if seen.insert(rel.as_path()) {
|
||||
pending.push(rel.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let walk = repo
|
||||
.rev_walk([head])
|
||||
.sorting(gix::revision::walk::Sorting::ByCommitTime(
|
||||
CommitTimeOrder::NewestFirst,
|
||||
));
|
||||
|
||||
let mut found = Vec::new();
|
||||
for info in walk.all()? {
|
||||
if pending.is_empty() {
|
||||
break;
|
||||
}
|
||||
let info = info?;
|
||||
let commit = info.object()?;
|
||||
let blob = commit.tree()?.lookup_entry_by_path(rel)?;
|
||||
let parent_blob = match info.parent_ids().next() {
|
||||
Some(parent) => parent
|
||||
.object()?
|
||||
.into_commit()
|
||||
.tree()?
|
||||
.lookup_entry_by_path(rel)?,
|
||||
let tree = commit.tree()?;
|
||||
let parent_tree = match info.parent_ids().next() {
|
||||
Some(parent) => Some(parent.object()?.into_commit().tree()?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
if blob.map(|entry| entry.id().detach()) != parent_blob.map(|entry| entry.id().detach()) {
|
||||
let author = commit.author()?;
|
||||
let message = commit.message()?;
|
||||
return Ok(Some(FileCommit {
|
||||
id: commit.id().shorten_or_id().to_string(),
|
||||
summary: String::from_utf8_lossy(message.title).trim().to_string(),
|
||||
author: String::from_utf8_lossy(author.name).trim().to_string(),
|
||||
time: author.time()?.seconds,
|
||||
}));
|
||||
// Compare each still-unresolved path against this commit and its
|
||||
// first parent; resolved paths leave the pending set.
|
||||
let mut ix = 0;
|
||||
while ix < pending.len() {
|
||||
let rel = &pending[ix];
|
||||
let blob = tree.lookup_entry_by_path(rel)?;
|
||||
let parent_blob = match &parent_tree {
|
||||
Some(tree) => tree.lookup_entry_by_path(rel)?,
|
||||
None => None,
|
||||
};
|
||||
|
||||
if blob.map(|entry| entry.id().detach()) != parent_blob.map(|entry| entry.id().detach())
|
||||
{
|
||||
found.push((rel.clone(), file_commit(&commit)?));
|
||||
pending.swap_remove(ix);
|
||||
} else {
|
||||
ix += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// Like [`last_commit`], but opens the repository located at `workdir`
|
||||
/// (for non-bare clones the clone root is the worktree) first.
|
||||
pub fn worktree_last_commit(workdir: &Path, rel: &Path) -> Result<Option<FileCommit>> {
|
||||
last_commit(&gix::open(workdir)?, rel)
|
||||
/// Cap on [`CommitList::commits`]: the virtual list renders a window at a
|
||||
/// time and the tab badge shows the real count, so a huge history is never
|
||||
/// fully materialized in memory.
|
||||
pub const MAX_LISTED_COMMITS: usize = 20_000;
|
||||
|
||||
/// Commits reachable from `HEAD`, newest first, possibly capped: `commits`
|
||||
/// holds at most [`MAX_LISTED_COMMITS`] entries and `total` is the real
|
||||
/// count (for the tab badge).
|
||||
pub struct CommitList {
|
||||
/// Number of commits reachable from HEAD.
|
||||
pub total: usize,
|
||||
/// Newest commits, capped at [`MAX_LISTED_COMMITS`].
|
||||
pub commits: Vec<FileCommit>,
|
||||
}
|
||||
|
||||
/// All commits reachable from `HEAD`, newest first, with author and summary.
|
||||
/// Returns `Ok(vec![])` for a repository without any commits yet.
|
||||
pub fn all_commits(repo: &gix::Repository) -> Result<Vec<FileCommit>> {
|
||||
/// Returns an empty list for a repository without any commits yet.
|
||||
pub fn all_commits(repo: &gix::Repository) -> Result<CommitList> {
|
||||
use gix::traverse::commit::simple::CommitTimeOrder;
|
||||
|
||||
let Some(head) = repo.head_id().ok() else {
|
||||
return Ok(Vec::new());
|
||||
return Ok(CommitList {
|
||||
total: 0,
|
||||
commits: Vec::new(),
|
||||
});
|
||||
};
|
||||
let walk = repo
|
||||
.rev_walk([head])
|
||||
@@ -288,25 +368,21 @@ pub fn all_commits(repo: &gix::Repository) -> Result<Vec<FileCommit>> {
|
||||
));
|
||||
|
||||
let mut commits = Vec::new();
|
||||
let mut total = 0;
|
||||
for info in walk.all()? {
|
||||
let info = info?;
|
||||
let commit = info.object()?;
|
||||
let author = commit.author()?;
|
||||
let message = commit.message()?;
|
||||
commits.push(FileCommit {
|
||||
id: commit.id().shorten_or_id().to_string(),
|
||||
summary: String::from_utf8_lossy(message.title).trim().to_string(),
|
||||
author: String::from_utf8_lossy(author.name).trim().to_string(),
|
||||
time: author.time()?.seconds,
|
||||
});
|
||||
total += 1;
|
||||
if commits.len() < MAX_LISTED_COMMITS {
|
||||
commits.push(file_commit(&info.object()?)?);
|
||||
}
|
||||
}
|
||||
Ok(commits)
|
||||
Ok(CommitList { total, commits })
|
||||
}
|
||||
|
||||
/// Like [`all_commits`], but opens the repository located at `workdir`
|
||||
/// (for non-bare clones the clone root is the worktree) first.
|
||||
pub fn worktree_all_commits(workdir: &Path) -> Result<Vec<FileCommit>> {
|
||||
all_commits(&gix::open(workdir)?)
|
||||
pub fn worktree_all_commits(workdir: &Path) -> Result<CommitList> {
|
||||
all_commits(&open_with_cache(workdir)?)
|
||||
}
|
||||
|
||||
/// The commit HEAD points to, like `git log -1`. Returns `Ok(None)` for a
|
||||
@@ -328,7 +404,7 @@ pub fn head_commit(repo: &gix::Repository) -> Result<Option<FileCommit>> {
|
||||
|
||||
/// Short names of local branches (`refs/heads/*`), sorted alphabetically.
|
||||
pub fn worktree_branches(workdir: &Path) -> Result<Vec<String>> {
|
||||
let repo = gix::open(workdir)?;
|
||||
let repo = open_with_cache(workdir)?;
|
||||
let mut names = Vec::new();
|
||||
for reference in repo.references()?.local_branches()? {
|
||||
let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||
@@ -340,7 +416,7 @@ pub fn worktree_branches(workdir: &Path) -> Result<Vec<String>> {
|
||||
|
||||
/// Short names of tags (`refs/tags/*`), sorted alphabetically.
|
||||
pub fn worktree_tags(workdir: &Path) -> Result<Vec<String>> {
|
||||
let repo = gix::open(workdir)?;
|
||||
let repo = open_with_cache(workdir)?;
|
||||
let mut names = Vec::new();
|
||||
for reference in repo.references()?.tags()? {
|
||||
let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||
@@ -377,7 +453,7 @@ pub struct WorktreeSnapshot {
|
||||
/// Snapshot the worktree after a branch/tag switch: entries, README, the
|
||||
/// branch HEAD points to and its commit, opening the repository once.
|
||||
pub fn worktree_snapshot(workdir: &Path) -> Result<WorktreeSnapshot> {
|
||||
let repo = gix::open(workdir)?;
|
||||
let repo = open_with_cache(workdir)?;
|
||||
let readme_path = find_readme(&repo)?;
|
||||
let readme = match &readme_path {
|
||||
Some(path) => worktree_read(&repo, path)?,
|
||||
@@ -448,6 +524,8 @@ fn collect_entries(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, bool)>) -> R
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use nostr::prelude::*;
|
||||
use signed_core::repo_addr;
|
||||
|
||||
@@ -601,12 +679,13 @@ mod tests {
|
||||
std::fs::write(dir.path().join("b.txt"), b"b").expect("write");
|
||||
commit_all(&repo, "third");
|
||||
|
||||
let commits = all_commits(&repo).expect("commits");
|
||||
let mut summaries: Vec<&str> = commits.iter().map(|c| c.summary.as_str()).collect();
|
||||
let list = all_commits(&repo).expect("commits");
|
||||
assert_eq!(list.total, 3);
|
||||
let mut summaries: Vec<&str> = list.commits.iter().map(|c| c.summary.as_str()).collect();
|
||||
summaries.sort();
|
||||
assert_eq!(summaries, vec!["initial", "second", "third"]);
|
||||
assert!(
|
||||
commits
|
||||
list.commits
|
||||
.iter()
|
||||
.all(|c| c.author == "Test Author" && !c.id.is_empty() && c.time > 0)
|
||||
);
|
||||
@@ -616,7 +695,9 @@ mod tests {
|
||||
fn all_commits_returns_empty_without_head() {
|
||||
let (_dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||
|
||||
assert!(all_commits(&repo).expect("commits").is_empty());
|
||||
let list = all_commits(&repo).expect("commits");
|
||||
assert!(list.commits.is_empty());
|
||||
assert_eq!(list.total, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -664,6 +745,35 @@ mod tests {
|
||||
assert!(commit.summary.starts_with("Merge branch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_commits_batches_multiple_paths() {
|
||||
let (dir, repo) = fixture(&[("a.txt", b"one"), ("b.txt", b"b")]);
|
||||
commit_all(&repo, "initial");
|
||||
|
||||
std::fs::write(dir.path().join("a.txt"), b"two").expect("write");
|
||||
commit_all(&repo, "change a");
|
||||
std::fs::write(dir.path().join("b.txt"), b"bb").expect("write");
|
||||
commit_all(&repo, "change b");
|
||||
|
||||
let found = worktree_last_commits(
|
||||
dir.path(),
|
||||
&[
|
||||
PathBuf::from("a.txt"),
|
||||
PathBuf::from("b.txt"),
|
||||
// Untracked paths are simply absent from the result.
|
||||
PathBuf::from("missing.txt"),
|
||||
],
|
||||
)
|
||||
.expect("commits");
|
||||
let by_path: HashMap<&Path, &FileCommit> = found
|
||||
.iter()
|
||||
.map(|(path, commit)| (path.as_path(), commit))
|
||||
.collect();
|
||||
assert_eq!(by_path.len(), 2);
|
||||
assert_eq!(by_path[Path::new("a.txt")].summary, "change a");
|
||||
assert_eq!(by_path[Path::new("b.txt")].summary, "change b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_readme_prefers_markdown() {
|
||||
let (_dir, repo) = fixture(&[("readme.txt", b"txt"), ("README.md", b"md")]);
|
||||
|
||||
Reference in New Issue
Block a user