This commit is contained in:
2026-09-04 08:16:05 +07:00
parent 212f35d6bb
commit 17de4f6376
43 changed files with 376 additions and 721 deletions
+66 -91
View File
@@ -44,8 +44,6 @@ impl GitCache {
}
/// Open the existing clone, fetching it first.
/// Otherwise clone from the first working URL in `clone_urls`.
/// `clone_urls` holds the announcement's `clone` tag.
pub fn ensure_clone(&self, addr: &RepoAddr, clone_urls: &[String]) -> Result<gix::Repository> {
let path = self.repo_path(addr);
@@ -66,19 +64,16 @@ impl GitCache {
}
/// Maximum directory nesting depth when scanning for local repositories.
///
/// Pathological trees can't stall the scan.
const SCAN_MAX_DEPTH: usize = 12;
/// Directories never descended into during a scan.
///
/// Dependency caches can be enormous without ever containing user repositories.
const SCAN_SKIPPED_DIRS: [&str; 1] = ["node_modules"];
/// Walk `root` recursively and collect the paths of git repositories below it.
/// A repository is a directory containing a `.git` entry.
/// Hidden entries and symlinks are skipped.
/// Repositories are not descended into.
/// Nested ones like submodule worktrees are not reported.
/// Results are canonicalized, deduplicated and sorted.
pub fn find_git_repos(root: &Path) -> Vec<PathBuf> {
let mut repos = Vec::new();
if !root.is_dir() {
@@ -125,10 +120,7 @@ pub fn find_git_repos(root: &Path) -> Vec<PathBuf> {
}
/// Clone into `path` from the first working URL in `clone_urls`.
/// `clone_urls` holds the announcement's `clone` tag.
/// Then fetch the `refs/nostr/*` PR refs like the cache clone does.
/// The destination must not exist yet.
/// When no URL works, the last error is returned.
///
/// Unlike [`GitCache::ensure_clone`], the clone is not kept in any cache.
pub fn clone_repo(clone_urls: &[String], path: &Path) -> Result<()> {
if path.exists() {
@@ -156,8 +148,6 @@ pub fn clone_repo(clone_urls: &[String], path: &Path) -> Result<()> {
}
/// Fetch all configured refspecs from `origin`, plus the `refs/nostr/*` namespace.
/// GRASP mirrors serve pull request branches there, one ref per PR event id.
/// This mirrors the layout used by ngit.
pub fn fetch_all(repo: &gix::Repository) -> Result<()> {
let options = gix::remote::ref_map::Options {
extra_refspecs: vec![
@@ -176,9 +166,10 @@ pub fn fetch_all(repo: &gix::Repository) -> Result<()> {
Ok(())
}
/// Apply a `git format-patch` patch or series with `git am`.
/// Uses the git CLI because it handles the mbox format natively.
/// Can be replaced with a pure-Rust implementation later without changing callers.
/// Apply a `git format-patch` patch or series with `git am`,
/// uses the git CLI because it handles the mbox format natively.
///
/// TODO: Replaced with a pure-Rust implementation later without changing callers.
pub fn apply_patch(repo_path: &Path, patch: &str) -> Result<()> {
let mut child = Command::new("git")
.arg("am")
@@ -201,9 +192,11 @@ pub fn apply_patch(repo_path: &Path, patch: &str) -> Result<()> {
Ok(())
}
/// The merge base of two revisions in the repository at `repo_path`.
/// Revisions may be branch names, remote-tracking refs or commit ids.
/// The merge base of two revisions in the repository at `repo_path`,
/// revisions may be branch names, remote-tracking refs or commit ids.
///
/// `Ok(None)` when the revisions share no common ancestor.
///
/// Unresolvable revisions are errors.
pub fn merge_base(repo_path: &Path, a: &str, b: &str) -> Result<Option<String>> {
let output = Command::new("git")
@@ -230,8 +223,8 @@ pub fn merge_base(repo_path: &Path, a: &str, b: &str) -> Result<Option<String>>
/// The `git format-patch` mbox series of `base..tip`, like `git format-patch --stdout`.
/// Fails when the range has no commits.
/// The mbox is returned untrimmed.
/// Trailing newlines are part of the format.
///
/// The mbox is returned untrimmed. Trailing newlines are part of the format.
pub fn format_patch_between(repo_path: &Path, base: &str, tip: &str) -> Result<String> {
let output = Command::new("git")
.arg("-C")
@@ -256,9 +249,6 @@ pub fn format_patch_between(repo_path: &Path, base: &str, tip: &str) -> Result<S
}
/// Whether `patch` applies to the working tree of `repo_path` without modifying anything.
/// `patch` is a `git format-patch` series.
/// Checks with `git apply --check --3way`.
/// Best-effort, useful to surface conflicts before a patch is published or applied.
pub fn patch_applies(repo_path: &Path, patch: &str) -> Result<()> {
let mut child = Command::new("git")
.arg("apply")
@@ -282,14 +272,11 @@ pub fn patch_applies(repo_path: &Path, patch: &str) -> Result<()> {
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(())
}
/// Push `commit` to `reference` on the server at `url`, from `repo_path`.
/// `reference` is a ref name like `refs/nostr/<event-id>`.
/// GRASP servers host the `refs/nostr` namespace so anyone can contribute a commit.
/// nak pushes pull request tips there before publishing the PR event.
/// Readers fetch the ref to get the commit behind a PR's `c` tag.
pub fn push_commit_ref(repo_path: &Path, url: &str, commit: &str, reference: &str) -> Result<()> {
let output = Command::new("git")
.arg("-C")
@@ -312,8 +299,7 @@ pub fn push_commit_ref(repo_path: &Path, url: &str, commit: &str, reference: &st
}
/// Split a `git format-patch` series into its individual patches, mbox messages.
/// Each message begins with a `From <40-hex> ` boundary line.
/// `>From` quoting inside bodies means no false positives.
///
/// A single patch yields one element.
/// A malformed input yields one element covering it.
pub fn split_patch_series(patch: &str) -> Vec<&str> {
@@ -342,6 +328,7 @@ pub fn split_patch_series(patch: &str) -> Vec<&str> {
}
/// The commit HEAD points to in the repository at `repo_path`.
///
/// `None` when the repository has no commits yet, an unborn HEAD.
pub fn head_commit_id(repo_path: &Path) -> Result<Option<String>> {
let output = Command::new("git")
@@ -363,8 +350,8 @@ pub fn head_commit_id(repo_path: &Path) -> Result<Option<String>> {
/// The commits in `base..HEAD` of the repository at `repo_path`, oldest first.
/// This is the order `git am` creates them.
///
/// `HEAD` alone when `base` is `None`.
/// An empty range yields an empty list.
pub fn commits_since(repo_path: &Path, base: Option<&str>) -> Result<Vec<String>> {
let output = match base {
Some(base) => git_in(
@@ -402,9 +389,10 @@ fn clone(url: &str, path: &Path) -> Result<gix::Repository> {
/// Create a repository at `path` with an initial `main` branch.
/// Write a `README.md` from `name` and `description`, then create the initial commit.
///
/// Returns the initial commit id.
///
/// Uses the git CLI, like [`apply_patch`].
/// The CLI handles index writes, ref updates and default branch selection natively.
pub fn init_repository(path: &Path, name: &str, description: &str) -> Result<String> {
std::fs::create_dir_all(path)
.with_context(|| format!("failed to create {}", path.display()))?;
@@ -470,6 +458,7 @@ pub fn push_main(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -
}
/// Push every local branch and tag of the repository at `repo_path` to a grasp server.
///
/// This mirrors an initialized repository's whole history.
pub fn push_all(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
let url = format!("{base_url}/{owner}/{repo_id}.git");
@@ -496,8 +485,8 @@ pub fn push_all(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) ->
}
/// The earliest unique commit of the repository at `repo_path`.
/// A root commit, like `git rev-list --max-parents=0 HEAD`.
/// Used as the NIP-34 announcement's `euc` marker.
///
/// `None` for a repository without commits.
pub fn root_commit(repo_path: &Path) -> Result<Option<String>> {
let output = Command::new("git")
@@ -523,8 +512,7 @@ pub fn root_commit(repo_path: &Path) -> Result<Option<String>> {
}
/// Add `origin` pointing at `url` when the repository has no remote yet.
/// Uses the standard fetch mapping.
/// Later `git fetch origin` and the cache's `fetch_all` update `refs/remotes/origin/*`.
///
/// No-op if `origin` already exists.
pub fn ensure_origin(repo_path: &Path, url: &str) -> Result<()> {
// `git remote get-url origin` exits non-zero when the remote is absent.
@@ -543,8 +531,9 @@ pub fn ensure_origin(repo_path: &Path, url: &str) -> Result<()> {
Ok(())
}
/// Point `origin` at `url`, replacing an existing remote.
/// Used after a clone whose `origin` points at the cloned-from path.
/// Point `origin` at `url`, replacing an existing remote,
/// used after a clone whose `origin` points at the cloned-from path.
///
/// A working copy cloned from a local mirror is re-targeted at the grasp server.
pub fn set_origin(repo_path: &Path, url: &str) -> Result<()> {
// `git remote get-url origin` exits non-zero when the remote is absent.
@@ -557,10 +546,8 @@ pub fn set_origin(repo_path: &Path, url: &str) -> Result<()> {
}
/// Fetch `refspec` into `repo_path` from the first working URL in `urls`.
/// An example refspec is `+refs/heads/*:refs/fork/<owner>/<id>/*`.
/// Like [`clone_repo`], `grasp://` URLs are rewritten to `https://`.
/// The terminal prompt is disabled.
/// When no URL works, the last error is returned.
///
/// Never touches the checked-out refs or the worktree.
pub fn fetch_repo_refs(repo_path: &Path, urls: &[String], refspec: &str) -> Result<()> {
let mut last_err: Option<anyhow::Error> = None;
@@ -599,6 +586,7 @@ pub fn fetch_repo_refs(repo_path: &Path, urls: &[String], refspec: &str) -> Resu
/// Full ref names under `prefix`, sorted lexicographically, like `git for-each-ref`.
/// `prefix` is a ref namespace like `refs/fork/<owner>/<id>`.
///
/// Returns an empty list when nothing matches.
pub fn refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<Vec<String>> {
// `for-each-ref` patterns match whole path components.
@@ -629,7 +617,9 @@ pub fn refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<Vec<String>> {
/// Delete every ref under `prefix` of the repository at `repo_path`.
/// `prefix` is a ref namespace like `refs/fork/<owner>/<id>`.
///
/// Lets a stale import be pruned before a re-import.
///
/// No-op when nothing matches.
pub fn delete_refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<()> {
let refs = refs_with_prefix(repo_path, prefix)?;
@@ -666,6 +656,7 @@ pub fn delete_refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<()> {
}
/// The URL of the `origin` remote of the repository at `workdir`.
///
/// `None` when it has no `origin` yet.
pub fn origin_url(workdir: &Path) -> Result<Option<String>> {
let output = Command::new("git")
@@ -685,14 +676,7 @@ pub fn origin_url(workdir: &Path) -> Result<Option<String>> {
}
/// Fast-forward local branches that trail their remote-tracking counterpart.
/// The counterpart ref is `refs/remotes/origin/<name>` in the repository at `workdir`.
/// Like a `git pull --ff-only` on each branch.
/// A mirror clone used for browsing catches up without rewriting history.
/// The checked-out branch is moved with a merge so its worktree follows.
/// A dirty worktree fails the merge cleanly and is left for the next refresh.
/// Other branches are updated directly.
/// Branches without a remote-tracking counterpart are left alone.
/// Local commits of their own also keep a branch untouched.
///
/// Returns whether any branch moved.
pub fn fast_forward_branches(workdir: &Path) -> Result<bool> {
let current = git_in(workdir, &["branch", "--show-current"]).unwrap_or_default();
@@ -734,6 +718,7 @@ pub fn fast_forward_branches(workdir: &Path) -> Result<bool> {
}
/// Run a git command in `dir`, returning trimmed stdout.
///
/// The terminal prompt is disabled so a credential request fails instead of hanging.
fn git_in(dir: &Path, args: &[&str]) -> Result<String> {
let output = Command::new("git")
@@ -757,6 +742,7 @@ fn git_in(dir: &Path, args: &[&str]) -> Result<String> {
}
/// Map an untrusted repository id or display name to a safe single path component.
///
/// Everything outside `[A-Za-z0-9._-]` becomes `_`.
/// An id that maps to exactly `.` or `..` becomes `_`.
pub fn sanitize_path_component(id: &str) -> String {
@@ -779,6 +765,7 @@ pub fn sanitize_path_component(id: &str) -> String {
}
/// 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;
@@ -799,7 +786,7 @@ pub struct FileCommit {
}
/// Relative paths of all entries in the worktree, files and directories.
/// Directories first, then alphabetically within each group.
///
/// The `.git` directory is skipped.
pub fn worktree_entries(repo: &gix::Repository) -> Result<Vec<PathBuf>> {
let workdir = repo.workdir().context("repository has no worktree")?;
@@ -816,6 +803,7 @@ pub fn worktree_entries(repo: &gix::Repository) -> Result<Vec<PathBuf>> {
}
/// Read a file from the worktree.
///
/// Returns `Ok(None)` if the path is missing or not a regular file.
pub fn worktree_read(repo: &gix::Repository, rel: &Path) -> Result<Option<Vec<u8>>> {
let workdir = repo.workdir().context("repository has no worktree")?;
@@ -830,9 +818,7 @@ pub fn worktree_read(repo: &gix::Repository, rel: &Path) -> Result<Option<Vec<u8
}
/// Find the README file in the repository root.
/// Returned as a path relative to the worktree.
/// Matching is case-insensitive.
/// Prefers `README.md`, then `.markdown`, `.mdown`, `.mkdn`.
///
/// Falls back to any other file whose name starts with `readme`.
pub fn find_readme(repo: &gix::Repository) -> Result<Option<PathBuf>> {
let Some(workdir) = repo.workdir() else {
@@ -878,7 +864,10 @@ fn open_with_cache(workdir: &Path) -> Result<gix::Repository> {
/// A [`FileCommit`] from a walk commit, with author, message title and shortened id.
/// `include_description` controls whether the message body is copied.
/// History lists never display it, so skipping it saves an allocation per listed commit.
///
/// History lists never display it,
/// so skipping it saves an allocation per listed commit.
///
/// The diff panel fetches the full commit on demand.
fn file_commit(commit: &gix::Commit<'_>, include_description: bool) -> Result<FileCommit> {
let author = commit.author()?;
@@ -900,9 +889,7 @@ fn file_commit(commit: &gix::Commit<'_>, include_description: bool) -> Result<Fi
}
/// Find the most recent commit that changed `rel`, a path relative to the worktree.
/// Like `git log -1 -- <rel>`.
/// Walks newest-first from `HEAD`.
/// Reports the first commit whose tree entry for `rel` differs from its first parent's.
///
/// `Ok(None)` when no commit touched the file, e.g. an untracked file.
pub fn last_commit(repo: &gix::Repository, rel: &Path) -> Result<Option<FileCommit>> {
let rel = rel.to_path_buf();
@@ -914,7 +901,7 @@ pub fn last_commit(repo: &gix::Repository, rel: &Path) -> Result<Option<FileComm
/// Newest commit touching each of `rels`, like `git log -1 -- <rel>` per path.
/// `rels` are paths relative to the worktree.
/// A single walk decodes every commit once and shares it across all paths.
///
/// Paths without any commit, like untracked files, are absent from the result.
pub fn worktree_last_commits(
workdir: &Path,
@@ -924,6 +911,7 @@ pub fn worktree_last_commits(
}
/// The walk behind [`last_commit`] and [`worktree_last_commits`].
///
/// Stops 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;
@@ -984,14 +972,13 @@ fn last_commits(repo: &gix::Repository, rels: &[PathBuf]) -> Result<Vec<(PathBuf
Ok(found)
}
/// Cap on [`CommitList::commits`].
/// The virtual list renders a window at a time, the tab badge shows the real count.
/// Cap on [`CommitList::commits`]. The virtual list renders a window at a time,
/// the tab badge shows the real count.
///
/// 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.
/// `total` is the real count, for the tab badge.
pub struct CommitList {
/// Number of commits reachable from HEAD.
pub total: usize,
@@ -1000,6 +987,7 @@ pub struct CommitList {
}
/// All commits reachable from `HEAD`, newest first, with author and summary.
///
/// 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;
@@ -1029,6 +1017,7 @@ pub fn all_commits(repo: &gix::Repository) -> Result<CommitList> {
}
/// Like [`all_commits`], but opens the repository at `workdir` first.
///
/// For non-bare clones the clone root is the worktree.
pub fn worktree_all_commits(workdir: &Path) -> Result<CommitList> {
all_commits(&open_with_cache(workdir)?)
@@ -1058,7 +1047,6 @@ pub struct DiffLine {
}
/// A hunk of a file diff, like `@@ -a,b +c,d @@`.
/// Context around each change, then removals and additions.
#[derive(Debug, Clone)]
pub struct DiffHunk {
/// 1-based start line in the old version.
@@ -1101,16 +1089,14 @@ pub struct FileDiff {
}
/// The changes of one commit.
/// Lists every file it added, modified, deleted or renamed.
/// Text files carry line-level hunks.
#[derive(Debug, Clone)]
pub struct CommitDiff {
pub files: Vec<FileDiff>,
}
/// The changes of the commit `id`, short or full, in the repository at `workdir`.
///
/// Compared against its first parent, the empty tree for the root commit.
/// Like `git show`, files are sorted by path.
pub fn worktree_commit_diff(workdir: &Path, id: &str) -> Result<CommitDiff> {
commit_diff(&open_with_cache(workdir)?, id)
}
@@ -1127,7 +1113,7 @@ fn commit_diff(repo: &gix::Repository, id: &str) -> Result<CommitDiff> {
}
/// 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)?;
@@ -1170,8 +1156,7 @@ pub fn worktree_commit_range_commits(
Ok(commits)
}
/// The changes between two trees.
/// Used by both [`commit_diff`] and [`worktree_commit_range_diff`].
/// 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<'_>>,
@@ -1290,12 +1275,6 @@ fn tree_diff(
}
/// Parse `git format-patch` output, a single patch or a series.
/// Produces the same [`CommitDiff`] structure used for commit diffs.
/// The mbox envelope is skipped, the From and Subject headers, commit body and diffstat.
/// Every `diff --git` section becomes one [`FileDiff`].
/// Paths come from the section headers, with git's C-style quoting undone.
/// Sections without hunks are reported without lines.
/// That covers pure renames, mode changes and binary files.
pub fn patch_diffs(patch: &str) -> Result<CommitDiff> {
let lines: Vec<&str> = patch.lines().collect();
let mut files = Vec::new();
@@ -1315,8 +1294,7 @@ pub fn patch_diffs(patch: &str) -> Result<CommitDiff> {
}
/// Commits of a `git format-patch` output, a single patch or a series.
/// Parsed from each patch's mbox envelope headers.
/// Yields the commit id, author, summary and author time.
///
/// Entries appear in patch order, oldest first as `git format-patch` produces them.
pub fn patch_commits(patch: &str) -> Vec<FileCommit> {
let lines: Vec<&str> = patch.lines().collect();
@@ -1379,6 +1357,7 @@ fn name_from_address(from: &str) -> String {
}
/// Strip the patch prefix from a `Subject:` header.
///
/// Examples are `[PATCH]`, `[PATCH 1/2]` and `[RFC PATCH]`.
fn strip_patch_prefix(subject: &str) -> String {
let trimmed = subject.trim();
@@ -1396,7 +1375,7 @@ fn strip_patch_prefix(subject: &str) -> String {
}
/// Parse one file's diff section.
/// Everything after the `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.
fn parse_diff_section(header: &str, lines: &[&str], start: usize) -> Result<(FileDiff, usize)> {
let (header_old, header_new) = header_paths(header)?;
@@ -1484,7 +1463,7 @@ fn parse_diff_section(header: &str, lines: &[&str], start: usize) -> Result<(Fil
}
/// Parse one hunk, the `@@ -a,b +c,d @@` header plus every body line.
/// Lines end at the next hunk header, `diff --git` section or the end of the patch.
///
/// 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])?;
@@ -1543,6 +1522,7 @@ fn parse_hunk(lines: &[&str], start: usize) -> Result<(DiffHunk, usize)> {
}
/// 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()? {
@@ -1554,6 +1534,7 @@ fn line_prefix_kind(line: &str) -> Option<DiffLineKind> {
}
/// 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
@@ -1577,9 +1558,6 @@ fn hunk_header(header: &str) -> Result<(u32, u32, u32, u32)> {
}
/// The old and new paths of a `diff --git a/X b/Y` header.
/// Git's C-style quoting is undone.
/// Git only quotes paths that need escaping, non-ASCII bytes, `"` and `\`.
/// Plain spaces stay unquoted, so an unquoted header splits at the last ` b/`.
fn header_paths(header: &str) -> Result<(String, String)> {
if header.starts_with('"') {
// Quoted paths include the `a/` / `b/` prefix inside the quotes.
@@ -1609,9 +1587,6 @@ fn header_paths(header: &str) -> Result<(String, String)> {
}
/// The path of a `--- a/X` or `+++ b/Y` line.
/// The prefix is stripped, the trailing tab removed, C-style quoting undone.
/// Git adds a trailing padding tab for paths containing spaces.
/// These lines name the two sides unambiguously, unlike the `diff --git` header.
fn diff_line_path(line: &str, prefix: &str) -> Result<String> {
let line = line.trim_end_matches('\t');
if line.starts_with('"') {
@@ -1630,8 +1605,8 @@ fn diff_line_path(line: &str, prefix: &str) -> Result<String> {
/// 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.
/// Iterates by character so slices land on UTF-8 boundaries even for non-ASCII paths.
fn take_quoted(input: &str) -> Option<(&str, &str)> {
let mut end = 1; // byte after the opening quote
let mut rest = &input[1..];
@@ -1708,10 +1683,6 @@ fn unquote_path(path: &str) -> Result<String> {
}
/// Collects the hunks of one blob diff while tracking per-line numbers.
/// The unified-diff headers give the 1-based start line of the hunk in each file.
/// Context lines advance both counters.
/// Removals advance only the old counter, additions only the new one.
/// Each line then carries its real numbers in both versions.
struct HunkCollector<'a> {
hunks: &'a mut Vec<DiffHunk>,
insertions: &'a mut usize,
@@ -1784,6 +1755,7 @@ impl ConsumeHunk for HunkCollector<'_> {
}
/// The commit HEAD points to, like `git log -1`.
///
/// `Ok(None)` for a repository without commits yet, an unborn HEAD.
pub fn head_commit(repo: &gix::Repository) -> Result<Option<FileCommit>> {
let Some(head) = repo.head_id().ok() else {
@@ -1795,9 +1767,8 @@ pub fn head_commit(repo: &gix::Repository) -> Result<Option<FileCommit>> {
/// Full metadata of the commit `id`, short or full, in the repository at `workdir`.
/// Like [`head_commit`] for an arbitrary commit.
///
/// `Ok(None)` when the id cannot be resolved.
/// The commit list, [`all_commits`], omits message bodies to keep the walk cheap.
/// The diff panel uses this to fetch the full commit on demand.
pub fn worktree_commit(workdir: &Path, id: &str) -> Result<Option<FileCommit>> {
let repo = open_with_cache(workdir)?;
match repo.rev_parse_single(id.as_bytes()) {
@@ -1842,6 +1813,7 @@ pub fn worktree_tags(workdir: &Path) -> Result<Vec<String>> {
}
/// Short name of the branch HEAD points to, or `None` when detached.
///
/// Detached after checking out a tag or a commit directly.
pub fn current_branch(repo: &gix::Repository) -> Result<Option<String>> {
let head = repo.head()?;
@@ -1852,6 +1824,7 @@ pub fn current_branch(repo: &gix::Repository) -> Result<Option<String>> {
}
/// Branch, tag and HEAD refs of a repository.
///
/// Ready for a NIP-34 kind-30618 repository state announcement.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepoRefState {
@@ -1862,6 +1835,7 @@ pub struct RepoRefState {
}
/// Collect the refs of `repo`.
///
/// Local branches and tags become `(refname, commit-id)` pairs.
/// Also reports the branch HEAD points to.
pub fn repo_ref_state(repo: &gix::Repository) -> Result<RepoRefState> {
@@ -1914,8 +1888,8 @@ pub struct WorktreeSnapshot {
}
/// Snapshot the worktree after a branch or tag switch.
///
/// Collects entries, the README, the branch HEAD points to and its commit.
/// Opens the repository once.
pub fn worktree_snapshot(workdir: &Path) -> Result<WorktreeSnapshot> {
let repo = open_with_cache(workdir)?;
let readme_path = find_readme(&repo)?;
@@ -1933,6 +1907,7 @@ pub fn worktree_snapshot(workdir: &Path) -> Result<WorktreeSnapshot> {
}
/// Switch the checked-out ref and update the worktree, like `git checkout --force`.
///
/// Local modifications are discarded, these clones are read-only browser copies.
fn checkout(workdir: &Path, args: &[&str]) -> Result<()> {
let output = Command::new("git")