strip redundant doc comments from signed_core and signed_git
This commit is contained in:
@@ -5,7 +5,6 @@ use signed_core::{Announcement, RepoAddr};
|
||||
|
||||
use crate::repo::Repo;
|
||||
|
||||
/// On-disk cache of cloned repositories, keyed by owner pubkey / repo id.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GitCache {
|
||||
root: PathBuf,
|
||||
@@ -36,7 +35,6 @@ impl GitCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the existing clone, fetching it first.
|
||||
pub fn ensure_clone<U: AsRef<str>>(&self, addr: &RepoAddr, clone_urls: &[U]) -> Result<Repo> {
|
||||
let path = self.repo_path(addr);
|
||||
|
||||
@@ -53,7 +51,8 @@ impl GitCache {
|
||||
Repo::clone(clone_urls, &path)
|
||||
}
|
||||
|
||||
/// Map an untrusted repository id or display name to a safe single path component.
|
||||
// `id` is untrusted relay content: it must never escape the cache root as
|
||||
// a single path component.
|
||||
pub fn sanitize_path_component(id: &str) -> String {
|
||||
let sanitized: String = id
|
||||
.chars()
|
||||
@@ -73,7 +72,6 @@ impl GitCache {
|
||||
sanitized
|
||||
}
|
||||
|
||||
/// The refs namespace of a fork's import in the target mirror.
|
||||
pub fn fork_namespace(announcement: &Announcement) -> String {
|
||||
format!(
|
||||
"{}/{}",
|
||||
|
||||
@@ -5,7 +5,6 @@ use crate::repo::Repo;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DiffLineKind {
|
||||
/// An unchanged context line, present on both sides.
|
||||
Context,
|
||||
Addition,
|
||||
Deletion,
|
||||
@@ -14,21 +13,15 @@ pub enum DiffLineKind {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiffLine {
|
||||
pub kind: DiffLineKind,
|
||||
/// 1-based line number in the old version, if the line exists there.
|
||||
pub old: Option<u32>,
|
||||
/// 1-based line number in the new version, if the line exists there.
|
||||
pub new: Option<u32>,
|
||||
/// Line content without the trailing newline.
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// A hunk of a file diff, like `@@ -a,b +c,d @@`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiffHunk {
|
||||
/// 1-based start line in the old version.
|
||||
pub old_start: u32,
|
||||
pub old_lines: u32,
|
||||
/// 1-based start line in the new version.
|
||||
pub new_start: u32,
|
||||
pub new_lines: u32,
|
||||
pub lines: Vec<DiffLine>,
|
||||
@@ -45,18 +38,13 @@ pub enum DiffStatus {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileDiff {
|
||||
/// Path of the file relative to the repo root.
|
||||
///
|
||||
/// For renames and copies, this is the destination path.
|
||||
// For renames and copies, the destination path.
|
||||
pub path: String,
|
||||
/// Previous path, for renames and copies.
|
||||
pub old_path: Option<String>,
|
||||
pub status: DiffStatus,
|
||||
/// Number of added lines, 0 for binary files.
|
||||
pub insertions: usize,
|
||||
/// Number of removed lines, 0 for binary files.
|
||||
pub deletions: usize,
|
||||
/// True if either version is binary, then `hunks` is empty.
|
||||
// Binary files have empty `hunks`.
|
||||
pub binary: bool,
|
||||
pub hunks: Vec<DiffHunk>,
|
||||
}
|
||||
@@ -67,9 +55,7 @@ pub struct CommitDiff {
|
||||
}
|
||||
|
||||
impl Repo {
|
||||
/// The changes of the commit `id`, short or full.
|
||||
///
|
||||
/// Compared against its first parent, the empty tree for the root commit.
|
||||
// Compared against the first parent; the empty tree for the root commit.
|
||||
pub fn commit_diff(&self, id: &str) -> Result<CommitDiff> {
|
||||
let commit_id = self.inner.rev_parse_single(id.as_bytes())?;
|
||||
let commit = commit_id.object()?.into_commit();
|
||||
@@ -81,9 +67,7 @@ impl Repo {
|
||||
Self::tree_diff(self, old_tree.as_ref(), &new_tree)
|
||||
}
|
||||
|
||||
/// The changes between two commits, `base`..`tip`, like `git diff base tip`.
|
||||
///
|
||||
/// Directories and submodules are skipped, files are sorted by path.
|
||||
// Directories and submodules are skipped, files are sorted by path.
|
||||
pub fn range_diff(&self, base: &str, tip: &str) -> Result<CommitDiff> {
|
||||
let base_tree = self
|
||||
.inner
|
||||
@@ -119,7 +103,6 @@ impl Repo {
|
||||
for change in changes {
|
||||
let attached = Change::from_change_ref(change.to_ref(), &repo.inner, &repo.inner);
|
||||
|
||||
// Skip directory trees and submodule gitlinks, only files are listed.
|
||||
let (path, old_path, status) = match attached {
|
||||
Change::Addition {
|
||||
location,
|
||||
@@ -175,7 +158,6 @@ impl Repo {
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// Always diff with the built-in algorithm.
|
||||
// External diff drivers would shell out, out of scope for a read-only viewer.
|
||||
let platform = attached.diff(&mut cache)?;
|
||||
platform
|
||||
@@ -224,7 +206,6 @@ impl Repo {
|
||||
}
|
||||
}
|
||||
|
||||
/// Collects the hunks of one blob diff while tracking per-line numbers.
|
||||
struct HunkCollector<'a> {
|
||||
hunks: &'a mut Vec<DiffHunk>,
|
||||
insertions: &'a mut usize,
|
||||
|
||||
@@ -5,33 +5,22 @@ use anyhow::Result;
|
||||
|
||||
use crate::repo::Repo;
|
||||
|
||||
/// Metadata of a commit, as shown in the repository browser's file header.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileCommit {
|
||||
/// Shortened commit id, 7+ hex chars, disambiguated if needed.
|
||||
pub id: String,
|
||||
/// First line of the commit message.
|
||||
pub summary: String,
|
||||
/// Rest of the commit message after the title.
|
||||
///
|
||||
/// `None` for single-line commit messages.
|
||||
pub description: Option<String>,
|
||||
pub author: String,
|
||||
/// Author time, seconds since the Unix epoch.
|
||||
pub time: i64,
|
||||
}
|
||||
|
||||
impl FileCommit {
|
||||
/// A [`FileCommit`] with author, message title, body and shortened id.
|
||||
///
|
||||
/// The diff panel fetches the full commit on demand.
|
||||
fn from_commit(commit: &gix::Commit<'_>) -> Result<FileCommit> {
|
||||
Self::from_commit_with_description(commit, true)
|
||||
}
|
||||
|
||||
/// A [`FileCommit`] without the message body, for history lists that never display it.
|
||||
///
|
||||
/// Skipping the body saves an allocation per listed commit.
|
||||
// History lists never display the body,
|
||||
// skipping it saves an allocation per listed commit.
|
||||
fn from_commit_summary(commit: &gix::Commit<'_>) -> Result<FileCommit> {
|
||||
Self::from_commit_with_description(commit, false)
|
||||
}
|
||||
@@ -61,10 +50,7 @@ impl FileCommit {
|
||||
}
|
||||
|
||||
impl Repo {
|
||||
/// Newest commit touching each of `rels`, like `git log -1 -- <rel>` per path.
|
||||
/// `rels` are paths relative to the worktree.
|
||||
///
|
||||
/// Paths without any commit, like untracked files, are absent from the result.
|
||||
// Paths without any commit, like untracked files, are absent from the result.
|
||||
pub fn last_commits(&self, rels: &[PathBuf]) -> Result<Vec<(PathBuf, FileCommit)>> {
|
||||
use gix::traverse::commit::simple::CommitTimeOrder;
|
||||
|
||||
@@ -125,9 +111,6 @@ impl Repo {
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// 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(&self) -> Result<CommitList> {
|
||||
use gix::traverse::commit::simple::CommitTimeOrder;
|
||||
|
||||
@@ -159,7 +142,6 @@ impl Repo {
|
||||
Ok(CommitList { total, commits })
|
||||
}
|
||||
|
||||
/// Commits in the range `base`..`tip`, newest first, like `git log base..tip`.
|
||||
pub fn commit_range(&self, base: &str, tip: &str) -> Result<Vec<FileCommit>> {
|
||||
use gix::traverse::commit::simple::CommitTimeOrder;
|
||||
|
||||
@@ -183,9 +165,7 @@ impl Repo {
|
||||
Ok(commits)
|
||||
}
|
||||
|
||||
/// The commit HEAD points to, like `git log -1`.
|
||||
///
|
||||
/// `Ok(None)` for a repository without commits yet, an unborn HEAD.
|
||||
// `Ok(None)` for an unborn HEAD.
|
||||
pub fn head_commit(&self) -> Result<Option<FileCommit>> {
|
||||
let Some(head) = self.inner.head_id().ok() else {
|
||||
return Ok(None);
|
||||
@@ -194,10 +174,7 @@ impl Repo {
|
||||
Ok(Some(FileCommit::from_commit(&commit)?))
|
||||
}
|
||||
|
||||
/// Full metadata of the commit `id`, short or full.
|
||||
/// Like [`Repo::head_commit`] for an arbitrary commit.
|
||||
///
|
||||
/// `Ok(None)` when the id cannot be resolved.
|
||||
// `Ok(None)` when the id cannot be resolved.
|
||||
pub fn commit(&self, id: &str) -> Result<Option<FileCommit>> {
|
||||
match self.inner.rev_parse_single(id.as_bytes()) {
|
||||
Ok(commit_id) => {
|
||||
@@ -209,16 +186,11 @@ impl Repo {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
// 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.
|
||||
pub struct CommitList {
|
||||
/// Number of commits reachable from HEAD.
|
||||
pub total: usize,
|
||||
/// Newest commits, capped at [`MAX_LISTED_COMMITS`].
|
||||
pub commits: Vec<FileCommit>,
|
||||
}
|
||||
|
||||
@@ -7,11 +7,11 @@ use crate::repo::Repo;
|
||||
/// The kind of NIP-34 relationship a local repository has on disk.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Nip34Kind {
|
||||
/// Bound to a NIP-34 coordinate, by `nak`'s `nip34.json` or `ngit`'s `nostr.repo`.
|
||||
// By `nak`'s `nip34.json` or `ngit`'s `nostr.repo`.
|
||||
Initialized,
|
||||
/// Cloned from a `nostr://` remote but never initialized locally.
|
||||
// Cloned from a `nostr://` remote but never initialized locally.
|
||||
Cloned,
|
||||
/// Nostr tooling touched the repository but no binding is recoverable.
|
||||
// Nostr tooling touched the repository but no binding is recoverable.
|
||||
ToolingOnly,
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ pub struct GraspSignals {
|
||||
pub nostr_repo_config: bool,
|
||||
pub nostr_remote: bool,
|
||||
pub grasp_remote: bool,
|
||||
/// `nip34_grasp_remote` is the `nak`-specific `nip34/grasp/<host>` remote name.
|
||||
// `nak`-specific `nip34/grasp/<host>` remote name.
|
||||
pub nip34_grasp_remote: bool,
|
||||
pub nip34_state_refs: bool,
|
||||
pub nostr_cache: bool,
|
||||
@@ -41,7 +41,7 @@ impl GraspSignals {
|
||||
pub struct Nip34Binding {
|
||||
pub kind: Nip34Kind,
|
||||
pub signals: GraspSignals,
|
||||
/// Coordinate owner and identifier, from `nip34.json` or `nostr.repo`.
|
||||
// From `nip34.json` or `nostr.repo`.
|
||||
pub owner: Option<PublicKey>,
|
||||
pub identifier: Option<String>,
|
||||
pub grasp_urls: Vec<String>,
|
||||
@@ -54,9 +54,7 @@ struct Nip34Json {
|
||||
}
|
||||
|
||||
impl Repo {
|
||||
/// What the repository's on-disk state says about its NIP-34 binding.
|
||||
///
|
||||
/// `None` when no Nostr tooling left a marker.
|
||||
// `None` when no Nostr tooling left a marker.
|
||||
pub fn nip34_binding(&self) -> Option<Nip34Binding> {
|
||||
let common_dir = self.inner.common_dir().to_path_buf();
|
||||
let workdir = self.inner.workdir().map(std::path::Path::to_path_buf);
|
||||
@@ -186,7 +184,6 @@ impl Repo {
|
||||
})
|
||||
}
|
||||
|
||||
/// Record the repository's NIP-34 coordinate in its local `nostr.repo` config.
|
||||
pub fn set_nostr_repo(&self, naddr: &str) -> Result<()> {
|
||||
self.edit_local_config(|config| {
|
||||
config.set_raw_value("nostr.repo", naddr)?;
|
||||
@@ -194,8 +191,8 @@ impl Repo {
|
||||
})
|
||||
}
|
||||
|
||||
/// Mirrors `nak`'s `IsGraspURL`: two path segments, a path of at least 65 bytes,
|
||||
/// and a first segment that decodes as an `npub`.
|
||||
// Mirrors `nak`'s `IsGraspURL`: two path segments,
|
||||
// a path of at least 65 bytes, and a first segment that decodes as an `npub`.
|
||||
fn is_grasp_url(url: &str) -> bool {
|
||||
let Ok(parsed) = Url::parse(url) else {
|
||||
return false;
|
||||
@@ -233,8 +230,8 @@ impl Repo {
|
||||
Some((coordinate.public_key, identifier))
|
||||
}
|
||||
|
||||
/// Handles a bare `naddr`, an `npub`, and the optional `[ssh-key-file@]`,
|
||||
/// `[protocol/]` and `[relay/]` components. An `nip05` owner yields no binding.
|
||||
// Handles a bare `naddr`, an `npub`, and the optional `[ssh-key-file@]`,
|
||||
// `[protocol/]` and `[relay/]` components. An `nip05` owner yields no binding.
|
||||
fn parse_nostr_url(url: &str) -> Option<(PublicKey, String)> {
|
||||
let rest = url.strip_prefix("nostr://")?;
|
||||
|
||||
@@ -252,7 +249,6 @@ impl Repo {
|
||||
parts.remove(0);
|
||||
}
|
||||
|
||||
// `[owner, (relay), identifier]`.
|
||||
if parts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -9,14 +9,10 @@ use crate::diff::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, File
|
||||
use crate::history::FileCommit;
|
||||
use crate::repo::Repo;
|
||||
|
||||
/// Pure parsing of `git format-patch` output, no repository involved.
|
||||
pub struct PatchParser;
|
||||
|
||||
impl PatchParser {
|
||||
/// Split a `git format-patch` series into its individual patches, mbox messages.
|
||||
///
|
||||
/// A single patch yields one element.
|
||||
/// A malformed input yields one element covering it.
|
||||
// A malformed input yields one element covering it.
|
||||
pub fn split_patch_series(patch: &str) -> Vec<&str> {
|
||||
Self::envelopes(patch)
|
||||
.into_iter()
|
||||
@@ -24,11 +20,8 @@ impl PatchParser {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Parse `git format-patch` output, a single patch or a series.
|
||||
///
|
||||
/// Backed by [`diffy::patch_set`], which implements git's extended diff format:
|
||||
/// `diff --git` headers, rename and copy detection, binary detection, and
|
||||
/// C-style quoted or octal-escaped paths.
|
||||
// Backed by `diffy::patch_set`, which implements git's extended diff format:
|
||||
// rename and copy detection, binary detection, quoted/escaped paths.
|
||||
pub fn patch_diffs(patch: &str) -> Result<CommitDiff> {
|
||||
if !patch.lines().any(|line| line.starts_with("diff --git ")) {
|
||||
return Ok(CommitDiff { files: Vec::new() });
|
||||
@@ -43,9 +36,8 @@ impl PatchParser {
|
||||
Ok(CommitDiff { files })
|
||||
}
|
||||
|
||||
/// Commits of a `git format-patch` output, a single patch or a series.
|
||||
///
|
||||
/// Entries appear in patch order, oldest first as `git format-patch` produces them.
|
||||
// Entries appear in patch order, oldest first as `git format-patch`
|
||||
// produces them.
|
||||
pub fn patch_commits(patch: &str) -> Vec<FileCommit> {
|
||||
Self::envelopes(patch)
|
||||
.into_iter()
|
||||
@@ -70,15 +62,11 @@ impl PatchParser {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Splits a `git format-patch` mbox into its messages by their envelopes,
|
||||
/// the one parser [`Self::split_patch_series`] and [`Self::patch_commits`] share.
|
||||
///
|
||||
/// A malformed input yields one message covering the whole input.
|
||||
// The one parser `split_patch_series` and `patch_commits` share.
|
||||
// A malformed input yields one message covering the whole input.
|
||||
fn envelopes(patch: &str) -> Vec<Envelope<'_>> {
|
||||
let mut messages: Vec<Envelope<'_>> = Vec::new();
|
||||
// Byte offset of the current envelope line, its commit id, and its headers.
|
||||
let mut current: Option<(usize, &str, Vec<&str>)> = None;
|
||||
// Headers run up to the blank line before the commit message.
|
||||
let mut headers_closed = false;
|
||||
|
||||
let mut offset = 0usize;
|
||||
@@ -86,7 +74,6 @@ impl PatchParser {
|
||||
let line_start = offset;
|
||||
offset += line.len() + 1;
|
||||
|
||||
// A message starts at its `From <id> <date>` envelope line.
|
||||
let is_envelope = line
|
||||
.strip_prefix("From ")
|
||||
.and_then(|rest| rest.split_whitespace().next())
|
||||
@@ -132,7 +119,6 @@ impl PatchParser {
|
||||
}
|
||||
|
||||
if messages.is_empty() {
|
||||
// Not an mbox at all: one message covering the whole input.
|
||||
messages.push(Envelope {
|
||||
text: patch,
|
||||
id: "",
|
||||
@@ -150,9 +136,7 @@ impl PatchParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip the patch prefix from a `Subject:` header.
|
||||
///
|
||||
/// Examples are `[PATCH]`, `[PATCH 1/2]` and `[RFC PATCH]`.
|
||||
// Matches `[PATCH]`, `[PATCH 1/2]`, `[RFC PATCH]`, etc.
|
||||
fn strip_patch_prefix(subject: &str) -> String {
|
||||
let trimmed = subject.trim();
|
||||
let Some(rest) = trimmed.strip_prefix('[') else {
|
||||
@@ -169,9 +153,9 @@ impl PatchParser {
|
||||
}
|
||||
|
||||
fn file_diff(file: FilePatch<'_, str>) -> Result<FileDiff> {
|
||||
// The `---`/`+++` paths carry the `a/`/`b/` prefix, so the first path
|
||||
// component is dropped, the same way `git apply -p1` does.
|
||||
// Rename and copy paths come from their own headers, unprefixed.
|
||||
// The `---`/`+++` paths carry the `a/`/`b/` prefix, dropped the same way
|
||||
// `git apply -p1` does; rename and copy paths come from their own
|
||||
// headers, unprefixed.
|
||||
let stripped;
|
||||
let operation = match file.operation() {
|
||||
operation @ (FileOperation::Rename { .. } | FileOperation::Copy { .. }) => operation,
|
||||
@@ -229,11 +213,8 @@ impl PatchParser {
|
||||
})
|
||||
}
|
||||
|
||||
/// The [`DiffHunk`] of one parsed hunk, including the line number of every line.
|
||||
///
|
||||
/// `diffy` reports only the hunk header ranges. The per-line numbers are
|
||||
/// counted from them the way the header encodes them: context lines advance
|
||||
/// both sides, deletions only the old, insertions only the new.
|
||||
// `diffy` reports only the hunk header ranges; the per-line numbers are
|
||||
// counted from them the way the header encodes them.
|
||||
fn hunk_diff(hunk: &Hunk<'_, str>) -> DiffHunk {
|
||||
let old_range = hunk.old_range();
|
||||
let new_range = hunk.new_range();
|
||||
@@ -285,22 +266,16 @@ impl PatchParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// The content of a parsed line without its line ending.
|
||||
///
|
||||
/// `diffy` keeps the trailing `\n`, the way `str::lines` splits it off.
|
||||
fn line_text(text: &str) -> String {
|
||||
let text = text.strip_suffix('\n').unwrap_or(text);
|
||||
text.strip_suffix('\r').unwrap_or(text).to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// A `git format-patch` mbox message, split on its `From <40-hex> <date>` envelope.
|
||||
// A `git format-patch` mbox message, split on its `From <40-hex> <date>` envelope.
|
||||
struct Envelope<'a> {
|
||||
/// The whole message, envelope and diff.
|
||||
text: &'a str,
|
||||
/// Commit id from the `From <id> <date>` line.
|
||||
id: &'a str,
|
||||
/// Header lines between the envelope and the commit message.
|
||||
headers: Vec<&'a str>,
|
||||
}
|
||||
|
||||
@@ -315,11 +290,8 @@ impl Envelope<'_> {
|
||||
}
|
||||
|
||||
impl Repo {
|
||||
/// Apply a `git format-patch` patch or series with `git am`.
|
||||
///
|
||||
/// Uses the git CLI because it handles the mbox format natively.
|
||||
///
|
||||
/// TODO: replace with a pure-Rust implementation later without changing callers.
|
||||
// The git CLI handles the mbox format natively.
|
||||
// TODO: replace with a pure-Rust implementation later without changing callers.
|
||||
pub fn apply_patch(&self, patch: &str) -> Result<()> {
|
||||
let workdir = self
|
||||
.inner
|
||||
@@ -349,10 +321,8 @@ impl Repo {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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.
|
||||
// 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(),
|
||||
|
||||
@@ -9,7 +9,6 @@ use gix::progress::Discard;
|
||||
use crate::repo::Repo;
|
||||
|
||||
impl Repo {
|
||||
/// Fetch all configured refspecs from `origin`, plus the `refs/nostr/*` namespace.
|
||||
pub fn fetch(&self) -> Result<()> {
|
||||
let options = gix::remote::ref_map::Options {
|
||||
extra_refspecs: vec![
|
||||
@@ -29,7 +28,6 @@ impl Repo {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Push `commit` to `reference` at `url`.
|
||||
pub fn push_ref(&self, url: &str, commit: &str, reference: &str) -> Result<()> {
|
||||
let output = Self::run_git(
|
||||
self.workdir_or_dot(),
|
||||
@@ -46,7 +44,6 @@ impl Repo {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Push the local `main` branch to a grasp server.
|
||||
pub fn push_main(&self, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
|
||||
self.push_refspecs(
|
||||
base_url,
|
||||
@@ -56,9 +53,6 @@ impl Repo {
|
||||
)
|
||||
}
|
||||
|
||||
/// Push every local branch and tag to a grasp server.
|
||||
///
|
||||
/// This mirrors an initialized repository's whole history.
|
||||
pub fn push_all(&self, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
|
||||
self.push_refspecs(
|
||||
base_url,
|
||||
@@ -93,12 +87,10 @@ impl Repo {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether `url` advertises every ref in `expected` at the given commit.
|
||||
///
|
||||
/// Extra advertised refs are ignored: the question is whether the data this
|
||||
/// push wanted to land is already there, not whether the remote is an exact mirror.
|
||||
/// This is the convergence probe for a push that lost the compare-and-swap race
|
||||
/// to the grasp server's own background ref alignment.
|
||||
// The convergence probe for a push that lost the compare-and-swap race to
|
||||
// the grasp server's own background ref alignment. Extra advertised refs
|
||||
// are ignored: the question is whether the pushed data is already there,
|
||||
// not whether the remote is an exact mirror.
|
||||
pub fn remote_has_refs(&self, url: &str, expected: &[(String, String)]) -> Result<bool> {
|
||||
if expected.is_empty() {
|
||||
return Ok(true);
|
||||
@@ -106,9 +98,8 @@ impl Repo {
|
||||
|
||||
let url = Self::transport_url(url);
|
||||
|
||||
// A URL-created remote has no configured fetch refspecs, and `ref_map` only
|
||||
// keeps refs that match one. Match each expected ref by its exact name,
|
||||
// like `git ls-remote <url> <name>` would; ref maps never write to the repository.
|
||||
// A URL-created remote has no configured fetch refspecs, and `ref_map`
|
||||
// only keeps refs matching one; match each expected ref by exact name.
|
||||
let refspecs = expected
|
||||
.iter()
|
||||
.map(|(name, _)| {
|
||||
@@ -135,9 +126,8 @@ impl Repo {
|
||||
.ref_map(Discard, options)
|
||||
.with_context(|| format!("listing refs of {url} failed"))?;
|
||||
|
||||
// Peeled tag entries carry the tag object in their direct oid, so mapping
|
||||
// each advertised ref to its direct oid matches `git ls-remote` while
|
||||
// skipping the duplicated `^{}` lines.
|
||||
// Peeled tag entries carry the tag object in their direct oid, matching
|
||||
// `git ls-remote` while skipping the duplicated `^{}` lines.
|
||||
let advertised: HashMap<String, String> = refs
|
||||
.remote_refs
|
||||
.iter()
|
||||
@@ -152,9 +142,6 @@ impl Repo {
|
||||
.all(|(name, oid)| advertised.get(name.as_str()) == Some(oid)))
|
||||
}
|
||||
|
||||
/// Add `origin` pointing at `url` when the repository has no remote yet.
|
||||
///
|
||||
/// No-op if `origin` already exists.
|
||||
pub fn ensure_origin(&self, url: &str) -> Result<()> {
|
||||
if self.inner.find_remote("origin").is_ok() {
|
||||
return Ok(());
|
||||
@@ -168,16 +155,12 @@ impl Repo {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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.
|
||||
// A working copy cloned from a local mirror is re-targeted at the
|
||||
// grasp server; a pre-existing fetch refspec is left untouched.
|
||||
pub fn set_origin(&self, url: &str) -> Result<()> {
|
||||
let had_origin = self.inner.find_remote("origin").is_ok();
|
||||
|
||||
self.edit_local_config(|config| {
|
||||
// Replaces the existing url, like `git remote set-url origin <url>`.
|
||||
// A pre-existing fetch refspec is left untouched.
|
||||
config.set_raw_value("remote.origin.url", url)?;
|
||||
|
||||
if !had_origin {
|
||||
@@ -189,9 +172,6 @@ impl Repo {
|
||||
})
|
||||
}
|
||||
|
||||
/// The URL of the `origin` remote.
|
||||
///
|
||||
/// `None` when the repository has no `origin` yet.
|
||||
pub fn origin_url(&self) -> Result<Option<String>> {
|
||||
let Ok(remote) = self.inner.find_remote("origin") else {
|
||||
return Ok(None);
|
||||
@@ -202,10 +182,8 @@ impl Repo {
|
||||
.map(|url| url.to_string()))
|
||||
}
|
||||
|
||||
/// Fetch `refspec` from the first working URL in `urls`.
|
||||
/// When no URL works, the last error is returned.
|
||||
///
|
||||
/// Never touches the checked-out refs or the worktree.
|
||||
// Never touches the checked-out refs or the worktree. The last error is
|
||||
// returned when no URL works.
|
||||
pub fn fetch_refs<U: AsRef<str>>(&self, urls: &[U], refspec: &str) -> Result<()> {
|
||||
let refspec = gix::refspec::parse(
|
||||
gix::bstr::BStr::new(refspec),
|
||||
@@ -247,7 +225,6 @@ impl Repo {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply `edit` to the repository-local configuration and persist it.
|
||||
pub(crate) fn edit_local_config(
|
||||
&self,
|
||||
edit: impl FnOnce(&mut gix::config::File) -> Result<()>,
|
||||
@@ -265,7 +242,6 @@ impl Repo {
|
||||
match gix::config::File::from_path_no_includes(config_path, gix::config::Source::Local)
|
||||
{
|
||||
Ok(config) => config,
|
||||
// A repository without a config file yet starts from scratch.
|
||||
Err(gix::config::file::init::from_paths::Error::Io { source, .. })
|
||||
if source.kind() == std::io::ErrorKind::NotFound =>
|
||||
{
|
||||
@@ -289,9 +265,6 @@ impl Repo {
|
||||
self.inner.workdir().unwrap_or_else(|| Path::new("."))
|
||||
}
|
||||
|
||||
/// Run `git -C dir args`, disabling the terminal prompt and capturing stderr.
|
||||
///
|
||||
/// `what` names the command in the spawn error.
|
||||
pub(crate) fn run_git(dir: &Path, args: &[&str], what: &str) -> Result<std::process::Output> {
|
||||
Self::git_command(dir)
|
||||
.args(args)
|
||||
@@ -300,7 +273,6 @@ impl Repo {
|
||||
.with_context(|| format!("failed to spawn `{what}`"))
|
||||
}
|
||||
|
||||
/// A `git -C dir` command with the terminal prompt disabled.
|
||||
pub(crate) fn git_command(dir: &Path) -> Command {
|
||||
let mut command = Command::new("git");
|
||||
command.arg("-C").arg(dir).env("GIT_TERMINAL_PROMPT", "0");
|
||||
|
||||
@@ -2,46 +2,34 @@ use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
/// In-memory object cache for history walks, see [`Repo::open_cached`].
|
||||
///
|
||||
/// Without one, a walk re-decodes the same commit objects from the object database.
|
||||
/// Sized generously: a walk can cover a large portion of the repository's history.
|
||||
// History walks re-decode the same commit objects without one; sized
|
||||
// generously, a walk can cover a large portion of the history.
|
||||
const OBJECT_CACHE_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
/// An opened git repository.
|
||||
///
|
||||
/// One `open` per operation instead of every helper re-opening by path.
|
||||
// One `open` per operation instead of every helper re-opening by path.
|
||||
pub struct Repo {
|
||||
pub(crate) inner: gix::Repository,
|
||||
}
|
||||
|
||||
impl Repo {
|
||||
/// Open the repository at `workdir`.
|
||||
pub fn open(workdir: &Path) -> Result<Self> {
|
||||
Ok(Self {
|
||||
inner: gix::open(workdir)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Open the repository at `workdir`, `None` when the path is not one.
|
||||
pub fn try_open(workdir: &Path) -> Option<Self> {
|
||||
Self::open(workdir).ok()
|
||||
}
|
||||
|
||||
/// Open the repository at `workdir` with an in-memory object cache.
|
||||
///
|
||||
/// Only history walks benefit from it, they re-decode the same commit
|
||||
/// objects repeatedly. Single-object reads open the repository plain.
|
||||
// Only history walks benefit from the object cache, they re-decode the
|
||||
// same commit objects repeatedly; single-object reads open plain.
|
||||
pub fn open_cached(workdir: &Path) -> Result<Self> {
|
||||
let mut repo = gix::open(workdir)?;
|
||||
repo.object_cache_size_if_unset(OBJECT_CACHE_BYTES);
|
||||
Ok(Self { inner: repo })
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn init(path: &Path, name: &str, description: &str) -> Result<String> {
|
||||
use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
|
||||
|
||||
@@ -53,8 +41,8 @@ impl Repo {
|
||||
let (signature, mut time_buf) = Self::repository_signature();
|
||||
let signature = signature.to_ref(&mut time_buf);
|
||||
|
||||
// The initial branch is `main`, regardless of `init.defaultBranch` in
|
||||
// the user's git configuration: point the unborn HEAD there.
|
||||
// The initial branch is `main` regardless of `init.defaultBranch`:
|
||||
// point the unborn HEAD there.
|
||||
let head = gix::refs::FullName::try_from("HEAD")
|
||||
.map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?;
|
||||
|
||||
@@ -107,17 +95,15 @@ impl Repo {
|
||||
Vec::<gix::ObjectId>::new(),
|
||||
)?;
|
||||
|
||||
// Populate the index so the fresh repository is clean,
|
||||
// as `git add` and `git commit` would leave it.
|
||||
// Populate the index so the fresh repository is clean, as `git add`
|
||||
// and `git commit` would leave it.
|
||||
let mut index = repo.index_from_tree(&tree)?;
|
||||
index.write(gix::index::write::Options::default())?;
|
||||
|
||||
Ok(commit.to_string())
|
||||
}
|
||||
|
||||
/// Clone into `path` from the first working URL in `clone_urls`.
|
||||
///
|
||||
/// Unlike [`crate::GitCache::ensure_clone`], the clone is not kept in any cache.
|
||||
// Not kept in any cache, unlike `GitCache::ensure_clone`.
|
||||
pub fn clone<U: AsRef<str>>(clone_urls: &[U], path: &Path) -> Result<Self> {
|
||||
if path.exists() {
|
||||
anyhow::bail!("destination {} already exists", path.display());
|
||||
@@ -128,7 +114,7 @@ impl Repo {
|
||||
for url in clone_urls {
|
||||
match Self::clone_from(url.as_ref(), path) {
|
||||
Ok(repo) => {
|
||||
// The initial clone uses the default refspecs. Also fetch the `refs/nostr/*` PR refs.
|
||||
// The default-refspec clone misses the `refs/nostr/*` PR refs.
|
||||
repo.fetch().ok();
|
||||
return Ok(repo);
|
||||
}
|
||||
@@ -159,39 +145,28 @@ impl Repo {
|
||||
&self.inner
|
||||
}
|
||||
|
||||
/// The worktree directory, `None` for a bare repository.
|
||||
pub fn workdir(&self) -> Option<&Path> {
|
||||
self.inner.workdir()
|
||||
}
|
||||
|
||||
/// The commit id HEAD points to.
|
||||
///
|
||||
/// `None` when the repository has no commits yet, an unborn HEAD.
|
||||
// `None` for an unborn HEAD.
|
||||
pub fn head(&self) -> Option<String> {
|
||||
self.inner.head_id().ok().map(|id| id.to_string())
|
||||
}
|
||||
|
||||
/// The merge base of two revisions,
|
||||
/// revisions may be branch names, remote-tracking refs or commit ids.
|
||||
///
|
||||
/// `Ok(None)` when the revisions share no common ancestor.
|
||||
///
|
||||
/// Unresolvable revisions are errors.
|
||||
// `Ok(None)` when the revisions share no common ancestor — a valid
|
||||
// outcome for a proposal; unresolvable revisions are errors.
|
||||
pub fn merge_base(&self, a: &str, b: &str) -> Result<Option<String>> {
|
||||
let a = self.inner.rev_parse_single(a.as_bytes())?;
|
||||
let b = self.inner.rev_parse_single(b.as_bytes())?;
|
||||
match self.inner.merge_base(a, b) {
|
||||
Ok(id) => Ok(Some(id.to_string())),
|
||||
// No common ancestor, a valid outcome for a proposal.
|
||||
Err(gix::repository::merge_base::Error::NotFound { .. }) => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The commits in `base..HEAD`, oldest first.
|
||||
/// This is the order `git am` creates them.
|
||||
///
|
||||
/// `HEAD` alone when `base` is `None`.
|
||||
// Oldest first: the order `git am` creates them.
|
||||
pub fn commits_since(&self, base: Option<&str>) -> Result<Vec<String>> {
|
||||
let head = match self.inner.head_id() {
|
||||
Ok(head) => head,
|
||||
@@ -218,16 +193,12 @@ impl Repo {
|
||||
commits.push(info?.id().to_string());
|
||||
}
|
||||
|
||||
// Oldest first, like `git rev-list --reverse`, the order `git am` creates them.
|
||||
commits.reverse();
|
||||
|
||||
Ok(commits)
|
||||
}
|
||||
|
||||
/// The earliest unique commit of the repository.
|
||||
/// Used as the NIP-34 announcement's `euc` marker.
|
||||
///
|
||||
/// `None` for a repository without commits.
|
||||
// The NIP-34 announcement's `euc` marker; `None` without commits.
|
||||
pub fn root_commit(&self) -> Result<Option<String>> {
|
||||
let Ok(head) = self.inner.head_id() else {
|
||||
return Ok(None);
|
||||
@@ -250,10 +221,6 @@ impl Repo {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// 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(&self, prefix: &str) -> Result<Vec<String>> {
|
||||
let pattern = prefix.trim_end_matches('/');
|
||||
let mut names = Vec::new();
|
||||
@@ -262,7 +229,7 @@ impl Repo {
|
||||
let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||
let name = String::from_utf8_lossy(reference.name().as_bstr()).into_owned();
|
||||
|
||||
// Match the pattern itself and everything beneath it, like `git for-each-ref`.
|
||||
// Match the pattern itself and everything beneath it.
|
||||
let under_pattern = name
|
||||
.strip_prefix(pattern)
|
||||
.is_some_and(|rest| rest.is_empty() || rest.starts_with('/'));
|
||||
@@ -277,8 +244,6 @@ impl Repo {
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// Delete every ref under `prefix`.
|
||||
/// `prefix` is a ref namespace like `refs/fork/<owner>/<id>`.
|
||||
pub fn delete_refs_with_prefix(&self, prefix: &str) -> Result<()> {
|
||||
use gix::refs::transaction::{Change, PreviousValue, RefEdit, RefLog};
|
||||
|
||||
@@ -308,8 +273,6 @@ impl Repo {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Short name of the branch HEAD points to,
|
||||
/// `None` when detached or unreadable, like `git branch --show-current`.
|
||||
pub fn current_branch(&self) -> Option<String> {
|
||||
let head = self.inner.head().ok()?;
|
||||
let name = head.referent_name()?;
|
||||
@@ -320,7 +283,6 @@ impl Repo {
|
||||
self.inner.find_reference(name).is_ok()
|
||||
}
|
||||
|
||||
/// Short names of local branches, `refs/heads/*`, sorted alphabetically.
|
||||
pub fn branches(&self) -> Result<Vec<String>> {
|
||||
let mut names = Vec::new();
|
||||
for reference in self.inner.references()?.local_branches()? {
|
||||
@@ -331,7 +293,6 @@ impl Repo {
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// Short names of tags, `refs/tags/*`, sorted alphabetically.
|
||||
pub fn tags(&self) -> Result<Vec<String>> {
|
||||
let mut names = Vec::new();
|
||||
for reference in self.inner.references()?.tags()? {
|
||||
@@ -342,9 +303,6 @@ impl Repo {
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// Branch, tag and HEAD refs of the repository.
|
||||
///
|
||||
/// Ready for a NIP-34 kind-30618 repository state announcement.
|
||||
pub fn ref_state(&self) -> Result<RepoRefState> {
|
||||
let mut refs = Vec::new();
|
||||
|
||||
@@ -376,9 +334,8 @@ impl Repo {
|
||||
Ok(RepoRefState { refs, head })
|
||||
}
|
||||
|
||||
/// Fast-forward local branches that trail their remote-tracking counterpart.
|
||||
///
|
||||
/// Returns whether any branch moved.
|
||||
// Fast-forward only: local-only commits or diverged history must never
|
||||
// be rewritten by a refresh. Returns whether any branch moved.
|
||||
pub fn fast_forward_branches(&self) -> Result<bool> {
|
||||
use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
|
||||
|
||||
@@ -400,7 +357,6 @@ impl Repo {
|
||||
};
|
||||
|
||||
let remote = format!("refs/remotes/origin/{branch}");
|
||||
// No remote-tracking counterpart means the remote lacks this branch.
|
||||
let Ok(mut remote_reference) = self.inner.find_reference(&remote) else {
|
||||
continue;
|
||||
};
|
||||
@@ -424,8 +380,7 @@ impl Repo {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only fast-forward.
|
||||
// Local-only commits or diverged history must never be rewritten by a refresh.
|
||||
// Only fast-forward: the base must be the local tip.
|
||||
let Ok(base) = self.inner.merge_base(local_oid, remote_oid) else {
|
||||
continue;
|
||||
};
|
||||
@@ -454,7 +409,6 @@ impl Repo {
|
||||
};
|
||||
|
||||
if current.as_deref() == Some(branch) {
|
||||
// Merge so the checked-out worktree follows the branch.
|
||||
// Only proceed on a clean worktree, like `git merge --ff-only`.
|
||||
if self.is_dirty() {
|
||||
continue;
|
||||
@@ -483,10 +437,8 @@ impl Repo {
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
/// The identity written to reflogs and commits created by this crate itself.
|
||||
///
|
||||
/// Like `git -c user.name=… -c user.email=…` per invocation: the repository works
|
||||
/// without a global git identity, and `gix` runs no hooks and never signs.
|
||||
// Like `git -c user.name=… -c user.email=…` per invocation: the repository
|
||||
// works without a global git identity, and `gix` runs no hooks and never signs.
|
||||
pub(crate) fn repository_signature() -> (gix::actor::Signature, gix::date::parse::TimeBuf) {
|
||||
let seconds = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -502,10 +454,8 @@ impl Repo {
|
||||
(signature, gix::date::parse::TimeBuf::default())
|
||||
}
|
||||
|
||||
/// Rewrite a grasp server URL to the https URL the git transport actually uses.
|
||||
///
|
||||
/// GRASP servers announce `grasp://<host>/<owner>/<repo>` clone URLs.
|
||||
/// The transport is git smart HTTP, so the scheme is rewritten for gix.
|
||||
// GRASP servers announce `grasp://` clone URLs but the transport is git
|
||||
// smart HTTP, so the scheme is rewritten for gix.
|
||||
pub(crate) fn transport_url(url: &str) -> String {
|
||||
url.strip_prefix("grasp://")
|
||||
.map(|rest| format!("https://{rest}"))
|
||||
@@ -513,11 +463,8 @@ impl Repo {
|
||||
}
|
||||
}
|
||||
|
||||
/// Branch, tag and HEAD refs of a repository.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RepoRefState {
|
||||
/// `(full refname, commit id)` pairs for heads and tags, sorted.
|
||||
pub refs: Vec<(String, String)>,
|
||||
/// Short branch name HEAD points to, or `None` when detached.
|
||||
pub head: Option<String>,
|
||||
}
|
||||
|
||||
@@ -5,18 +5,15 @@ use ignore::WalkBuilder;
|
||||
use crate::nip34::Nip34Binding;
|
||||
use crate::repo::Repo;
|
||||
|
||||
/// Caps nesting so pathological trees can't stall the scan.
|
||||
// Caps nesting so pathological trees can't stall the scan.
|
||||
const SCAN_MAX_DEPTH: usize = 12;
|
||||
|
||||
/// A git repository discovered under a scan root.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocalRepo {
|
||||
pub path: PathBuf,
|
||||
/// `None` for a plain repository.
|
||||
pub nip34: Option<Nip34Binding>,
|
||||
}
|
||||
|
||||
/// Walk `root` recursively and collect the git repositories below it.
|
||||
pub fn find_git_repos(root: &Path) -> Vec<LocalRepo> {
|
||||
if !root.is_dir() {
|
||||
return Vec::new();
|
||||
|
||||
@@ -7,7 +7,6 @@ use super::*;
|
||||
fn blocks_parent_components() {
|
||||
assert_eq!(GitCache::sanitize_path_component(".."), "_");
|
||||
assert_eq!(GitCache::sanitize_path_component("."), "_");
|
||||
// Separators are neutralized before the check, so these stay safe.
|
||||
assert_eq!(GitCache::sanitize_path_component("../.."), ".._..");
|
||||
assert_eq!(GitCache::sanitize_path_component("a/../b"), "a_.._b");
|
||||
}
|
||||
@@ -21,7 +20,6 @@ fn root_commit_reports_the_first_ancestor() {
|
||||
let root = repo.root_commit().expect("root").expect("commit");
|
||||
assert_eq!(root.len(), 40);
|
||||
|
||||
// The root commit does not change when history grows.
|
||||
std::fs::write(dir.join("b.txt"), b"two").expect("write");
|
||||
commit_all(&repo, "second");
|
||||
assert_eq!(
|
||||
@@ -32,8 +30,6 @@ fn root_commit_reports_the_first_ancestor() {
|
||||
|
||||
#[test]
|
||||
fn push_all_mirrors_branches_and_tags() {
|
||||
// A bare server repository reachable via a `file://` URL.
|
||||
// Mirrors a grasp server's `{base}/{owner}/{repo-id}.git` layout.
|
||||
let server = tempfile::tempdir().unwrap();
|
||||
let server_repo = bare_server(server.path(), "npub1test", "my-repo");
|
||||
|
||||
@@ -82,12 +78,9 @@ fn remote_has_refs_reports_whether_pushed_refs_landed() {
|
||||
|
||||
assert!(repo.remote_has_refs(&url, &expected).expect("probe"));
|
||||
|
||||
// A stale expectation - the exact race a retry resolves - is false.
|
||||
let stale = vec![("refs/heads/main".to_owned(), "0".repeat(40))];
|
||||
assert!(!repo.remote_has_refs(&url, &stale).expect("probe"));
|
||||
|
||||
// Extra remote refs (e.g. a tag pushed later) do not invalidate the
|
||||
// refs this push wanted to land.
|
||||
git_run(dir, &["tag", "v1.0"]);
|
||||
repo.push_all(
|
||||
&format!("file://{}", server.path().display()),
|
||||
@@ -137,7 +130,6 @@ fn repo_ref_state_lists_branches_tags_and_head() {
|
||||
assert_eq!(state.refs.len(), 3);
|
||||
}
|
||||
|
||||
/// Build a throwaway non-bare repository from `(rel, bytes)` file pairs.
|
||||
fn fixture(files: &[(&str, &[u8])]) -> (tempfile::TempDir, Repo) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
gix::init(&dir).expect("init");
|
||||
@@ -152,8 +144,6 @@ fn fixture(files: &[(&str, &[u8])]) -> (tempfile::TempDir, Repo) {
|
||||
(dir, repo)
|
||||
}
|
||||
|
||||
/// Stage everything and create a commit with the git CLI.
|
||||
/// Like [`Repo::apply_patch`], the crate already shells out to the CLI.
|
||||
fn commit_all(repo: &Repo, message: &str) {
|
||||
git_run(repo.workdir().expect("workdir"), &["add", "-A"]);
|
||||
git_run(repo.workdir().expect("workdir"), &["commit", "-m", message]);
|
||||
@@ -166,8 +156,6 @@ fn merge_base_finds_the_fork_point_and_reports_unrelated_history() {
|
||||
let initial = Repo::init(&path, "My Repo", "desc").expect("init");
|
||||
let repo = Repo::open(&path).expect("open");
|
||||
|
||||
// A feature branch and a mainline commit diverge from the initial commit.
|
||||
// The initial commit is their merge base.
|
||||
git_run(&path, &["checkout", "-b", "feature"]);
|
||||
std::fs::write(path.join("feature.txt"), "feature\n").expect("write");
|
||||
commit_all(&repo, "feature commit");
|
||||
@@ -182,13 +170,11 @@ fn merge_base_finds_the_fork_point_and_reports_unrelated_history() {
|
||||
Some(initial.as_str())
|
||||
);
|
||||
|
||||
// An orphan branch shares no history with main, so `Ok(None)`.
|
||||
git_run(&path, &["checkout", "--orphan", "orphan"]);
|
||||
std::fs::write(path.join("orphan.txt"), "orphan\n").expect("write");
|
||||
commit_all(&repo, "orphan commit");
|
||||
assert_eq!(repo.merge_base("orphan", "main").expect("ok"), None);
|
||||
|
||||
// An unresolvable revision is an error, not a missing ancestor.
|
||||
assert!(repo.merge_base("orphan", "no-such-ref").is_err());
|
||||
}
|
||||
|
||||
@@ -213,7 +199,6 @@ fn split_patch_series_splits_real_multi_commit_mboxes() {
|
||||
assert_eq!(parts.len(), 2);
|
||||
assert!(parts[0].contains("Subject: [PATCH 1/2] first commit"));
|
||||
assert!(parts[1].contains("Subject: [PATCH 2/2] second commit"));
|
||||
// Each part starts its own mbox message with its own commit id.
|
||||
let first = parts[0].lines().next().expect("first header");
|
||||
let second = parts[1].lines().next().expect("second header");
|
||||
assert!(first.starts_with("From ") && first.len() >= 45);
|
||||
@@ -228,7 +213,6 @@ fn head_commit_and_commits_since_track_applied_commits() {
|
||||
let repo = Repo::open(&path).expect("open");
|
||||
|
||||
assert_eq!(repo.head().as_deref(), Some(initial.as_str()));
|
||||
// No base given, `HEAD` alone.
|
||||
assert_eq!(
|
||||
repo.commits_since(None).expect("commits"),
|
||||
vec![initial.clone()]
|
||||
@@ -242,7 +226,6 @@ fn head_commit_and_commits_since_track_applied_commits() {
|
||||
commit_all(&repo, "second commit");
|
||||
let second = repo.head().expect("on a branch");
|
||||
|
||||
// Oldest first, like the order `git am` creates them.
|
||||
assert_eq!(
|
||||
repo.commits_since(Some(&initial)).expect("commits"),
|
||||
vec![first.clone(), second.clone()]
|
||||
@@ -255,10 +238,6 @@ fn head_commit_and_commits_since_track_applied_commits() {
|
||||
|
||||
#[test]
|
||||
fn working_copy_cloned_from_the_mirror_matches_head_and_origin() {
|
||||
// The mirror is a freshly initialized repository, standing in for
|
||||
// the grasp server. Its `origin` points at the (fake) grasp server.
|
||||
// `GitCache::ensure_clone` lazily clones from a URL shaped like this
|
||||
// the first time a repository is opened.
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let mirror = dir.path().join("mirror");
|
||||
let commit = Repo::init(&mirror, "My Repo", "Does things.").expect("init");
|
||||
@@ -267,9 +246,6 @@ fn working_copy_cloned_from_the_mirror_matches_head_and_origin() {
|
||||
.ensure_origin("https://gitnostr.com/npub1test/my-repo.git")
|
||||
.expect("origin");
|
||||
|
||||
// The working copy is cloned from the mirror.
|
||||
// It then shares the announced history exactly.
|
||||
// `origin` is re-pointed at the grasp server instead of the mirror path.
|
||||
let destination = dir.path().join("folder").join("My_Repo");
|
||||
std::fs::create_dir_all(destination.parent().unwrap()).expect("parent");
|
||||
Repo::clone(&[format!("file://{}", mirror.display())], &destination).expect("clone");
|
||||
@@ -289,7 +265,6 @@ fn working_copy_cloned_from_the_mirror_matches_head_and_origin() {
|
||||
|
||||
#[test]
|
||||
fn fast_forward_branches_moves_the_mirror_and_keeps_local_work() {
|
||||
// A bare server, like a grasp server's `{base}/{owner}/{repo}.git` layout.
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
bare_server(dir.path(), "npub1test", "repo");
|
||||
|
||||
@@ -301,7 +276,6 @@ fn fast_forward_branches_moves_the_mirror_and_keeps_local_work() {
|
||||
.push_all(&base_url, "npub1test", "repo")
|
||||
.expect("push");
|
||||
|
||||
// A mirror clone, like the app's GitCache clones.
|
||||
let mirror = dir.path().join("mirror");
|
||||
git_run(
|
||||
dir.path(),
|
||||
@@ -315,8 +289,6 @@ fn fast_forward_branches_moves_the_mirror_and_keeps_local_work() {
|
||||
let initial = git_in(&mirror, &["rev-parse", "HEAD"]).expect("initial");
|
||||
let repo = Repo::open(&mirror).expect("open");
|
||||
|
||||
// The owner pushes a new commit.
|
||||
// The mirror fetches it, but its local `main` and worktree stay behind.
|
||||
std::fs::write(work.join("new.txt"), b"new\n").expect("write");
|
||||
commit_all(&work_repo, "new commit");
|
||||
work_repo
|
||||
@@ -330,8 +302,6 @@ fn fast_forward_branches_moves_the_mirror_and_keeps_local_work() {
|
||||
);
|
||||
assert_ne!(remote, initial);
|
||||
|
||||
// Fast-forwarding catches the branch and its worktree up.
|
||||
// The second call has nothing left to move.
|
||||
assert!(repo.fast_forward_branches().expect("ff"));
|
||||
assert_eq!(
|
||||
git_in(&mirror, &["rev-parse", "HEAD"]).expect("local"),
|
||||
@@ -340,7 +310,6 @@ fn fast_forward_branches_moves_the_mirror_and_keeps_local_work() {
|
||||
assert!(mirror.join("new.txt").is_file());
|
||||
assert!(!repo.fast_forward_branches().expect("idle"));
|
||||
|
||||
// A branch with local commits of its own is never touched.
|
||||
git_run(&mirror, &["checkout", "-b", "wip"]);
|
||||
std::fs::write(mirror.join("wip.txt"), b"wip\n").expect("write");
|
||||
commit_all(&repo, "local wip");
|
||||
@@ -356,8 +325,6 @@ fn fast_forward_branches_moves_the_mirror_and_keeps_local_work() {
|
||||
fn fetch_repo_refs_imports_heads_under_a_prefix() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
// A bare base server holding the initial commit.
|
||||
// Like a grasp server's `{base}/{owner}/{repo-id}.git` layout.
|
||||
let base_server = bare_server(dir.path(), "npub1base", "base");
|
||||
|
||||
let (upstream_dir, upstream_repo) = fixture(&[("a.txt", b"one")]);
|
||||
@@ -380,8 +347,6 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() {
|
||||
);
|
||||
let mirror_repo = Repo::open(&mirror).expect("open");
|
||||
|
||||
// The fork server has the same initial commit.
|
||||
// It also carries a feature commit on its own `feature` branch.
|
||||
let fork_work = dir.path().join("fork-work");
|
||||
git_run(
|
||||
dir.path(),
|
||||
@@ -402,8 +367,6 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() {
|
||||
)
|
||||
.expect("push");
|
||||
|
||||
// Import the fork's heads into the mirror under a private prefix.
|
||||
// The first dead URL is skipped, the second works.
|
||||
let dead = format!("file://{}/missing.git", dir.path().display());
|
||||
mirror_repo
|
||||
.fetch_refs(
|
||||
@@ -418,7 +381,6 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() {
|
||||
.expect("refs"),
|
||||
vec!["refs/fork/npub1fork/fork/feature"]
|
||||
);
|
||||
// Nothing leaked into the normal ref namespaces.
|
||||
assert_eq!(
|
||||
mirror_repo
|
||||
.refs_with_prefix("refs/heads/fork")
|
||||
@@ -426,8 +388,6 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() {
|
||||
Vec::<String>::new()
|
||||
);
|
||||
|
||||
// The mirror can now range across both histories.
|
||||
// The fork point is the shared initial commit, the proposal covers the fork commit.
|
||||
assert_eq!(
|
||||
mirror_repo
|
||||
.merge_base(
|
||||
@@ -455,7 +415,6 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Run a git command in `dir` with a fixed test identity, asserting success.
|
||||
fn git_run(dir: &Path, args: &[&str]) -> std::process::Output {
|
||||
let output = Command::new("git")
|
||||
.current_dir(dir)
|
||||
@@ -471,7 +430,6 @@ fn git_run(dir: &Path, args: &[&str]) -> std::process::Output {
|
||||
output
|
||||
}
|
||||
|
||||
/// Create a bare `{base}/{owner}/{name}.git` repository, like a grasp server.
|
||||
fn bare_server(base: &Path, owner: &str, name: &str) -> PathBuf {
|
||||
let repo = base.join(owner).join(format!("{name}.git"));
|
||||
std::fs::create_dir_all(repo.parent().expect("parent")).expect("mkdir");
|
||||
@@ -531,10 +489,6 @@ diff --git a/b.txt b/b.txt
|
||||
|
||||
#[test]
|
||||
fn parses_real_format_patch_output() {
|
||||
// Build a commit touching a mix of file kinds.
|
||||
// Feed genuine `git format-patch` output through the parser.
|
||||
// It covers quoted and octal-escaped paths.
|
||||
// There are also a rename-free modification, an addition and a binary deletion.
|
||||
let (dir, repo) = fixture(&[
|
||||
("src/main.rs", b"fn main() {\n println!(\"one\");\n}\n"),
|
||||
("my file.txt", b"hello\n"),
|
||||
@@ -570,12 +524,10 @@ fn parses_real_format_patch_output() {
|
||||
.unwrap_or_else(|| panic!("missing file {path:?}"))
|
||||
};
|
||||
|
||||
// Space in the name makes git quote the path in the header.
|
||||
let file = by_path("my file.txt");
|
||||
assert_eq!(file.status, DiffStatus::Modified);
|
||||
assert_eq!(file.insertions, 1);
|
||||
|
||||
// UTF-8 names are emitted as octal escapes.
|
||||
let file = by_path("\u{8bf4}\u{660e}.md");
|
||||
assert_eq!(file.status, DiffStatus::Modified);
|
||||
assert_eq!(file.insertions, 1);
|
||||
@@ -590,8 +542,6 @@ fn parses_real_format_patch_output() {
|
||||
assert_eq!(file.status, DiffStatus::Added);
|
||||
assert_eq!(file.insertions, 1);
|
||||
|
||||
// A binary deletion emits no `---` or `+++` lines.
|
||||
// Only the mode line and the `Binary files` marker remain.
|
||||
let file = by_path("img.png");
|
||||
assert_eq!(file.status, DiffStatus::Deleted);
|
||||
assert!(file.binary);
|
||||
|
||||
@@ -8,19 +8,17 @@ use crate::history::FileCommit;
|
||||
use crate::repo::Repo;
|
||||
|
||||
impl Repo {
|
||||
/// Whether the worktree has uncommitted changes.
|
||||
///
|
||||
/// Best-effort: any read failure is reported as clean.
|
||||
// Best-effort: any read failure is reported as clean.
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
// Changes to tracked files, staged or not; untracked files are excluded.
|
||||
// Tracked files, staged or not; untracked files are handled below.
|
||||
match self.inner.is_dirty() {
|
||||
Ok(true) => return true,
|
||||
Ok(false) => {}
|
||||
Err(_) => return false,
|
||||
}
|
||||
|
||||
// Untracked files surface as `DirectoryContents` items of the index-vs-worktree walk,
|
||||
// tracked files only appear there when modified.
|
||||
// Untracked files surface as `DirectoryContents` items of the
|
||||
// index-vs-worktree walk; tracked files only appear there when modified.
|
||||
let Ok(platform) = self.inner.status(Discard) else {
|
||||
return false;
|
||||
};
|
||||
@@ -41,9 +39,7 @@ impl Repo {
|
||||
false
|
||||
}
|
||||
|
||||
/// Commits in `base..branch`.
|
||||
///
|
||||
/// Best-effort: 0 when the range cannot be computed.
|
||||
// Best-effort: 0 when the range cannot be computed.
|
||||
pub fn commits_ahead(&self, base: &str, branch: &str) -> u32 {
|
||||
let (Some(base), Some(branch)) = (self.resolve_commit(base), self.resolve_commit(branch))
|
||||
else {
|
||||
@@ -57,15 +53,13 @@ impl Repo {
|
||||
walk.filter_map(Result::ok).count().min(u32::MAX as usize) as u32
|
||||
}
|
||||
|
||||
/// Resolve `rev` to a commit id, accepting full refs or the bare branch names
|
||||
/// callers pass. `gix`'s revision parser already applies git's ref DWIM.
|
||||
// Accepts full refs or the bare branch names callers pass; `gix`'s
|
||||
// revision parser already applies git's ref DWIM.
|
||||
fn resolve_commit<'a>(&'a self, rev: &str) -> Option<gix::Id<'a>> {
|
||||
self.inner.rev_parse_single(rev.as_bytes()).ok()
|
||||
}
|
||||
|
||||
/// Relative paths of all entries in the worktree, files and directories.
|
||||
///
|
||||
/// The `.git` directory is skipped.
|
||||
// The `.git` directory is skipped.
|
||||
pub fn entries(&self) -> Result<Vec<PathBuf>> {
|
||||
let workdir = self.inner.workdir().context("repository has no worktree")?;
|
||||
|
||||
@@ -80,9 +74,7 @@ impl Repo {
|
||||
Ok(entries.into_iter().map(|(path, _)| path).collect())
|
||||
}
|
||||
|
||||
/// Read a file from the worktree.
|
||||
///
|
||||
/// Returns `Ok(None)` if the path is missing or not a regular file.
|
||||
// `Ok(None)` when the path is missing or not a regular file.
|
||||
pub fn read(&self, rel: &Path) -> Result<Option<Vec<u8>>> {
|
||||
let workdir = self.inner.workdir().context("repository has no worktree")?;
|
||||
let path = workdir.join(rel);
|
||||
@@ -95,9 +87,7 @@ impl Repo {
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the README file in the repository root.
|
||||
///
|
||||
/// Falls back to any other file whose name starts with `readme`.
|
||||
// Falls back to any other file whose name starts with `readme`.
|
||||
pub fn find_readme(&self) -> Result<Option<PathBuf>> {
|
||||
let Some(workdir) = self.inner.workdir() else {
|
||||
return Ok(None);
|
||||
@@ -133,7 +123,6 @@ impl Repo {
|
||||
.and_then(|path| path.strip_prefix(workdir).ok().map(Path::to_path_buf)))
|
||||
}
|
||||
|
||||
/// Everything the browser needs to refresh after a branch or tag switch.
|
||||
pub fn snapshot(&self) -> Result<WorktreeSnapshot> {
|
||||
let readme_path = self.find_readme()?;
|
||||
let readme = match &readme_path {
|
||||
@@ -151,7 +140,7 @@ impl Repo {
|
||||
})
|
||||
}
|
||||
|
||||
/// Check out the local branch `name`, HEAD stays attached to it.
|
||||
// HEAD stays attached to the branch.
|
||||
pub fn checkout_branch(&self, name: &str) -> Result<()> {
|
||||
let full = format!("refs/heads/{name}");
|
||||
|
||||
@@ -175,7 +164,7 @@ impl Repo {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check out the tag `name`, HEAD becomes detached at the tagged commit.
|
||||
// HEAD becomes detached at the tagged commit.
|
||||
pub fn checkout_tag(&self, name: &str) -> Result<()> {
|
||||
let full = format!("refs/tags/{name}");
|
||||
|
||||
@@ -207,8 +196,8 @@ impl Repo {
|
||||
|
||||
let mut index = self.inner.index_from_tree(tree)?;
|
||||
|
||||
// Files the previous index tracked but `tree` no longer contains are removed,
|
||||
// like git deleting files that vanish between branches.
|
||||
// Files the previous index tracked but `tree` no longer contains are
|
||||
// removed, like git deleting files that vanish between branches.
|
||||
if let Ok(previous) = self.inner.index_or_empty() {
|
||||
let keep: HashSet<PathBuf> = index
|
||||
.entries()
|
||||
@@ -262,7 +251,6 @@ impl Repo {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Point `HEAD` at `target` and record the switch in the reflog.
|
||||
fn move_head(
|
||||
&self,
|
||||
signature: gix::actor::SignatureRef<'_>,
|
||||
@@ -294,7 +282,6 @@ impl Repo {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Relative paths of all entries below `dir`, relative to `root`.
|
||||
fn collect_entries(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, bool)>) -> Result<()> {
|
||||
for entry in std::fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
@@ -315,20 +302,13 @@ impl Repo {
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the browser needs to refresh after a branch or tag switch.
|
||||
pub struct WorktreeSnapshot {
|
||||
/// Relative paths of all worktree entries, directories first.
|
||||
pub entries: Vec<PathBuf>,
|
||||
/// README path relative to the worktree, if any.
|
||||
pub readme_path: Option<PathBuf>,
|
||||
/// Contents of the README, if any.
|
||||
pub readme: Option<Vec<u8>>,
|
||||
/// Branch HEAD points to, `None` when detached, for example on a tag.
|
||||
// `None` when detached, for example on a tag.
|
||||
pub current_branch: Option<String>,
|
||||
/// Commit HEAD points to, if any, see [`Repo::head_commit`].
|
||||
pub head_commit: Option<FileCommit>,
|
||||
/// Short names of local branches, sorted alphabetically.
|
||||
pub branches: Vec<String>,
|
||||
/// Short names of tags, sorted alphabetically.
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user