From d212f0fa90486465c4b171135ab470be8d98f7ed Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 7 Oct 2026 09:16:16 +0700 Subject: [PATCH] merge remote module into repo --- crates/signed_git/src/cache.rs | 12 +- crates/signed_git/src/diff.rs | 268 ++---- crates/signed_git/src/history.rs | 206 +---- crates/signed_git/src/lib.rs | 2 +- crates/signed_git/src/nip34.rs | 430 +-------- crates/signed_git/src/patch.rs | 180 ++-- crates/signed_git/src/remote.rs | 285 ------ crates/signed_git/src/repo.rs | 1382 ++++++++++++++++++++++++++++- crates/signed_git/src/scan.rs | 28 +- crates/signed_git/src/tests.rs | 21 + crates/signed_git/src/worktree.rs | 296 +----- 11 files changed, 1615 insertions(+), 1495 deletions(-) delete mode 100644 crates/signed_git/src/remote.rs diff --git a/crates/signed_git/src/cache.rs b/crates/signed_git/src/cache.rs index 667e63f..d55ddea 100644 --- a/crates/signed_git/src/cache.rs +++ b/crates/signed_git/src/cache.rs @@ -11,30 +11,34 @@ pub struct GitCache { } impl GitCache { + /// Creates a cache rooted at `root`. pub fn new(root: PathBuf) -> Self { Self { root } } + /// Returns the cache root directory. pub fn root(&self) -> &Path { &self.root } + /// Returns the cache path for a repository address. pub fn repo_path(&self, addr: &RepoAddr) -> PathBuf { self.root .join(addr.public_key().to_hex()) .join(Self::sanitize_path_component(addr.identifier())) } + /// Opens the cached repository, or `None` when it is not cloned yet. pub fn open(&self, addr: &RepoAddr) -> Result> { let path = self.repo_path(addr); match gix::open(&path) { - Ok(repo) => Ok(Some(Repo { inner: repo })), - // A missing path and a directory that is no repository are both classified `NotFound`. + Ok(repo) => Ok(Some(Repo::new(repo))), Err(e) if e.is_not_found() => Ok(None), Err(e) => Err(e.into()), } } + /// Returns the cached repository, cloning from one of `clone_urls` on first use. pub fn ensure_clone>(&self, addr: &RepoAddr, clone_urls: &[U]) -> Result { let path = self.repo_path(addr); @@ -51,8 +55,7 @@ impl GitCache { Repo::clone(clone_urls, &path) } - // `id` is untrusted relay content: it must never escape the cache root as - // a single path component. + /// Maps untrusted relay content onto a safe single path component. pub fn sanitize_path_component(id: &str) -> String { let sanitized: String = id .chars() @@ -72,6 +75,7 @@ impl GitCache { sanitized } + /// Returns the `owner/identifier` namespace used for fork checkouts. pub fn fork_namespace(announcement: &Announcement) -> String { format!( "{}/{}", diff --git a/crates/signed_git/src/diff.rs b/crates/signed_git/src/diff.rs index 17a5fc8..c1ad2e9 100644 --- a/crates/signed_git/src/diff.rs +++ b/crates/signed_git/src/diff.rs @@ -1,9 +1,5 @@ -use anyhow::Result; use gix::diff::blob::unified_diff::{ConsumeHunk, DiffLineKind as GixLineKind, HunkHeader}; -use crate::GixResultExt as _; -use crate::repo::Repo; - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DiffLineKind { Context, @@ -19,6 +15,18 @@ pub struct DiffLine { pub text: String, } +impl DiffLine { + /// Creates a diff line with its kind, optional line numbers, and text. + pub fn new(kind: DiffLineKind, old: Option, new: Option, text: String) -> Self { + Self { + kind, + old, + new, + text, + } + } +} + #[derive(Debug, Clone)] pub struct DiffHunk { pub old_start: u32, @@ -28,6 +36,25 @@ pub struct DiffHunk { pub lines: Vec, } +impl DiffHunk { + /// Creates a hunk spanning the given old and new ranges. + pub fn new( + old_start: u32, + old_lines: u32, + new_start: u32, + new_lines: u32, + lines: Vec, + ) -> Self { + Self { + old_start, + old_lines, + new_start, + new_lines, + lines, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DiffStatus { Added, @@ -39,183 +66,75 @@ pub enum DiffStatus { #[derive(Debug, Clone)] pub struct FileDiff { - // For renames and copies, the destination path. pub path: String, pub old_path: Option, pub status: DiffStatus, pub insertions: usize, pub deletions: usize, - // Binary files have empty `hunks`. pub binary: bool, pub hunks: Vec, } +impl FileDiff { + /// Creates a file diff; `old_path` is the rename or copy source and `binary` files have no hunks. + pub fn new( + path: String, + old_path: Option, + status: DiffStatus, + insertions: usize, + deletions: usize, + binary: bool, + hunks: Vec, + ) -> Self { + Self { + path, + old_path, + status, + insertions, + deletions, + binary, + hunks, + } + } +} + #[derive(Debug, Clone)] pub struct CommitDiff { pub files: Vec, } -impl Repo { - // Compared against the first parent; the empty tree for the root commit. - pub fn commit_diff(&self, id: &str) -> Result { - let commit_id = self.inner.rev_parse_single(id.as_bytes())?; - let commit = commit_id.object()?.into_commit(); - let new_tree = commit.tree()?; - let old_tree = match commit.parent_ids().next() { - Some(parent) => Some(parent.object()?.into_commit().tree()?), - None => None, - }; - Self::tree_diff(self, old_tree.as_ref(), &new_tree) - } - - // Directories and submodules are skipped, files are sorted by path. - pub fn range_diff(&self, base: &str, tip: &str) -> Result { - let base_tree = self - .inner - .rev_parse_single(base.as_bytes())? - .object()? - .into_commit() - .tree()?; - let tip_tree = self - .inner - .rev_parse_single(tip.as_bytes())? - .object()? - .into_commit() - .tree()?; - Self::tree_diff(self, Some(&base_tree), &tip_tree) - } - - fn tree_diff( - repo: &Repo, - old_tree: Option<&gix::Tree<'_>>, - new_tree: &gix::Tree<'_>, - ) -> Result { - use gix::diff::blob::platform::prepare_diff::Operation; - use gix::object::tree::diff::Change; - use gix::objs::tree::EntryKind; - - let changes = repo - .inner - .diff_tree_to_tree(old_tree, Some(new_tree), None)?; - - let mut cache = repo.inner.diff_resource_cache_for_tree_diff()?; - let mut files = Vec::new(); - - for change in changes { - let attached = Change::from_change_ref(change.to_ref(), &repo.inner, &repo.inner); - - let (path, old_path, status) = match attached { - Change::Addition { - location, - entry_mode, - .. - } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => { - (location.to_owned(), None, DiffStatus::Added) - } - Change::Deletion { - location, - entry_mode, - .. - } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => { - (location.to_owned(), None, DiffStatus::Deleted) - } - Change::Modification { - location, - previous_entry_mode, - entry_mode, - .. - } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) - && !matches!( - previous_entry_mode.kind(), - EntryKind::Tree | EntryKind::Commit - ) => - { - (location.to_owned(), None, DiffStatus::Modified) - } - Change::Rewrite { - location, - source_location, - source_entry_mode, - entry_mode, - copy, - .. - } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) - && !matches!( - source_entry_mode.kind(), - EntryKind::Tree | EntryKind::Commit - ) => - { - let status = if copy { - DiffStatus::Copied - } else { - DiffStatus::Renamed - }; - ( - location.to_owned(), - Some(source_location.to_owned()), - status, - ) - } - _ => continue, - }; - - // External diff drivers would shell out, out of scope for a read-only viewer. - let platform = attached.diff(&mut cache)?; - platform - .resource_cache - .options - .skip_internal_diff_if_external_is_configured = true; - let outcome = platform.resource_cache.prepare_diff().into_anyhow()?; - - let (binary, hunks, insertions, deletions) = match outcome.operation { - Operation::InternalDiff { algorithm } => { - let input = outcome.interned_input(); - let diff = gix::diff::blob::diff_with_slider_heuristics(algorithm, &input); - - let mut hunks = Vec::new(); - let mut insertions = 0usize; - let mut deletions = 0usize; - let collector = HunkCollector { - hunks: &mut hunks, - insertions: &mut insertions, - deletions: &mut deletions, - }; - gix::diff::blob::UnifiedDiff::new(&diff, &input, collector, Default::default()) - .consume()?; - (false, hunks, insertions, deletions) - } - Operation::SourceOrDestinationIsBinary => (true, Vec::new(), 0, 0), - Operation::ExternalCommand { .. } => { - unreachable!("external diff drivers are disabled") - } - }; - - files.push(FileDiff { - path: String::from_utf8_lossy(&path).into_owned(), - old_path: old_path.map(|p| String::from_utf8_lossy(&p).into_owned()), - status, - insertions, - deletions, - binary, - hunks, - }); - } - - files.sort_by(|a, b| a.path.cmp(&b.path)); - - Ok(CommitDiff { files }) +impl CommitDiff { + /// Creates a commit diff from its files. + pub fn new(files: Vec) -> Self { + Self { files } } } -struct HunkCollector<'a> { +pub(crate) struct HunkCollector<'a> { hunks: &'a mut Vec, insertions: &'a mut usize, deletions: &'a mut usize, } +impl<'a> HunkCollector<'a> { + /// Collects hunks into `hunks` while counting `insertions` and `deletions`. + pub(crate) fn new( + hunks: &'a mut Vec, + insertions: &'a mut usize, + deletions: &'a mut usize, + ) -> Self { + Self { + hunks, + insertions, + deletions, + } + } +} + impl ConsumeHunk for HunkCollector<'_> { type Out = (); + /// Consumes one hunk, tracking line numbers and insertion/deletion counts. fn consume_hunk( &mut self, header: HunkHeader, @@ -229,35 +148,21 @@ impl ConsumeHunk for HunkCollector<'_> { let text = String::from_utf8_lossy(content).into_owned(); let line = match kind { GixLineKind::Context => { - let line = DiffLine { - kind: DiffLineKind::Context, - old: Some(old_ln), - new: Some(new_ln), - text, - }; + let line = + DiffLine::new(DiffLineKind::Context, Some(old_ln), Some(new_ln), text); old_ln += 1; new_ln += 1; line } GixLineKind::Remove => { *self.deletions += 1; - let line = DiffLine { - kind: DiffLineKind::Deletion, - old: Some(old_ln), - new: None, - text, - }; + let line = DiffLine::new(DiffLineKind::Deletion, Some(old_ln), None, text); old_ln += 1; line } GixLineKind::Add => { *self.insertions += 1; - let line = DiffLine { - kind: DiffLineKind::Addition, - old: None, - new: Some(new_ln), - text, - }; + let line = DiffLine::new(DiffLineKind::Addition, None, Some(new_ln), text); new_ln += 1; line } @@ -265,16 +170,17 @@ impl ConsumeHunk for HunkCollector<'_> { out.push(line); } - self.hunks.push(DiffHunk { - old_start: header.before_hunk_start, - old_lines: header.before_hunk_len, - new_start: header.after_hunk_start, - new_lines: header.after_hunk_len, - lines: out, - }); + self.hunks.push(DiffHunk::new( + header.before_hunk_start, + header.before_hunk_len, + header.after_hunk_start, + header.after_hunk_len, + out, + )); Ok(()) } + /// Marks the end of the unified diff stream. fn finish(self) {} } diff --git a/crates/signed_git/src/history.rs b/crates/signed_git/src/history.rs index cc8adeb..3babe2b 100644 --- a/crates/signed_git/src/history.rs +++ b/crates/signed_git/src/history.rs @@ -1,10 +1,16 @@ -use std::collections::HashSet; -use std::path::{Path, PathBuf}; +pub const MAX_LISTED_COMMITS: usize = 20_000; -use anyhow::Result; +pub struct CommitList { + pub total: usize, + pub commits: Vec, +} -use crate::GixResultExt as _; -use crate::repo::Repo; +impl CommitList { + /// Creates a list holding `total` commits of which `commits` are materialized. + pub fn new(total: usize, commits: Vec) -> Self { + Self { total, commits } + } +} #[derive(Debug, Clone)] pub struct FileCommit { @@ -16,27 +22,47 @@ pub struct FileCommit { } impl FileCommit { - fn from_commit(commit: &gix::Commit<'_>) -> Result { + /// Creates a commit entry from its id, message parts, author, and timestamp. + pub fn new( + id: String, + summary: String, + description: Option, + author: String, + time: i64, + ) -> Self { + Self { + id, + summary, + description, + author, + time, + } + } + + /// Converts a gix commit including its message body. + pub(crate) fn from_commit(commit: &gix::Commit<'_>) -> anyhow::Result { Self::from_commit_with_description(commit, true) } - // History lists never display the body, - // skipping it saves an allocation per listed commit. - fn from_commit_summary(commit: &gix::Commit<'_>) -> Result { + /// Converts a gix commit, skipping the message body since lists never show it. + pub(crate) fn from_commit_summary(commit: &gix::Commit<'_>) -> anyhow::Result { Self::from_commit_with_description(commit, false) } + /// Converts a gix commit, optionally skipping the message body. fn from_commit_with_description( commit: &gix::Commit<'_>, include_description: bool, - ) -> Result { + ) -> anyhow::Result { + use crate::GixResultExt as _; + let author = commit.author().into_anyhow()?; let message = commit.message().into_anyhow()?; - Ok(FileCommit { - id: commit.id().shorten_or_id().to_string(), - summary: String::from_utf8_lossy(message.title).trim().to_string(), - description: if include_description { + Ok(FileCommit::new( + commit.id().shorten_or_id().to_string(), + String::from_utf8_lossy(message.title).trim().to_string(), + if include_description { message .body .map(|body| String::from_utf8_lossy(body).trim().to_string()) @@ -44,154 +70,8 @@ impl FileCommit { } else { None }, - author: String::from_utf8_lossy(author.name).trim().to_string(), - time: author.time().into_anyhow()?.seconds, - }) + String::from_utf8_lossy(author.name).trim().to_string(), + author.time().into_anyhow()?.seconds, + )) } } - -impl Repo { - // Paths without any commit, like untracked files, are absent from the result. - pub fn last_commits(&self, rels: &[PathBuf]) -> Result> { - use gix::traverse::commit::simple::CommitTimeOrder; - - let Some(head) = self.inner.head_id().ok() else { - return Ok(Vec::new()); - }; - - let mut pending: Vec = 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 = self - .inner - .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 tree = commit.tree()?; - let parent_tree = match info.parent_ids().next() { - Some(parent) => Some(parent.object()?.into_commit().tree()?), - None => None, - }; - - // Compare each unresolved path against this commit and its first parent. - 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(), FileCommit::from_commit(&commit)?)); - pending.swap_remove(ix); - } else { - ix += 1; - } - } - } - - Ok(found) - } - - pub fn all_commits(&self) -> Result { - use gix::traverse::commit::simple::CommitTimeOrder; - - let Some(head) = self.inner.head_id().ok() else { - return Ok(CommitList { - total: 0, - commits: Vec::new(), - }); - }; - - let walk = self - .inner - .rev_walk([head]) - .sorting(gix::revision::walk::Sorting::ByCommitTime( - CommitTimeOrder::NewestFirst, - )); - - let mut commits = Vec::new(); - let mut total = 0; - - for info in walk.all()? { - let info = info?; - total += 1; - if commits.len() < MAX_LISTED_COMMITS { - commits.push(FileCommit::from_commit_summary(&info.object()?)?); - } - } - - Ok(CommitList { total, commits }) - } - - pub fn commit_range(&self, base: &str, tip: &str) -> Result> { - use gix::traverse::commit::simple::CommitTimeOrder; - - let base_id = self.inner.rev_parse_single(base.as_bytes())?; - let tip_id = self.inner.rev_parse_single(tip.as_bytes())?; - let walk = self - .inner - .rev_walk([tip_id]) - .sorting(gix::revision::walk::Sorting::ByCommitTime( - CommitTimeOrder::NewestFirst, - )) - .with_hidden([base_id]); - - let mut commits = Vec::new(); - - for info in walk.all()? { - let info = info?; - commits.push(FileCommit::from_commit_summary(&info.object()?)?); - } - - Ok(commits) - } - - // `Ok(None)` for an unborn HEAD. - pub fn head_commit(&self) -> Result> { - let Some(head) = self.inner.head_id().ok() else { - return Ok(None); - }; - let commit = head.object()?.into_commit(); - Ok(Some(FileCommit::from_commit(&commit)?)) - } - - // `Ok(None)` when the id cannot be resolved. - pub fn commit(&self, id: &str) -> Result> { - match self.inner.rev_parse_single(id.as_bytes()) { - Ok(commit_id) => { - let commit = commit_id.object()?.into_commit(); - Ok(Some(FileCommit::from_commit(&commit)?)) - } - Err(_) => Ok(None), - } - } -} - -// 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; - -pub struct CommitList { - pub total: usize, - pub commits: Vec, -} diff --git a/crates/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs index 2e30a24..feaceca 100644 --- a/crates/signed_git/src/lib.rs +++ b/crates/signed_git/src/lib.rs @@ -3,7 +3,6 @@ mod diff; mod history; mod nip34; mod patch; -mod remote; mod repo; mod scan; mod worktree; @@ -21,6 +20,7 @@ pub use scan::{LocalRepo, find_git_repos}; pub use worktree::WorktreeSnapshot; pub(crate) trait GixResultExt { + /// Converts a gix exception result into an `anyhow` result. fn into_anyhow(self) -> anyhow::Result; } diff --git a/crates/signed_git/src/nip34.rs b/crates/signed_git/src/nip34.rs index 7b67d49..d8f422e 100644 --- a/crates/signed_git/src/nip34.rs +++ b/crates/signed_git/src/nip34.rs @@ -1,18 +1,15 @@ -use anyhow::Result; -use gix::bstr::ByteSlice; use nostr::prelude::*; -use crate::GixResultExt as _; -use crate::repo::Repo; +#[derive(serde::Deserialize)] +pub(crate) struct Nip34Json { + pub(crate) identifier: Option, + pub(crate) owner: Option, +} -/// The kind of NIP-34 relationship a local repository has on disk. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Nip34Kind { - // By `nak`'s `nip34.json` or `ngit`'s `nostr.repo`. Initialized, - // Cloned from a `nostr://` remote but never initialized locally. Cloned, - // Nostr tooling touched the repository but no binding is recoverable. ToolingOnly, } @@ -23,7 +20,6 @@ pub struct GraspSignals { pub nostr_repo_config: bool, pub nostr_remote: bool, pub grasp_remote: bool, - // `nak`-specific `nip34/grasp/` remote name. pub nip34_grasp_remote: bool, pub nip34_state_refs: bool, pub nostr_cache: bool, @@ -32,430 +28,36 @@ pub struct GraspSignals { } impl GraspSignals { + /// Returns whether any Nostr tooling marker was found. pub fn any(&self) -> bool { *self != Self::default() } } -/// What a local repository's on-disk state says about its NIP-34 binding. #[derive(Debug, Clone, PartialEq)] pub struct Nip34Binding { pub kind: Nip34Kind, pub signals: GraspSignals, - // From `nip34.json` or `nostr.repo`. pub owner: Option, pub identifier: Option, pub grasp_urls: Vec, } -#[derive(serde::Deserialize)] -struct Nip34Json { - identifier: Option, - owner: Option, -} - -impl Repo { - // `None` when no Nostr tooling left a marker. - pub fn nip34_binding(&self) -> Option { - let common_dir = self.inner.common_dir().to_path_buf(); - let workdir = self.inner.workdir().map(std::path::Path::to_path_buf); - - let mut signals = GraspSignals::default(); - let mut owner: Option = None; - let mut identifier: Option = None; - let mut grasp_urls: Vec = Vec::new(); - - if let Some(workdir) = &workdir { - if let Ok(bytes) = std::fs::read(workdir.join("nip34.json")) - && let Ok(config) = serde_json::from_slice::(&bytes) - { - signals.nip34_json = true; - identifier = config.identifier.and_then(Self::non_empty); - owner = config - .owner - .as_deref() - .and_then(|value| PublicKey::parse(value).ok()); - } - - if workdir.join("maintainers.yaml").is_file() { - signals.maintainers_yaml = true; - } - } - - if let Ok(exclude) = std::fs::read_to_string(common_dir.join("info/exclude")) - && exclude.contains("nip34.json") - { - signals.nip34_excluded = true; - } - - // `ngit` keeps its repository event cache in the Git common directory. - if common_dir.join("nostr-cache.lmdb").is_file() { - signals.nostr_cache = true; - } - - // `ngit` reads and writes `nostr.repo` at repository-local scope only. - if let Ok(config) = gix::config::File::from_path_no_includes( - common_dir.join("config"), - gix::config::Source::Local, - ) { - if let Some(value) = config.string("nostr.repo") - && let Some((key, id)) = Self::coordinate_from_naddr(&value.to_str_lossy()) - { - signals.nostr_repo_config = true; - owner = Some(key); - identifier = Some(id); - } - - for key in ["nostr.repo-relay-only", "nostr.nostate", "nostr.private"] { - if config.string(key).is_some() { - signals.nostr_aux_config = true; - } - } - - if let Some(sections) = config.sections_by_name("remote") { - for section in sections { - let Some(name) = section.header().subsection_name() else { - continue; - }; - let nak_grasp_remote = name.to_str_lossy().starts_with("nip34/grasp/"); - - for url in section.values("url") { - let url = url.to_str_lossy(); - - if url.starts_with("nostr://") { - signals.nostr_remote = true; - // Strong markers win; only fill an empty binding. - if owner.is_none() - && identifier.is_none() - && let Some((key, id)) = Self::parse_nostr_url(&url) - { - owner = Some(key); - identifier = Some(id); - } - } - - if Self::is_grasp_url(&url) { - signals.grasp_remote = true; - signals.nip34_grasp_remote |= nak_grasp_remote; - grasp_urls.push(url.to_string()); - - if owner.is_none() - && identifier.is_none() - && let Some((key, id)) = Self::grasp_parts(&url) - { - owner = Some(key); - identifier = Some(id); - } - } - } - } - } - } - - // `nak` materializes a kind-30618 state as `refs/heads/nip34/state/*`. - if let Ok(platform) = self.inner.references() - && let Ok(mut refs) = platform.prefixed(b"refs/heads/nip34/state/") - && refs.next().is_some() - { - signals.nip34_state_refs = true; - } - - if !signals.any() { - return None; - } - - let kind = if signals.nip34_json - || signals.nostr_repo_config - || signals.nip34_grasp_remote - || signals.nip34_state_refs - { - Nip34Kind::Initialized - } else if signals.nostr_remote { - Nip34Kind::Cloned - } else { - Nip34Kind::ToolingOnly - }; - - Some(Nip34Binding { +impl Nip34Binding { + /// Creates a binding from its kind, detected signals, and recovered coordinates. + pub fn new( + kind: Nip34Kind, + signals: GraspSignals, + owner: Option, + identifier: Option, + grasp_urls: Vec, + ) -> Self { + Self { kind, signals, owner, identifier, grasp_urls, - }) - } - - pub fn set_nostr_repo(&self, naddr: &str) -> Result<()> { - self.edit_local_config(|config| { - config.set_raw_value("nostr.repo", naddr).into_anyhow()?; - Ok(()) - }) - } - - // 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; - }; - - if !matches!(parsed.scheme(), "http" | "https" | "grasp") { - return false; } - - let path = parsed.path(); - if path.matches('/').count() != 2 || path.len() < 65 { - return false; - } - - Self::grasp_parts(url).is_some() - } - - fn grasp_parts(url: &str) -> Option<(PublicKey, String)> { - let parsed = Url::parse(url).ok()?; - let mut segments = parsed.path_segments()?.filter(|part| !part.is_empty()); - - let owner = PublicKey::parse(segments.next()?).ok()?; - let identifier = Self::non_empty(segments.next()?.trim_end_matches(".git"))?; - - Some((owner, identifier)) - } - - fn coordinate_from_naddr(value: &str) -> Option<(PublicKey, String)> { - let coordinate = Nip19Coordinate::from_bech32(value).ok()?; - if coordinate.kind != Kind::GitRepoAnnouncement { - return None; - } - - let identifier = Self::non_empty(coordinate.identifier.clone())?; - 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. - fn parse_nostr_url(url: &str) -> Option<(PublicKey, String)> { - let rest = url.strip_prefix("nostr://")?; - - if rest.starts_with("naddr1") { - return Self::coordinate_from_naddr(rest); - } - - let rest = rest.rsplit_once('@').map_or(rest, |(_, after)| after); - let mut parts: Vec<&str> = rest.split('/').filter(|part| !part.is_empty()).collect(); - - if parts - .first() - .is_some_and(|first| matches!(*first, "ssh" | "https" | "http")) - { - parts.remove(0); - } - - if parts.len() < 2 { - return None; - } - - let owner = PublicKey::parse(parts[0]).ok()?; - let identifier = Self::non_empty(parts.last()?.trim_end_matches(".git"))?; - - Some((owner, identifier)) - } - - fn non_empty(value: impl Into) -> Option { - let value = value.into(); - (!value.is_empty()).then_some(value) - } -} - -#[cfg(test)] -mod tests { - use std::path::Path; - use std::process::Command; - - use super::*; - - fn init_repo() -> (tempfile::TempDir, std::path::PathBuf) { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("repo"); - std::fs::create_dir_all(&path).expect("mkdir"); - git(&path, &["init", "-q"]); - (dir, path) - } - - fn git(dir: &Path, args: &[&str]) { - let status = Command::new("git") - .current_dir(dir) - .env("GIT_AUTHOR_NAME", "Test Author") - .env("GIT_AUTHOR_EMAIL", "test@example.com") - .env("GIT_COMMITTER_NAME", "Test Author") - .env("GIT_COMMITTER_EMAIL", "test@example.com") - .env("GIT_EDITOR", "true") - .args(args) - .status() - .expect("spawn git"); - assert!(status.success(), "git {args:?} failed"); - } - - fn key() -> PublicKey { - Keys::generate().public_key() - } - - fn naddr(kind: Kind, owner: PublicKey, identifier: &str) -> String { - let coordinate = Coordinate::new(kind, owner).identifier(identifier); - Nip19Coordinate::new(coordinate, Vec::::new()) - .to_bech32() - .expect("naddr") - } - - fn binding_of(path: &Path) -> Option { - Repo::open(path).ok()?.nip34_binding() - } - - #[test] - fn plain_repository_has_no_binding() { - let (_dir, path) = init_repo(); - assert!(binding_of(&path).is_none()); - } - - #[test] - fn nip34_json_marks_a_repository_initialized() { - let (_dir, path) = init_repo(); - let owner = key(); - let npub = owner.to_bech32().expect("npub"); - std::fs::write( - path.join("nip34.json"), - format!(r#"{{"identifier":"my-repo","owner":"{npub}"}}"#), - ) - .expect("write"); - - let binding = binding_of(&path).expect("binding"); - assert_eq!(binding.kind, Nip34Kind::Initialized); - assert!(binding.signals.nip34_json); - assert_eq!(binding.owner, Some(owner)); - assert_eq!(binding.identifier.as_deref(), Some("my-repo")); - } - - #[test] - fn malformed_nip34_json_is_ignored() { - let (_dir, path) = init_repo(); - std::fs::write(path.join("nip34.json"), b"not json").expect("write"); - - assert!(binding_of(&path).is_none()); - } - - #[test] - fn nak_exclude_and_state_refs_are_detected() { - let (_dir, path) = init_repo(); - - std::fs::create_dir_all(path.join(".git/info")).expect("mkdir"); - std::fs::write(path.join(".git/info/exclude"), "nip34.json\n").expect("write"); - - git(&path, &["commit", "-q", "--allow-empty", "-m", "initial"]); - git( - &path, - &["update-ref", "refs/heads/nip34/state/HEAD", "HEAD"], - ); - - let binding = binding_of(&path).expect("binding"); - assert_eq!(binding.kind, Nip34Kind::Initialized); - assert!(binding.signals.nip34_excluded); - assert!(binding.signals.nip34_state_refs); - } - - #[test] - fn nostr_repo_config_marks_a_repository_initialized() { - let (_dir, path) = init_repo(); - let owner = key(); - let naddr = naddr(Kind::GitRepoAnnouncement, owner, "my-repo"); - git(&path, &["config", "nostr.repo", &naddr]); - - let binding = binding_of(&path).expect("binding"); - assert_eq!(binding.kind, Nip34Kind::Initialized); - assert!(binding.signals.nostr_repo_config); - assert_eq!(binding.owner, Some(owner)); - assert_eq!(binding.identifier.as_deref(), Some("my-repo")); - } - - #[test] - fn the_written_nostr_repo_marker_is_detected() { - let (_dir, path) = init_repo(); - let owner = key(); - let naddr = naddr(Kind::GitRepoAnnouncement, owner, "my-repo"); - - Repo::open(&path) - .expect("open") - .set_nostr_repo(&naddr) - .expect("write marker"); - - let binding = binding_of(&path).expect("binding"); - assert_eq!(binding.kind, Nip34Kind::Initialized); - assert!(binding.signals.nostr_repo_config); - assert_eq!(binding.owner, Some(owner)); - assert_eq!(binding.identifier.as_deref(), Some("my-repo")); - } - - #[test] - fn nostr_remote_is_a_nip34_clone() { - let (_dir, path) = init_repo(); - let owner = key(); - let npub = owner.to_bech32().expect("npub"); - let url = format!("nostr://{npub}/relay.ngit.dev/my-repo"); - git(&path, &["remote", "add", "origin", &url]); - - let binding = binding_of(&path).expect("binding"); - assert_eq!(binding.kind, Nip34Kind::Cloned); - assert!(binding.signals.nostr_remote); - assert_eq!(binding.owner, Some(owner)); - assert_eq!(binding.identifier.as_deref(), Some("my-repo")); - } - - #[test] - fn nak_grasp_remote_marks_a_repository_initialized() { - let (_dir, path) = init_repo(); - let owner = key(); - let npub = owner.to_bech32().expect("npub"); - let url = format!("https://gitnostr.com/{npub}/my-repo.git"); - git( - &path, - &["config", "remote.nip34/grasp/gitnostr.com.url", &url], - ); - - let binding = binding_of(&path).expect("binding"); - assert_eq!(binding.kind, Nip34Kind::Initialized); - assert!(binding.signals.nip34_grasp_remote); - assert!(binding.signals.grasp_remote); - assert_eq!(binding.grasp_urls, vec![url]); - assert_eq!(binding.owner, Some(owner)); - assert_eq!(binding.identifier.as_deref(), Some("my-repo")); - } - - #[test] - fn nostr_cache_alone_is_tooling_only() { - let (_dir, path) = init_repo(); - std::fs::write(path.join(".git/nostr-cache.lmdb"), b"cache").expect("write"); - - let binding = binding_of(&path).expect("binding"); - assert_eq!(binding.kind, Nip34Kind::ToolingOnly); - assert!(binding.signals.nostr_cache); - } - - #[test] - fn grasp_urls_are_recognised_by_shape() { - let owner = key(); - let npub = owner.to_bech32().expect("npub"); - - assert!(Repo::is_grasp_url(&format!( - "https://gitnostr.com/{npub}/my-repo.git" - ))); - assert!(Repo::is_grasp_url(&format!( - "grasp://gitnostr.com/{npub}/my-repo.git" - ))); - - assert!(!Repo::is_grasp_url("https://gitnostr.com/my-repo.git")); - assert!(!Repo::is_grasp_url( - "https://gitnostr.com/not-a-pubkey/my-repo.git" - )); - assert!(!Repo::is_grasp_url(&format!( - "ssh://gitnostr.com/{npub}/my-repo.git" - ))); } } diff --git a/crates/signed_git/src/patch.rs b/crates/signed_git/src/patch.rs index bb0b543..b07931b 100644 --- a/crates/signed_git/src/patch.rs +++ b/crates/signed_git/src/patch.rs @@ -1,17 +1,14 @@ -use std::io::Write; -use std::process::Stdio; - -use anyhow::{Context, Result, bail}; +use anyhow::Result; use diffy::patch_set::{FileOperation, FilePatch, ParseOptions, PatchSet}; use diffy::{Hunk, Line}; use crate::diff::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileDiff}; use crate::history::FileCommit; -use crate::repo::Repo; pub struct PatchParser; impl PatchParser { + /// Splits an mbox series into the raw text of each message. pub fn split_patch_series(patch: &str) -> Vec<&str> { Self::envelopes(patch) .into_iter() @@ -19,44 +16,49 @@ impl PatchParser { .collect() } + /// Parses a git diff into file diffs, returning an empty diff without one. pub fn patch_diffs(patch: &str) -> Result { if !patch.lines().any(|line| line.starts_with("diff --git ")) { - return Ok(CommitDiff { files: Vec::new() }); + return Ok(CommitDiff::new(Vec::new())); } let mut files = Vec::new(); for file in PatchSet::parse(patch, ParseOptions::gitdiff()) { - files.push(Self::file_diff(file?)?); + files.push(Self::file_diff(file?)); } - Ok(CommitDiff { files }) + Ok(CommitDiff::new(files)) } + /// Lists the commits represented by the mbox messages that carry an id. pub fn patch_commits(patch: &str) -> Vec { Self::envelopes(patch) .into_iter() .filter(|message| !message.id.is_empty()) - .map(|message| FileCommit { - id: message.id.to_string(), - summary: message - .header("Subject") - .map(Self::strip_patch_prefix) - .unwrap_or_default(), - description: None, - author: message - .header("From") - .map(Self::name_from_address) - .unwrap_or_default(), - time: message - .header("Date") - .and_then(|value| gix::date::parse(value.trim(), None).ok()) - .map(|time| time.seconds) - .unwrap_or(0), + .map(|message| { + FileCommit::new( + message.id.to_string(), + message + .header("Subject") + .map(Self::strip_patch_prefix) + .unwrap_or_default(), + None, + message + .header("From") + .map(Self::name_from_address) + .unwrap_or_default(), + message + .header("Date") + .and_then(|value| gix::date::parse(value.trim(), None).ok()) + .map(|time| time.seconds) + .unwrap_or(0), + ) }) .collect() } + /// Splits `patch` on `From <40-hex> ` envelopes, falling back to one message. fn envelopes(patch: &str) -> Vec> { let mut messages: Vec> = Vec::new(); let mut current: Option<(usize, &str, Vec<&str>)> = None; @@ -74,11 +76,7 @@ impl PatchParser { if is_envelope { if let Some((start, id, headers)) = current.take() { - messages.push(Envelope { - text: &patch[start..], - id, - headers, - }); + messages.push(Envelope::new(&patch[start..], id, headers)); } let id = line @@ -104,24 +102,17 @@ impl PatchParser { } if let Some((start, id, headers)) = current.take() { - messages.push(Envelope { - text: &patch[start..], - id, - headers, - }); + messages.push(Envelope::new(&patch[start..], id, headers)); } if messages.is_empty() { - messages.push(Envelope { - text: patch, - id: "", - headers: Vec::new(), - }); + messages.push(Envelope::new(patch, "", Vec::new())); } messages } + /// Extracts the display name from a `From` header value. fn name_from_address(from: &str) -> String { match from.trim().find('<') { Some(ix) => from[..ix].trim().to_string(), @@ -129,7 +120,7 @@ impl PatchParser { } } - /// Matches `[PATCH]`, `[PATCH 1/2]`, `[RFC PATCH]`, etc. + /// Strips mailing list prefixes like `[PATCH]` or `[RFC PATCH 1/2]`. fn strip_patch_prefix(subject: &str) -> String { let trimmed = subject.trim(); let Some(rest) = trimmed.strip_prefix('[') else { @@ -145,7 +136,8 @@ impl PatchParser { } } - fn file_diff(file: FilePatch<'_, str>) -> Result { + /// Converts one parsed file patch, keeping rename and copy paths unstripped. + fn file_diff(file: FilePatch<'_, str>) -> FileDiff { let stripped; let operation = match file.operation() { operation @ (FileOperation::Rename { .. } | FileOperation::Copy { .. }) => operation, @@ -192,17 +184,18 @@ impl PatchParser { } } - Ok(FileDiff { - path: path.to_owned(), - old_path: old_path.map(str::to_owned), + FileDiff::new( + path.to_owned(), + old_path.map(str::to_owned), status, insertions, deletions, - binary: patch.is_binary(), + patch.is_binary(), hunks, - }) + ) } + /// Converts a diffy hunk into a `DiffHunk` with per-line old and new numbers. fn hunk_diff(hunk: &Hunk<'_, str>) -> DiffHunk { let old_range = hunk.old_range(); let new_range = hunk.new_range(); @@ -237,37 +230,38 @@ impl PatchParser { } }; - lines.push(DiffLine { - kind, - old: old_no, - new: new_no, - text: text - .strip_suffix('\n') - .unwrap_or(text) - .strip_suffix('\r') - .unwrap_or(text) - .to_owned(), - }); + let text = text + .strip_suffix('\n') + .unwrap_or(text) + .strip_suffix('\r') + .unwrap_or(text); + + lines.push(DiffLine::new(kind, old_no, new_no, text.to_owned())); } - DiffHunk { - old_start: old_range.start() as u32, - old_lines: old_range.len() as u32, - new_start: new_range.start() as u32, - new_lines: new_range.len() as u32, + DiffHunk::new( + old_range.start() as u32, + old_range.len() as u32, + new_range.start() as u32, + new_range.len() as u32, lines, - } + ) } } -/// A `git format-patch` mbox message, split on its `From <40-hex> ` envelope. struct Envelope<'a> { text: &'a str, id: &'a str, headers: Vec<&'a str>, } -impl Envelope<'_> { +impl<'a> Envelope<'a> { + /// Creates an envelope from its text, commit id, and header lines. + fn new(text: &'a str, id: &'a str, headers: Vec<&'a str>) -> Self { + Self { text, id, headers } + } + + /// Returns the first header line matching `name: `. fn header(&self, name: &str) -> Option<&str> { let prefix = format!("{name}: "); self.headers @@ -276,59 +270,3 @@ impl Envelope<'_> { .map(|line| &line[prefix.len()..]) } } - -impl Repo { - pub fn apply_patch(&self, patch: &str) -> Result<()> { - let workdir = self - .inner - .workdir() - .context("repository has no worktree")? - .to_path_buf(); - - let mut child = Repo::git_command(&workdir) - .args(["am"]) - .stdin(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .context("failed to spawn `git am`")?; - - child - .stdin - .as_mut() - .context("git am has no stdin pipe")? - .write_all(patch.as_bytes())?; - - let output = child.wait_with_output()?; - - if !output.status.success() { - bail!("git am failed: {}", String::from_utf8_lossy(&output.stderr)); - } - - Ok(()) - } - - // 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 { - let output = Repo::run_git( - self.workdir_or_dot(), - &["format-patch", "--stdout", &format!("{base}..{tip}")], - "git format-patch", - )?; - - if !output.status.success() { - bail!( - "git format-patch failed: {}", - String::from_utf8_lossy(&output.stderr).trim() - ); - } - - let patch = String::from_utf8_lossy(&output.stdout).into_owned(); - - if patch.trim().is_empty() { - bail!("no commits between {base} and {tip}"); - } - - Ok(patch) - } -} diff --git a/crates/signed_git/src/remote.rs b/crates/signed_git/src/remote.rs deleted file mode 100644 index a1445fa..0000000 --- a/crates/signed_git/src/remote.rs +++ /dev/null @@ -1,285 +0,0 @@ -use std::collections::HashMap; -use std::path::Path; -use std::process::{Command, Stdio}; - -use anyhow::{Context, Result, bail}; -use gix::interrupt::IS_INTERRUPTED; -use gix::progress::Discard; - -use crate::GixResultExt as _; -use crate::repo::Repo; - -impl Repo { - pub fn fetch(&self) -> Result<()> { - let options = gix::remote::ref_map::Options { - extra_refspecs: vec![ - gix::refspec::parse( - gix::bstr::BStr::new("+refs/nostr/*:refs/nostr/*"), - gix::refspec::parse::Operation::Fetch, - ) - .into_anyhow()? - .to_owned(), - ], - ..Default::default() - }; - self.inner - .find_remote("origin")? - .connect(gix::remote::Direction::Fetch)? - .prepare_fetch(Discard, options)? - .receive(Discard, &IS_INTERRUPTED)?; - Ok(()) - } - - pub fn push_ref(&self, url: &str, commit: &str, reference: &str) -> Result<()> { - let output = Self::run_git( - self.workdir_or_dot(), - &["push", url, &format!("{commit}:{reference}")], - "git push", - )?; - - if !output.status.success() { - bail!( - "git push failed: {}", - String::from_utf8_lossy(&output.stderr).trim() - ); - } - Ok(()) - } - - pub fn push_main(&self, base_url: &str, owner: &str, repo_id: &str) -> Result<()> { - self.push_refspecs( - base_url, - owner, - repo_id, - &["refs/heads/main:refs/heads/main"], - ) - } - - pub fn push_all(&self, base_url: &str, owner: &str, repo_id: &str) -> Result<()> { - self.push_refspecs( - base_url, - owner, - repo_id, - &["refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"], - ) - } - - fn push_refspecs( - &self, - base_url: &str, - owner: &str, - repo_id: &str, - refspecs: &[&str], - ) -> Result<()> { - let url = format!("{base_url}/{owner}/{repo_id}.git"); - - let mut args: Vec<&str> = Vec::with_capacity(refspecs.len() + 2); - args.push("push"); - args.push(&url); - args.extend_from_slice(refspecs); - - let output = Self::run_git(self.workdir_or_dot(), &args, "git push")?; - - if !output.status.success() { - bail!( - "git push to {base_url} failed: {}", - String::from_utf8_lossy(&output.stderr).trim() - ); - } - Ok(()) - } - - pub fn remote_has_refs(&self, url: &str, expected: &[(String, String)]) -> Result { - if expected.is_empty() { - return Ok(true); - } - - let url = Self::transport_url(url); - - let refspecs = expected - .iter() - .map(|(name, _)| { - gix::refspec::parse( - gix::bstr::BStr::new(format!("+{name}:{name}").as_bytes()), - gix::refspec::parse::Operation::Fetch, - ) - .map(|spec| spec.to_owned()) - }) - .collect::, _>>() - .into_anyhow() - .context("invalid refspec")?; - - let options = gix::remote::ref_map::Options { - extra_refspecs: refspecs, - ..Default::default() - }; - - let (refs, _) = self - .inner - .remote_at(url.as_str()) - .with_context(|| format!("cannot use remote {url}"))? - .connect(gix::remote::Direction::Fetch) - .with_context(|| format!("cannot connect to {url}"))? - .ref_map(Discard, options) - .with_context(|| format!("listing refs of {url} failed"))?; - - // Peeled tag entries carry the tag object in their direct oid, - // matching `git ls-remote` while skipping the duplicated `^{}` lines. - let advertised: HashMap = refs - .remote_refs - .iter() - .filter_map(|reference| { - let (name, object, _peeled) = reference.unpack(); - object.map(|oid| (String::from_utf8_lossy(name).into_owned(), oid.to_string())) - }) - .collect(); - - Ok(expected - .iter() - .all(|(name, oid)| advertised.get(name.as_str()) == Some(oid))) - } - - pub fn ensure_origin(&self, url: &str) -> Result<()> { - if self.inner.find_remote("origin").is_ok() { - return Ok(()); - } - - // `git remote add` also configures the default fetch refspec. - self.edit_local_config(|config| { - config - .set_raw_value("remote.origin.url", url) - .into_anyhow()?; - config - .set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*") - .into_anyhow()?; - Ok(()) - }) - } - - // 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| { - config - .set_raw_value("remote.origin.url", url) - .into_anyhow()?; - - if !had_origin { - config - .set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*") - .into_anyhow()?; - } - - Ok(()) - }) - } - - pub fn origin_url(&self) -> Result> { - let Ok(remote) = self.inner.find_remote("origin") else { - return Ok(None); - }; - - Ok(remote - .url(gix::remote::Direction::Fetch) - .map(|url| url.to_string())) - } - - pub fn fetch_refs>(&self, urls: &[U], refspec: &str) -> Result<()> { - let refspec = gix::refspec::parse( - gix::bstr::BStr::new(refspec), - gix::refspec::parse::Operation::Fetch, - ) - .into_anyhow() - .context("invalid fetch refspec")? - .to_owned(); - - let mut last_error = None; - - for url in urls { - let url = Self::transport_url(url.as_ref()); - let outcome = (|| -> Result<()> { - let options = gix::remote::ref_map::Options { - extra_refspecs: vec![refspec.clone()], - ..Default::default() - }; - self.inner - .remote_at(url.as_str()) - .with_context(|| format!("fetch from {url} failed"))? - .connect(gix::remote::Direction::Fetch) - .with_context(|| format!("fetch from {url} failed"))? - .prepare_fetch(Discard, options) - .with_context(|| format!("fetch from {url} failed"))? - .receive(Discard, &IS_INTERRUPTED) - .with_context(|| format!("fetch from {url} failed"))?; - Ok(()) - })(); - - match outcome { - Ok(()) => return Ok(()), - Err(error) => last_error = Some(error), - } - } - - match last_error { - Some(error) => Err(error).context("failed to fetch from any mirror"), - None => bail!("no fetch URLs provided"), - } - } - - pub(crate) fn edit_local_config( - &self, - edit: impl FnOnce(&mut gix::config::File) -> Result<()>, - ) -> Result<()> { - let config_path = self.inner.common_dir().join("config"); - - let mut lock = gix::lock::File::acquire_to_update_resource( - &config_path, - gix::lock::acquire::Fail::Immediately, - None, - ) - .into_anyhow() - .context("failed to lock repository config")?; - - let mut config = - match gix::config::File::from_path_no_includes(config_path, gix::config::Source::Local) - { - Ok(config) => config, - Err(error) if error.is_not_found() => gix::config::File::default(), - Err(error) => { - return Err(error) - .into_anyhow() - .context("failed to read repository config"); - } - }; - - edit(&mut config)?; - - config - .write_to(&mut lock) - .context("failed to write repository config")?; - - lock.commit().context("failed to save repository config")?; - - Ok(()) - } - - pub(crate) fn workdir_or_dot(&self) -> &Path { - self.inner.workdir().unwrap_or_else(|| Path::new(".")) - } - - pub(crate) fn run_git(dir: &Path, args: &[&str], what: &str) -> Result { - Self::git_command(dir) - .args(args) - .stderr(Stdio::piped()) - .output() - .with_context(|| format!("failed to spawn `{what}`")) - } - - pub(crate) fn git_command(dir: &Path) -> Command { - let mut command = Command::new("git"); - command.arg("-C").arg(dir).env("GIT_TERMINAL_PROMPT", "0"); - command - } -} diff --git a/crates/signed_git/src/repo.rs b/crates/signed_git/src/repo.rs index a286bd6..c3f2e1e 100644 --- a/crates/signed_git/src/repo.rs +++ b/crates/signed_git/src/repo.rs @@ -1,8 +1,19 @@ -use std::path::Path; +use std::collections::{HashMap, HashSet}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::Stdio; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, bail}; +use gix::bstr::ByteSlice; +use gix::interrupt::IS_INTERRUPTED; +use gix::progress::Discard; +use nostr::prelude::*; use crate::GixResultExt as _; +use crate::diff::{CommitDiff, DiffStatus, FileDiff, HunkCollector}; +use crate::history::{CommitList, FileCommit, MAX_LISTED_COMMITS}; +use crate::nip34::{GraspSignals, Nip34Binding, Nip34Json, Nip34Kind}; +use crate::worktree::WorktreeSnapshot; const OBJECT_CACHE_BYTES: usize = 64 * 1024 * 1024; @@ -13,33 +24,40 @@ pub struct RepoRefState { } impl RepoRefState { + /// Creates a ref state from its refs and current branch. fn new(refs: Vec<(String, String)>, head: Option) -> Self { Self { refs, head } } } -/// One `open` per operation instead of every helper re-opening by path. pub struct Repo { pub(crate) inner: gix::Repository, } impl Repo { - pub fn open(workdir: &Path) -> Result { - Ok(Self { - inner: gix::open(workdir)?, - }) + /// Wraps an already opened gix repository. + pub(crate) fn new(inner: gix::Repository) -> Self { + Self { inner } } + /// Opens the repository at `workdir`. + pub fn open(workdir: &Path) -> Result { + Ok(Self::new(gix::open(workdir)?)) + } + + /// Opens the repository at `workdir`, or `None` on failure. pub fn try_open(workdir: &Path) -> Option { Self::open(workdir).ok() } + /// Opens the repository at `workdir` with a larger object cache for big diffs. pub fn open_cached(workdir: &Path) -> Result { let mut repo = gix::open(workdir)?; repo.object_cache_size_if_unset(OBJECT_CACHE_BYTES); - Ok(Self { inner: repo }) + Ok(Self::new(repo)) } + /// Initializes a repository at `path` with a README and an initial commit. pub fn init(path: &Path, name: &str, description: &str) -> Result { use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; @@ -103,8 +121,6 @@ impl Repo { Vec::::new(), )?; - // 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()) @@ -113,7 +129,7 @@ impl Repo { Ok(commit.to_string()) } - /// Not kept in any cache, unlike `GitCache::ensure_clone`. + /// Clones the first working URL into `path` and fetches the NIP-34 refs. pub fn clone>(clone_urls: &[U], path: &Path) -> Result { if path.exists() { anyhow::bail!("destination {} already exists", path.display()); @@ -124,7 +140,6 @@ impl Repo { for url in clone_urls { match Self::clone_from(url.as_ref(), path) { Ok(repo) => { - // The default-refspec clone misses the `refs/nostr/*` PR refs. repo.fetch().ok(); return Ok(repo); } @@ -138,6 +153,7 @@ impl Repo { } } + /// Clones a single URL into `path`. fn clone_from(url: &str, path: &Path) -> Result { let url = Self::transport_url(url); let url = gix::url::parse(url) @@ -145,36 +161,41 @@ impl Repo { .context("invalid clone URL")?; let mut prepare = gix::prepare_clone(url, path)?; - let (mut checkout, _fetch) = - prepare.fetch_then_checkout(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)?; - let (repo, _checkout) = - checkout.main_worktree(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)?; + let (mut checkout, _fetch) = prepare.fetch_then_checkout(Discard, &IS_INTERRUPTED)?; + let (repo, _checkout) = checkout.main_worktree(Discard, &IS_INTERRUPTED)?; - Ok(Self { inner: repo }) + Ok(Self::new(repo)) } + /// Returns the underlying gix repository. pub fn inner(&self) -> &gix::Repository { &self.inner } + /// Returns the worktree directory, or `None` for a bare repository. pub fn workdir(&self) -> Option<&Path> { self.inner.workdir() } - /// `None` for an unborn HEAD. + /// Returns the worktree directory, or `.` for a bare repository. + pub(crate) fn workdir_or_dot(&self) -> &Path { + self.inner.workdir().unwrap_or_else(|| Path::new(".")) + } + + /// Returns the HEAD commit id, or `None` for an unborn HEAD. pub fn head(&self) -> Option { self.inner.head_id().ok().map(|id| id.to_string()) } + /// Returns the merge base of `a` and `b`, or `None` for unrelated histories. pub fn merge_base(&self, a: &str, b: &str) -> Result> { let a = self.inner.rev_parse_single(a.as_bytes())?; let b = self.inner.rev_parse_single(b.as_bytes())?; - // `merge_base` reports a missing base as an unclassified error, while the - // many-bases variant keeps the distinction as an empty result. let bases = self.inner.merge_bases_many(a.detach(), &[b.detach()])?; Ok(bases.first().map(|id| id.to_string())) } + /// Lists commit ids reachable from HEAD but not `base`, oldest first. pub fn commits_since(&self, base: Option<&str>) -> Result> { let head = match self.inner.head_id() { Ok(head) => head, @@ -206,6 +227,7 @@ impl Repo { Ok(commits) } + /// Returns the first commit of the history, or `None` without commits. pub fn root_commit(&self) -> Result> { let Ok(head) = self.inner.head_id() else { return Ok(None); @@ -228,6 +250,7 @@ impl Repo { Ok(None) } + /// Lists sorted ref names equal to or nested under `prefix`. pub fn refs_with_prefix(&self, prefix: &str) -> Result> { let pattern = prefix.trim_end_matches('/'); let mut names = Vec::new(); @@ -236,7 +259,6 @@ 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. let under_pattern = name .strip_prefix(pattern) .is_some_and(|rest| rest.is_empty() || rest.starts_with('/')); @@ -251,6 +273,7 @@ impl Repo { Ok(names) } + /// Deletes every ref under `prefix`. pub fn delete_refs_with_prefix(&self, prefix: &str) -> Result<()> { use gix::refs::transaction::{Change, PreviousValue, RefEdit, RefLog}; @@ -280,16 +303,19 @@ impl Repo { Ok(()) } + /// Returns the checked out branch name, or `None` when detached. pub fn current_branch(&self) -> Option { let head = self.inner.head().ok()?; let name = head.referent_name()?; Some(String::from_utf8_lossy(name.shorten()).into_owned()) } + /// Returns whether a ref named `name` exists. pub fn ref_exists(&self, name: &str) -> bool { self.inner.find_reference(name).is_ok() } + /// Lists sorted local branch names. pub fn branches(&self) -> Result> { let mut names = Vec::new(); for reference in self.inner.references()?.local_branches()? { @@ -300,6 +326,7 @@ impl Repo { Ok(names) } + /// Lists sorted tag names. pub fn tags(&self) -> Result> { let mut names = Vec::new(); for reference in self.inner.references()?.tags()? { @@ -310,6 +337,7 @@ impl Repo { Ok(names) } + /// Snapshots branch and tag refs with their oids plus the current branch. pub fn ref_state(&self) -> Result { let mut refs = Vec::new(); @@ -341,6 +369,7 @@ impl Repo { Ok(RepoRefState::new(refs, head)) } + /// Fast-forwards local branches to their origin counterparts, reporting any move. pub fn fast_forward_branches(&self) -> Result { use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; @@ -385,7 +414,6 @@ impl Repo { continue; } - // Only fast-forward: the base must be the local tip. let Ok(base) = self.inner.merge_base(local_oid, remote_oid) else { continue; }; @@ -414,7 +442,6 @@ impl Repo { }; if current.as_deref() == Some(branch) { - // Only proceed on a clean worktree, like `git merge --ff-only`. if self.is_dirty() { continue; } @@ -442,6 +469,1115 @@ impl Repo { Ok(moved) } + /// Diffs a commit against its first parent, skipping trees and submodules. + pub fn commit_diff(&self, id: &str) -> Result { + let commit_id = self.inner.rev_parse_single(id.as_bytes())?; + let commit = commit_id.object()?.into_commit(); + let new_tree = commit.tree()?; + let old_tree = match commit.parent_ids().next() { + Some(parent) => Some(parent.object()?.into_commit().tree()?), + None => None, + }; + Self::tree_diff(self, old_tree.as_ref(), &new_tree) + } + + /// Diffs the trees of `base` and `tip` directly, files sorted by path. + pub fn range_diff(&self, base: &str, tip: &str) -> Result { + let base_tree = self + .inner + .rev_parse_single(base.as_bytes())? + .object()? + .into_commit() + .tree()?; + let tip_tree = self + .inner + .rev_parse_single(tip.as_bytes())? + .object()? + .into_commit() + .tree()?; + Self::tree_diff(self, Some(&base_tree), &tip_tree) + } + + /// Produces file diffs between two trees, skipping trees and submodules. + fn tree_diff( + repo: &Repo, + old_tree: Option<&gix::Tree<'_>>, + new_tree: &gix::Tree<'_>, + ) -> Result { + use gix::diff::blob::platform::prepare_diff::Operation; + use gix::object::tree::diff::Change; + use gix::objs::tree::EntryKind; + + let changes = repo + .inner + .diff_tree_to_tree(old_tree, Some(new_tree), None)?; + + let mut cache = repo.inner.diff_resource_cache_for_tree_diff()?; + let mut files = Vec::new(); + + for change in changes { + let attached = Change::from_change_ref(change.to_ref(), &repo.inner, &repo.inner); + + let (path, old_path, status) = match attached { + Change::Addition { + location, + entry_mode, + .. + } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => { + (location.to_owned(), None, DiffStatus::Added) + } + Change::Deletion { + location, + entry_mode, + .. + } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => { + (location.to_owned(), None, DiffStatus::Deleted) + } + Change::Modification { + location, + previous_entry_mode, + entry_mode, + .. + } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) + && !matches!( + previous_entry_mode.kind(), + EntryKind::Tree | EntryKind::Commit + ) => + { + (location.to_owned(), None, DiffStatus::Modified) + } + Change::Rewrite { + location, + source_location, + source_entry_mode, + entry_mode, + copy, + .. + } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) + && !matches!( + source_entry_mode.kind(), + EntryKind::Tree | EntryKind::Commit + ) => + { + let status = if copy { + DiffStatus::Copied + } else { + DiffStatus::Renamed + }; + ( + location.to_owned(), + Some(source_location.to_owned()), + status, + ) + } + _ => continue, + }; + + let platform = attached.diff(&mut cache)?; + platform + .resource_cache + .options + .skip_internal_diff_if_external_is_configured = true; + let outcome = platform.resource_cache.prepare_diff().into_anyhow()?; + + let (binary, hunks, insertions, deletions) = match outcome.operation { + Operation::InternalDiff { algorithm } => { + let input = outcome.interned_input(); + let diff = gix::diff::blob::diff_with_slider_heuristics(algorithm, &input); + + let mut hunks = Vec::new(); + let mut insertions = 0usize; + let mut deletions = 0usize; + let collector = HunkCollector::new(&mut hunks, &mut insertions, &mut deletions); + gix::diff::blob::UnifiedDiff::new(&diff, &input, collector, Default::default()) + .consume()?; + (false, hunks, insertions, deletions) + } + Operation::SourceOrDestinationIsBinary => (true, Vec::new(), 0, 0), + Operation::ExternalCommand { .. } => { + unreachable!("external diff drivers are disabled") + } + }; + + files.push(FileDiff::new( + String::from_utf8_lossy(&path).into_owned(), + old_path.map(|p| String::from_utf8_lossy(&p).into_owned()), + status, + insertions, + deletions, + binary, + hunks, + )); + } + + files.sort_by(|a, b| a.path.cmp(&b.path)); + + Ok(CommitDiff::new(files)) + } + + /// Finds the newest commit touching each path, skipping paths never committed. + pub fn last_commits(&self, rels: &[PathBuf]) -> Result> { + use gix::traverse::commit::simple::CommitTimeOrder; + + let Some(head) = self.inner.head_id().ok() else { + return Ok(Vec::new()); + }; + + let mut pending: Vec = 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 = self + .inner + .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 tree = commit.tree()?; + let parent_tree = match info.parent_ids().next() { + Some(parent) => Some(parent.object()?.into_commit().tree()?), + None => None, + }; + + 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(), FileCommit::from_commit(&commit)?)); + pending.swap_remove(ix); + } else { + ix += 1; + } + } + } + + Ok(found) + } + + /// Lists at most `MAX_LISTED_COMMITS` commits plus the real total. + pub fn all_commits(&self) -> Result { + use gix::traverse::commit::simple::CommitTimeOrder; + + let Some(head) = self.inner.head_id().ok() else { + return Ok(CommitList::new(0, Vec::new())); + }; + + let walk = self + .inner + .rev_walk([head]) + .sorting(gix::revision::walk::Sorting::ByCommitTime( + CommitTimeOrder::NewestFirst, + )); + + let mut commits = Vec::new(); + let mut total = 0; + + for info in walk.all()? { + let info = info?; + total += 1; + if commits.len() < MAX_LISTED_COMMITS { + commits.push(FileCommit::from_commit_summary(&info.object()?)?); + } + } + + Ok(CommitList::new(total, commits)) + } + + /// Lists commits in `base..tip`, newest first. + pub fn commit_range(&self, base: &str, tip: &str) -> Result> { + use gix::traverse::commit::simple::CommitTimeOrder; + + let base_id = self.inner.rev_parse_single(base.as_bytes())?; + let tip_id = self.inner.rev_parse_single(tip.as_bytes())?; + let walk = self + .inner + .rev_walk([tip_id]) + .sorting(gix::revision::walk::Sorting::ByCommitTime( + CommitTimeOrder::NewestFirst, + )) + .with_hidden([base_id]); + + let mut commits = Vec::new(); + + for info in walk.all()? { + let info = info?; + commits.push(FileCommit::from_commit_summary(&info.object()?)?); + } + + Ok(commits) + } + + /// Returns the HEAD commit, or `None` for an unborn HEAD. + pub fn head_commit(&self) -> Result> { + let Some(head) = self.inner.head_id().ok() else { + return Ok(None); + }; + let commit = head.object()?.into_commit(); + Ok(Some(FileCommit::from_commit(&commit)?)) + } + + /// Resolves `id` to a commit, or `None` when unknown. + pub fn commit(&self, id: &str) -> Result> { + match self.inner.rev_parse_single(id.as_bytes()) { + Ok(commit_id) => { + let commit = commit_id.object()?.into_commit(); + Ok(Some(FileCommit::from_commit(&commit)?)) + } + Err(_) => Ok(None), + } + } + + /// Returns whether the worktree has index or untracked changes. + pub fn is_dirty(&self) -> bool { + match self.inner.is_dirty() { + Ok(true) => return true, + Ok(false) => {} + Err(_) => return false, + } + + let Ok(platform) = self.inner.status(Discard) else { + return false; + }; + + let Ok(mut changes) = platform.into_index_worktree_iter(Vec::::new()) + else { + return false; + }; + + for change in changes.by_ref() { + match change { + Ok(gix::status::index_worktree::Item::DirectoryContents { .. }) => return true, + Ok(_) => {} + Err(_) => return false, + } + } + + false + } + + /// Counts commits in `branch` since `base`, or 0 when either is unknown. + pub fn commits_ahead(&self, base: &str, branch: &str) -> u32 { + let (Some(base), Some(branch)) = (self.resolve_commit(base), self.resolve_commit(branch)) + else { + return 0; + }; + + let Ok(walk) = self.inner.rev_walk([branch]).with_hidden([base]).all() else { + return 0; + }; + + walk.filter_map(Result::ok).count().min(u32::MAX as usize) as u32 + } + + /// Resolves a revision to a gix id. + fn resolve_commit<'a>(&'a self, rev: &str) -> Option> { + self.inner.rev_parse_single(rev.as_bytes()).ok() + } + + /// Lists every worktree path relative to the root, directories first. + pub fn entries(&self) -> Result> { + let workdir = self.inner.workdir().context("repository has no worktree")?; + + let mut entries: Vec<(PathBuf, bool)> = Vec::new(); + Self::collect_entries(workdir, workdir, &mut entries)?; + + entries.sort_by(|(a, a_is_dir), (b, b_is_dir)| { + b_is_dir + .cmp(a_is_dir) + .then_with(|| a.as_os_str().cmp(b.as_os_str())) + }); + Ok(entries.into_iter().map(|(path, _)| path).collect()) + } + + /// Reads a worktree file, or `None` when missing. + pub fn read(&self, rel: &Path) -> Result>> { + let workdir = self.inner.workdir().context("repository has no worktree")?; + let path = workdir.join(rel); + + match std::fs::read(&path) { + Ok(bytes) => Ok(Some(bytes)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) if e.kind() == std::io::ErrorKind::IsADirectory => Ok(None), + Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())), + } + } + + /// Finds the top-level README, preferring markdown extensions. + pub fn find_readme(&self) -> Result> { + let Some(workdir) = self.inner.workdir() else { + return Ok(None); + }; + + let mut candidates: Vec = Vec::new(); + for entry in std::fs::read_dir(workdir)? { + let entry = entry?; + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + if name.to_ascii_lowercase().starts_with("readme") { + candidates.push(entry.path()); + } + } + + candidates.sort_by_key(|path| { + let ext = path + .extension() + .map(|e| e.to_string_lossy().to_ascii_lowercase()); + match ext.as_deref() { + Some("md") => 0, + Some("markdown") => 1, + Some("mdown") => 2, + Some("mkdn") => 3, + Some(_) => 5, + None => 4, + } + }); + + Ok(candidates + .into_iter() + .next() + .and_then(|path| path.strip_prefix(workdir).ok().map(Path::to_path_buf))) + } + + /// Captures worktree entries, README, and ref state in one pass. + pub fn snapshot(&self) -> Result { + let readme_path = self.find_readme()?; + let readme = match &readme_path { + Some(path) => self.read(path)?, + None => None, + }; + Ok(WorktreeSnapshot::new( + self.entries()?, + readme_path, + readme, + self.current_branch(), + self.head_commit().unwrap_or(None), + self.branches().unwrap_or_default(), + self.tags().unwrap_or_default(), + )) + } + + /// Checks out a local branch, replacing worktree contents. + pub fn checkout_branch(&self, name: &str) -> Result<()> { + let full = format!("refs/heads/{name}"); + + let branch = gix::refs::FullName::try_from(full.as_str()) + .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?; + + let mut reference = self.inner.find_reference(&full)?; + let tree = reference.peel_to_tree()?.id; + + let (signature, mut time_buf) = Self::repository_signature(); + let signature = signature.to_ref(&mut time_buf); + + self.move_head( + signature, + gix::refs::Target::Symbolic(branch), + &format!("checkout: moving to {name}"), + )?; + + self.force_checkout(&tree)?; + + Ok(()) + } + + /// Detaches HEAD onto a tag, replacing worktree contents. + pub fn checkout_tag(&self, name: &str) -> Result<()> { + let full = format!("refs/tags/{name}"); + + let mut reference = self.inner.find_reference(&full)?; + + let commit = reference.peel_to_id()?; + let tree = reference.peel_to_tree()?.id; + + let (signature, mut time_buf) = Self::repository_signature(); + let signature = signature.to_ref(&mut time_buf); + + self.move_head( + signature, + gix::refs::Target::Object(commit.detach()), + &format!("checkout: moving to {name}"), + )?; + + self.force_checkout(&tree)?; + + Ok(()) + } + + /// Writes `tree` over the worktree and removes files absent from it. + pub(crate) fn force_checkout(&self, tree: &gix::hash::oid) -> Result<()> { + let workdir = self + .inner + .workdir() + .context("repository has no worktree")? + .to_path_buf(); + + let mut index = self.inner.index_from_tree(tree)?; + + if let Ok(previous) = self.inner.index_or_empty() { + let keep: HashSet = index + .entries() + .iter() + .map(|entry| { + PathBuf::from(String::from_utf8_lossy(entry.path(&index)).into_owned()) + }) + .collect(); + for entry in previous.entries() { + let rel = entry.path(&previous); + let rel = PathBuf::from(String::from_utf8_lossy(rel).into_owned()); + + if keep.contains(&rel) { + continue; + } + + let path = workdir.join(&rel); + + match std::fs::remove_file(&path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error) + .with_context(|| format!("failed to remove {}", path.display())); + } + } + } + } + + let mut options = self + .inner + .checkout_options(gix_worktree::stack::state::attributes::Source::IdMapping)?; + options.overwrite_existing = true; + + let objects = self.inner.objects.clone().into_arc()?; + let files = gix::progress::Discard; + let bytes = gix::progress::Discard; + + gix_worktree_state::checkout( + &mut index, + workdir, + objects, + &files, + &bytes, + &gix::interrupt::IS_INTERRUPTED, + options, + ) + .into_anyhow()?; + + index + .write(gix::index::write::Options::default()) + .into_anyhow()?; + + Ok(()) + } + + /// Moves HEAD with a reflog entry. + fn move_head( + &self, + signature: gix::actor::SignatureRef<'_>, + target: gix::refs::Target, + message: &str, + ) -> Result<()> { + use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; + + let head = gix::refs::FullName::try_from("HEAD") + .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?; + + self.inner.edit_references_as( + [RefEdit { + change: Change::Update { + log: LogChange { + mode: RefLog::AndReference, + force_create_reflog: false, + message: message.into(), + }, + expected: PreviousValue::Any, + new: target, + }, + name: head, + deref: false, + }], + Some(signature), + )?; + + Ok(()) + } + + /// Recursively collects paths under `dir` relative to `root`, skipping `.git`. + fn collect_entries(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, bool)>) -> Result<()> { + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + if entry.file_name() == ".git" { + continue; + } + + let is_dir = entry.file_type()?.is_dir(); + let path = entry.path(); + let rel = path.strip_prefix(root)?.to_path_buf(); + out.push((rel, is_dir)); + + if is_dir { + Self::collect_entries(root, &path, out)?; + } + } + Ok(()) + } + + /// Applies an mbox patch series with `git am`. + pub fn apply_patch(&self, patch: &str) -> Result<()> { + let workdir = self + .inner + .workdir() + .context("repository has no worktree")? + .to_path_buf(); + + let mut child = Self::git_command(&workdir) + .args(["am"]) + .stdin(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("failed to spawn `git am`")?; + + child + .stdin + .as_mut() + .context("git am has no stdin pipe")? + .write_all(patch.as_bytes())?; + + let output = child.wait_with_output()?; + + if !output.status.success() { + bail!("git am failed: {}", String::from_utf8_lossy(&output.stderr)); + } + + Ok(()) + } + + /// Formats `base..tip` as an mbox, failing when the range is empty. + pub fn format_patch_between(&self, base: &str, tip: &str) -> Result { + let output = Self::run_git( + self.workdir_or_dot(), + &["format-patch", "--stdout", &format!("{base}..{tip}")], + "git format-patch", + )?; + + if !output.status.success() { + bail!( + "git format-patch failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + + let patch = String::from_utf8_lossy(&output.stdout).into_owned(); + + if patch.trim().is_empty() { + bail!("no commits between {base} and {tip}"); + } + + Ok(patch) + } + + /// Fetches `origin` including the `refs/nostr/*` PR refs. + pub fn fetch(&self) -> Result<()> { + let options = gix::remote::ref_map::Options { + extra_refspecs: vec![ + gix::refspec::parse( + gix::bstr::BStr::new("+refs/nostr/*:refs/nostr/*"), + gix::refspec::parse::Operation::Fetch, + ) + .into_anyhow()? + .to_owned(), + ], + ..Default::default() + }; + self.inner + .find_remote("origin")? + .connect(gix::remote::Direction::Fetch)? + .prepare_fetch(Discard, options)? + .receive(Discard, &IS_INTERRUPTED)?; + Ok(()) + } + + /// Pushes `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(), + &["push", url, &format!("{commit}:{reference}")], + "git push", + )?; + + if !output.status.success() { + bail!( + "git push failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(()) + } + + /// Pushes `main` to the grasp repository at `base_url`. + pub fn push_main(&self, base_url: &str, owner: &str, repo_id: &str) -> Result<()> { + self.push_refspecs( + base_url, + owner, + repo_id, + &["refs/heads/main:refs/heads/main"], + ) + } + + /// Mirrors all branches and tags to the grasp repository at `base_url`. + pub fn push_all(&self, base_url: &str, owner: &str, repo_id: &str) -> Result<()> { + self.push_refspecs( + base_url, + owner, + repo_id, + &["refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"], + ) + } + + /// Pushes explicit refspecs to the grasp repository at `base_url`. + fn push_refspecs( + &self, + base_url: &str, + owner: &str, + repo_id: &str, + refspecs: &[&str], + ) -> Result<()> { + let url = format!("{base_url}/{owner}/{repo_id}.git"); + + let mut args: Vec<&str> = Vec::with_capacity(refspecs.len() + 2); + args.push("push"); + args.push(&url); + args.extend_from_slice(refspecs); + + let output = Self::run_git(self.workdir_or_dot(), &args, "git push")?; + + if !output.status.success() { + bail!( + "git push to {base_url} failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(()) + } + + /// Checks that `url` advertises exactly the expected ref oids. + pub fn remote_has_refs(&self, url: &str, expected: &[(String, String)]) -> Result { + if expected.is_empty() { + return Ok(true); + } + + let url = Self::transport_url(url); + + let refspecs = expected + .iter() + .map(|(name, _)| { + gix::refspec::parse( + gix::bstr::BStr::new(format!("+{name}:{name}").as_bytes()), + gix::refspec::parse::Operation::Fetch, + ) + .map(|spec| spec.to_owned()) + }) + .collect::, _>>() + .into_anyhow() + .context("invalid refspec")?; + + let options = gix::remote::ref_map::Options { + extra_refspecs: refspecs, + ..Default::default() + }; + + let (refs, _) = self + .inner + .remote_at(url.as_str()) + .with_context(|| format!("cannot use remote {url}"))? + .connect(gix::remote::Direction::Fetch) + .with_context(|| format!("cannot connect to {url}"))? + .ref_map(Discard, options) + .with_context(|| format!("listing refs of {url} failed"))?; + + let advertised: HashMap = refs + .remote_refs + .iter() + .filter_map(|reference| { + let (name, object, _peeled) = reference.unpack(); + object.map(|oid| (String::from_utf8_lossy(name).into_owned(), oid.to_string())) + }) + .collect(); + + Ok(expected + .iter() + .all(|(name, oid)| advertised.get(name.as_str()) == Some(oid))) + } + + /// Sets the `origin` URL when the repository has no remote yet. + pub fn ensure_origin(&self, url: &str) -> Result<()> { + if self.inner.find_remote("origin").is_ok() { + return Ok(()); + } + + self.edit_local_config(|config| { + config + .set_raw_value("remote.origin.url", url) + .into_anyhow()?; + config + .set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*") + .into_anyhow()?; + Ok(()) + }) + } + + /// Repoints `origin` at `url`, keeping any existing fetch refspec. + pub fn set_origin(&self, url: &str) -> Result<()> { + let had_origin = self.inner.find_remote("origin").is_ok(); + + self.edit_local_config(|config| { + config + .set_raw_value("remote.origin.url", url) + .into_anyhow()?; + + if !had_origin { + config + .set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*") + .into_anyhow()?; + } + + Ok(()) + }) + } + + /// Returns the `origin` fetch URL, or `None` without an origin. + pub fn origin_url(&self) -> Result> { + let Ok(remote) = self.inner.find_remote("origin") else { + return Ok(None); + }; + + Ok(remote + .url(gix::remote::Direction::Fetch) + .map(|url| url.to_string())) + } + + /// Fetches `refspec` from the first working URL. + pub fn fetch_refs>(&self, urls: &[U], refspec: &str) -> Result<()> { + let refspec = gix::refspec::parse( + gix::bstr::BStr::new(refspec), + gix::refspec::parse::Operation::Fetch, + ) + .into_anyhow() + .context("invalid fetch refspec")? + .to_owned(); + + let mut last_error = None; + + for url in urls { + let url = Self::transport_url(url.as_ref()); + let outcome = (|| -> Result<()> { + let options = gix::remote::ref_map::Options { + extra_refspecs: vec![refspec.clone()], + ..Default::default() + }; + self.inner + .remote_at(url.as_str()) + .with_context(|| format!("fetch from {url} failed"))? + .connect(gix::remote::Direction::Fetch) + .with_context(|| format!("fetch from {url} failed"))? + .prepare_fetch(Discard, options) + .with_context(|| format!("fetch from {url} failed"))? + .receive(Discard, &IS_INTERRUPTED) + .with_context(|| format!("fetch from {url} failed"))?; + Ok(()) + })(); + + match outcome { + Ok(()) => return Ok(()), + Err(error) => last_error = Some(error), + } + } + + match last_error { + Some(error) => Err(error).context("failed to fetch from any mirror"), + None => bail!("no fetch URLs provided"), + } + } + + /// Edits the repository config under a lock and saves it. + pub(crate) fn edit_local_config( + &self, + edit: impl FnOnce(&mut gix::config::File) -> Result<()>, + ) -> Result<()> { + let config_path = self.inner.common_dir().join("config"); + + let mut lock = gix::lock::File::acquire_to_update_resource( + &config_path, + gix::lock::acquire::Fail::Immediately, + None, + ) + .into_anyhow() + .context("failed to lock repository config")?; + + let mut config = + match gix::config::File::from_path_no_includes(config_path, gix::config::Source::Local) + { + Ok(config) => config, + Err(error) if error.is_not_found() => gix::config::File::default(), + Err(error) => { + return Err(error) + .into_anyhow() + .context("failed to read repository config"); + } + }; + + edit(&mut config)?; + + config + .write_to(&mut lock) + .context("failed to write repository config")?; + + lock.commit().context("failed to save repository config")?; + + Ok(()) + } + + /// Runs `git` in `dir` and returns its output. + pub(crate) fn run_git(dir: &Path, args: &[&str], what: &str) -> Result { + Self::git_command(dir) + .args(args) + .stderr(Stdio::piped()) + .output() + .with_context(|| format!("failed to spawn `{what}`")) + } + + /// Builds a `git` command working in `dir` without terminal prompts. + pub(crate) fn git_command(dir: &Path) -> std::process::Command { + let mut command = std::process::Command::new("git"); + command.arg("-C").arg(dir).env("GIT_TERMINAL_PROMPT", "0"); + command + } + + /// Detects the repository's NIP-34 binding, or `None` without Nostr markers. + pub fn nip34_binding(&self) -> Option { + let common_dir = self.inner.common_dir().to_path_buf(); + let workdir = self.inner.workdir().map(std::path::Path::to_path_buf); + + let mut signals = GraspSignals::default(); + let mut owner: Option = None; + let mut identifier: Option = None; + let mut grasp_urls: Vec = Vec::new(); + + if let Some(workdir) = &workdir { + if let Ok(bytes) = std::fs::read(workdir.join("nip34.json")) + && let Ok(config) = serde_json::from_slice::(&bytes) + { + signals.nip34_json = true; + identifier = config.identifier.and_then(Self::non_empty); + owner = config + .owner + .as_deref() + .and_then(|value| PublicKey::parse(value).ok()); + } + + if workdir.join("maintainers.yaml").is_file() { + signals.maintainers_yaml = true; + } + } + + if let Ok(exclude) = std::fs::read_to_string(common_dir.join("info/exclude")) + && exclude.contains("nip34.json") + { + signals.nip34_excluded = true; + } + + if common_dir.join("nostr-cache.lmdb").is_file() { + signals.nostr_cache = true; + } + + if let Ok(config) = gix::config::File::from_path_no_includes( + common_dir.join("config"), + gix::config::Source::Local, + ) { + if let Some(value) = config.string("nostr.repo") + && let Some((key, id)) = Self::coordinate_from_naddr(&value.to_str_lossy()) + { + signals.nostr_repo_config = true; + owner = Some(key); + identifier = Some(id); + } + + for key in ["nostr.repo-relay-only", "nostr.nostate", "nostr.private"] { + if config.string(key).is_some() { + signals.nostr_aux_config = true; + } + } + + if let Some(sections) = config.sections_by_name("remote") { + for section in sections { + let Some(name) = section.header().subsection_name() else { + continue; + }; + let nak_grasp_remote = name.to_str_lossy().starts_with("nip34/grasp/"); + + for url in section.values("url") { + let url = url.to_str_lossy(); + + if url.starts_with("nostr://") { + signals.nostr_remote = true; + if owner.is_none() + && identifier.is_none() + && let Some((key, id)) = Self::parse_nostr_url(&url) + { + owner = Some(key); + identifier = Some(id); + } + } + + if Self::is_grasp_url(&url) { + signals.grasp_remote = true; + signals.nip34_grasp_remote |= nak_grasp_remote; + grasp_urls.push(url.to_string()); + + if owner.is_none() + && identifier.is_none() + && let Some((key, id)) = Self::grasp_parts(&url) + { + owner = Some(key); + identifier = Some(id); + } + } + } + } + } + } + + if let Ok(platform) = self.inner.references() + && let Ok(mut refs) = platform.prefixed(b"refs/heads/nip34/state/") + && refs.next().is_some() + { + signals.nip34_state_refs = true; + } + + if !signals.any() { + return None; + } + + let kind = if signals.nip34_json + || signals.nostr_repo_config + || signals.nip34_grasp_remote + || signals.nip34_state_refs + { + Nip34Kind::Initialized + } else if signals.nostr_remote { + Nip34Kind::Cloned + } else { + Nip34Kind::ToolingOnly + }; + + Some(Nip34Binding::new( + kind, signals, owner, identifier, grasp_urls, + )) + } + + /// Stores a NIP-34 coordinate in the `nostr.repo` config. + pub fn set_nostr_repo(&self, naddr: &str) -> Result<()> { + self.edit_local_config(|config| { + config.set_raw_value("nostr.repo", naddr).into_anyhow()?; + Ok(()) + }) + } + + /// Matches grasp URLs by shape: two path segments with an npub owner. + pub(crate) fn is_grasp_url(url: &str) -> bool { + let Ok(parsed) = Url::parse(url) else { + return false; + }; + + if !matches!(parsed.scheme(), "http" | "https" | "grasp") { + return false; + } + + let path = parsed.path(); + if path.matches('/').count() != 2 || path.len() < 65 { + return false; + } + + Self::grasp_parts(url).is_some() + } + + /// Splits a grasp URL into its owner and identifier. + fn grasp_parts(url: &str) -> Option<(PublicKey, String)> { + let parsed = Url::parse(url).ok()?; + let mut segments = parsed.path_segments()?.filter(|part| !part.is_empty()); + + let owner = PublicKey::parse(segments.next()?).ok()?; + let identifier = Self::non_empty(segments.next()?.trim_end_matches(".git"))?; + + Some((owner, identifier)) + } + + /// Decodes an `naddr` coordinate of a git repo announcement. + fn coordinate_from_naddr(value: &str) -> Option<(PublicKey, String)> { + let coordinate = Nip19Coordinate::from_bech32(value).ok()?; + if coordinate.kind != Kind::GitRepoAnnouncement { + return None; + } + + let identifier = Self::non_empty(coordinate.identifier.clone())?; + Some((coordinate.public_key, identifier)) + } + + /// Extracts owner and identifier from a `nostr://` URL. + fn parse_nostr_url(url: &str) -> Option<(PublicKey, String)> { + let rest = url.strip_prefix("nostr://")?; + + if rest.starts_with("naddr1") { + return Self::coordinate_from_naddr(rest); + } + + let rest = rest.rsplit_once('@').map_or(rest, |(_, after)| after); + let mut parts: Vec<&str> = rest.split('/').filter(|part| !part.is_empty()).collect(); + + if parts + .first() + .is_some_and(|first| matches!(*first, "ssh" | "https" | "http")) + { + parts.remove(0); + } + + if parts.len() < 2 { + return None; + } + + let owner = PublicKey::parse(parts[0]).ok()?; + let identifier = Self::non_empty(parts.last()?.trim_end_matches(".git"))?; + + Some((owner, identifier)) + } + + /// Returns `value` unless it is empty. + fn non_empty(value: impl Into) -> Option { + let value = value.into(); + (!value.is_empty()).then_some(value) + } + + /// Returns the fixed author signature used for repository writes. pub(crate) fn repository_signature() -> (gix::actor::Signature, gix::date::parse::TimeBuf) { let seconds = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -457,9 +1593,209 @@ impl Repo { (signature, gix::date::parse::TimeBuf::default()) } + /// Rewrites `grasp://` URLs to `https://`, leaving others untouched. pub(crate) fn transport_url(url: &str) -> String { url.strip_prefix("grasp://") .map(|rest| format!("https://{rest}")) .unwrap_or_else(|| url.to_owned()) } } + +#[cfg(test)] +mod tests { + use std::path::Path; + use std::process::Command; + + use super::*; + + /// Creates a fresh repository in a temp directory. + fn init_repo() -> (tempfile::TempDir, std::path::PathBuf) { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("repo"); + std::fs::create_dir_all(&path).expect("mkdir"); + git(&path, &["init", "-q"]); + (dir, path) + } + + /// Runs `git` in `dir`, failing the test on a non-zero exit. + fn git(dir: &Path, args: &[&str]) { + let status = Command::new("git") + .current_dir(dir) + .env("GIT_AUTHOR_NAME", "Test Author") + .env("GIT_AUTHOR_EMAIL", "test@example.com") + .env("GIT_COMMITTER_NAME", "Test Author") + .env("GIT_COMMITTER_EMAIL", "test@example.com") + .env("GIT_EDITOR", "true") + .args(args) + .status() + .expect("spawn git"); + assert!(status.success(), "git {args:?} failed"); + } + + /// Generates a fresh public key. + fn key() -> PublicKey { + Keys::generate().public_key() + } + + /// Encodes a NIP-34 coordinate as an `naddr` string. + fn naddr(kind: Kind, owner: PublicKey, identifier: &str) -> String { + let coordinate = Coordinate::new(kind, owner).identifier(identifier); + Nip19Coordinate::new(coordinate, Vec::::new()) + .to_bech32() + .expect("naddr") + } + + /// Opens the repository at `path` and returns its NIP-34 binding. + fn binding_of(path: &Path) -> Option { + Repo::open(path).ok()?.nip34_binding() + } + + #[test] + fn plain_repository_has_no_binding() { + let (_dir, path) = init_repo(); + assert!(binding_of(&path).is_none()); + } + + #[test] + fn nip34_json_marks_a_repository_initialized() { + let (_dir, path) = init_repo(); + let owner = key(); + let npub = owner.to_bech32().expect("npub"); + std::fs::write( + path.join("nip34.json"), + format!(r#"{{"identifier":"my-repo","owner":"{npub}"}}"#), + ) + .expect("write"); + + let binding = binding_of(&path).expect("binding"); + assert_eq!(binding.kind, Nip34Kind::Initialized); + assert!(binding.signals.nip34_json); + assert_eq!(binding.owner, Some(owner)); + assert_eq!(binding.identifier.as_deref(), Some("my-repo")); + } + + #[test] + fn malformed_nip34_json_is_ignored() { + let (_dir, path) = init_repo(); + std::fs::write(path.join("nip34.json"), b"not json").expect("write"); + + assert!(binding_of(&path).is_none()); + } + + #[test] + fn nak_exclude_and_state_refs_are_detected() { + let (_dir, path) = init_repo(); + + std::fs::create_dir_all(path.join(".git/info")).expect("mkdir"); + std::fs::write(path.join(".git/info/exclude"), "nip34.json\n").expect("write"); + + git(&path, &["commit", "-q", "--allow-empty", "-m", "initial"]); + git( + &path, + &["update-ref", "refs/heads/nip34/state/HEAD", "HEAD"], + ); + + let binding = binding_of(&path).expect("binding"); + assert_eq!(binding.kind, Nip34Kind::Initialized); + assert!(binding.signals.nip34_excluded); + assert!(binding.signals.nip34_state_refs); + } + + #[test] + fn nostr_repo_config_marks_a_repository_initialized() { + let (_dir, path) = init_repo(); + let owner = key(); + let naddr = naddr(Kind::GitRepoAnnouncement, owner, "my-repo"); + git(&path, &["config", "nostr.repo", &naddr]); + + let binding = binding_of(&path).expect("binding"); + assert_eq!(binding.kind, Nip34Kind::Initialized); + assert!(binding.signals.nostr_repo_config); + assert_eq!(binding.owner, Some(owner)); + assert_eq!(binding.identifier.as_deref(), Some("my-repo")); + } + + #[test] + fn the_written_nostr_repo_marker_is_detected() { + let (_dir, path) = init_repo(); + let owner = key(); + let naddr = naddr(Kind::GitRepoAnnouncement, owner, "my-repo"); + + Repo::open(&path) + .expect("open") + .set_nostr_repo(&naddr) + .expect("write marker"); + + let binding = binding_of(&path).expect("binding"); + assert_eq!(binding.kind, Nip34Kind::Initialized); + assert!(binding.signals.nostr_repo_config); + assert_eq!(binding.owner, Some(owner)); + assert_eq!(binding.identifier.as_deref(), Some("my-repo")); + } + + #[test] + fn nostr_remote_is_a_nip34_clone() { + let (_dir, path) = init_repo(); + let owner = key(); + let npub = owner.to_bech32().expect("npub"); + let url = format!("nostr://{npub}/relay.ngit.dev/my-repo"); + git(&path, &["remote", "add", "origin", &url]); + + let binding = binding_of(&path).expect("binding"); + assert_eq!(binding.kind, Nip34Kind::Cloned); + assert!(binding.signals.nostr_remote); + assert_eq!(binding.owner, Some(owner)); + assert_eq!(binding.identifier.as_deref(), Some("my-repo")); + } + + #[test] + fn nak_grasp_remote_marks_a_repository_initialized() { + let (_dir, path) = init_repo(); + let owner = key(); + let npub = owner.to_bech32().expect("npub"); + let url = format!("https://gitnostr.com/{npub}/my-repo.git"); + git( + &path, + &["config", "remote.nip34/grasp/gitnostr.com.url", &url], + ); + + let binding = binding_of(&path).expect("binding"); + assert_eq!(binding.kind, Nip34Kind::Initialized); + assert!(binding.signals.nip34_grasp_remote); + assert!(binding.signals.grasp_remote); + assert_eq!(binding.grasp_urls, vec![url]); + assert_eq!(binding.owner, Some(owner)); + assert_eq!(binding.identifier.as_deref(), Some("my-repo")); + } + + #[test] + fn nostr_cache_alone_is_tooling_only() { + let (_dir, path) = init_repo(); + std::fs::write(path.join(".git/nostr-cache.lmdb"), b"cache").expect("write"); + + let binding = binding_of(&path).expect("binding"); + assert_eq!(binding.kind, Nip34Kind::ToolingOnly); + assert!(binding.signals.nostr_cache); + } + + #[test] + fn grasp_urls_are_recognised_by_shape() { + let owner = key(); + let npub = owner.to_bech32().expect("npub"); + + assert!(Repo::is_grasp_url(&format!( + "https://gitnostr.com/{npub}/my-repo.git" + ))); + assert!(Repo::is_grasp_url(&format!( + "grasp://gitnostr.com/{npub}/my-repo.git" + ))); + + assert!(!Repo::is_grasp_url("https://gitnostr.com/my-repo.git")); + assert!(!Repo::is_grasp_url( + "https://gitnostr.com/not-a-pubkey/my-repo.git" + )); + assert!(!Repo::is_grasp_url(&format!( + "ssh://gitnostr.com/{npub}/my-repo.git" + ))); + } +} diff --git a/crates/signed_git/src/scan.rs b/crates/signed_git/src/scan.rs index 3fb7797..97281fd 100644 --- a/crates/signed_git/src/scan.rs +++ b/crates/signed_git/src/scan.rs @@ -5,7 +5,6 @@ use ignore::WalkBuilder; use crate::nip34::Nip34Binding; use crate::repo::Repo; -/// Caps nesting so pathological trees can't stall the scan. const SCAN_MAX_DEPTH: usize = 12; #[derive(Debug, Clone)] @@ -14,14 +13,21 @@ pub struct LocalRepo { pub nip34: Option, } -pub fn find_git_repos(root: &Path) -> Vec { - if !root.is_dir() { - return Vec::new(); +impl LocalRepo { + /// Creates a local repository entry from its path and optional NIP-34 binding. + pub fn new(path: PathBuf, nip34: Option) -> Self { + Self { path, nip34 } } +} - let walker = WalkBuilder::new(root) +/// Finds the outermost git repositories under `root`, up to `SCAN_MAX_DEPTH` deep. +pub fn find_git_repos(root: &Path) -> Vec { + let Ok(root) = root.canonicalize() else { + return Vec::new(); + }; + + let walker = WalkBuilder::new(&root) .max_depth(Some(SCAN_MAX_DEPTH)) - // Honour `.gitignore` even when the scan root is not itself a repository. .require_git(false) .build(); @@ -29,14 +35,16 @@ pub fn find_git_repos(root: &Path) -> Vec { .flatten() .filter(|entry| entry.file_type().is_some_and(|kind| kind.is_dir())) .map(ignore::DirEntry::into_path) - .filter(|dir| dir.join(".git").exists()) - .filter_map(|dir| dir.canonicalize().ok()) + .filter_map(|dir| { + let repo = gix::discover(&dir).ok()?; + let workdir = repo.workdir()?.to_path_buf(); + workdir.starts_with(&root).then_some(workdir) + }) .collect(); repos.sort(); repos.dedup(); - // A repository nested inside another, like a submodule worktree, is not reported. let mut roots: Vec = Vec::with_capacity(repos.len()); for repo in repos { if !roots.iter().any(|kept| repo.starts_with(kept)) { @@ -48,7 +56,7 @@ pub fn find_git_repos(root: &Path) -> Vec { .into_iter() .map(|path| { let nip34 = Repo::open(&path).ok().and_then(|repo| repo.nip34_binding()); - LocalRepo { path, nip34 } + LocalRepo::new(path, nip34) }) .collect() } diff --git a/crates/signed_git/src/tests.rs b/crates/signed_git/src/tests.rs index fcb5f18..f0c1b3d 100644 --- a/crates/signed_git/src/tests.rs +++ b/crates/signed_git/src/tests.rs @@ -130,6 +130,7 @@ fn repo_ref_state_lists_branches_tags_and_head() { assert_eq!(state.refs.len(), 3); } +/// Creates a repository fixture with the given files. fn fixture(files: &[(&str, &[u8])]) -> (tempfile::TempDir, Repo) { let dir = tempfile::tempdir().expect("tempdir"); gix::init(&dir).expect("init"); @@ -144,6 +145,7 @@ fn fixture(files: &[(&str, &[u8])]) -> (tempfile::TempDir, Repo) { (dir, repo) } +/// Stages and commits every change in the repository. fn commit_all(repo: &Repo, message: &str) { git_run(repo.workdir().expect("workdir"), &["add", "-A"]); git_run(repo.workdir().expect("workdir"), &["commit", "-m", message]); @@ -415,6 +417,7 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() { ); } +/// Runs `git` in `dir`, failing the test on a non-zero exit. fn git_run(dir: &Path, args: &[&str]) -> std::process::Output { let output = Command::new("git") .current_dir(dir) @@ -430,6 +433,24 @@ fn git_run(dir: &Path, args: &[&str]) -> std::process::Output { output } +#[test] +fn find_git_repos_reports_the_outermost_repository() { + let root = tempfile::tempdir().expect("tempdir"); + let repo_dir = root.path().join("project").join("sub"); + std::fs::create_dir_all(&repo_dir).expect("mkdir"); + git_run(&repo_dir, &["init", "-q"]); + + let plain_dir = root.path().join("plain").join("deep"); + std::fs::create_dir_all(&plain_dir).expect("mkdir"); + + let repos = find_git_repos(root.path()); + + assert_eq!(repos.len(), 1); + assert_eq!(repos[0].path, repo_dir.canonicalize().expect("canonical")); + assert!(repos[0].nip34.is_none()); +} + +/// Creates an empty bare server repository for push tests. 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"); diff --git a/crates/signed_git/src/worktree.rs b/crates/signed_git/src/worktree.rs index ba0fde8..db491b5 100644 --- a/crates/signed_git/src/worktree.rs +++ b/crates/signed_git/src/worktree.rs @@ -1,18 +1,11 @@ -use std::collections::HashSet; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; -use anyhow::{Context, Result}; -use gix::progress::Discard; - -use crate::GixResultExt as _; use crate::history::FileCommit; -use crate::repo::Repo; pub struct WorktreeSnapshot { pub entries: Vec, pub readme_path: Option, pub readme: Option>, - // `None` when detached, for example on a tag. pub current_branch: Option, pub head_commit: Option, pub branches: Vec, @@ -20,7 +13,8 @@ pub struct WorktreeSnapshot { } impl WorktreeSnapshot { - fn new( + /// Creates a snapshot of the worktree contents and ref state. + pub(crate) fn new( entries: Vec, readme_path: Option, readme: Option>, @@ -40,287 +34,3 @@ impl WorktreeSnapshot { } } } - -impl Repo { - pub fn is_dirty(&self) -> bool { - match self.inner.is_dirty() { - Ok(true) => return true, - Ok(false) => {} - Err(_) => return false, - } - - let Ok(platform) = self.inner.status(Discard) else { - return false; - }; - - let Ok(mut changes) = platform.into_index_worktree_iter(Vec::::new()) - else { - return false; - }; - - for change in changes.by_ref() { - match change { - Ok(gix::status::index_worktree::Item::DirectoryContents { .. }) => return true, - Ok(_) => {} - Err(_) => return false, - } - } - - false - } - - pub fn commits_ahead(&self, base: &str, branch: &str) -> u32 { - let (Some(base), Some(branch)) = (self.resolve_commit(base), self.resolve_commit(branch)) - else { - return 0; - }; - - let Ok(walk) = self.inner.rev_walk([branch]).with_hidden([base]).all() else { - return 0; - }; - - walk.filter_map(Result::ok).count().min(u32::MAX as usize) as u32 - } - - fn resolve_commit<'a>(&'a self, rev: &str) -> Option> { - self.inner.rev_parse_single(rev.as_bytes()).ok() - } - - pub fn entries(&self) -> Result> { - let workdir = self.inner.workdir().context("repository has no worktree")?; - - let mut entries: Vec<(PathBuf, bool)> = Vec::new(); - Self::collect_entries(workdir, workdir, &mut entries)?; - - entries.sort_by(|(a, a_is_dir), (b, b_is_dir)| { - b_is_dir - .cmp(a_is_dir) - .then_with(|| a.as_os_str().cmp(b.as_os_str())) - }); - Ok(entries.into_iter().map(|(path, _)| path).collect()) - } - - pub fn read(&self, rel: &Path) -> Result>> { - let workdir = self.inner.workdir().context("repository has no worktree")?; - let path = workdir.join(rel); - - match std::fs::read(&path) { - Ok(bytes) => Ok(Some(bytes)), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(e) if e.kind() == std::io::ErrorKind::IsADirectory => Ok(None), - Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())), - } - } - - pub fn find_readme(&self) -> Result> { - let Some(workdir) = self.inner.workdir() else { - return Ok(None); - }; - - let mut candidates: Vec = Vec::new(); - for entry in std::fs::read_dir(workdir)? { - let entry = entry?; - let name = entry.file_name(); - let Some(name) = name.to_str() else { continue }; - if name.to_ascii_lowercase().starts_with("readme") { - candidates.push(entry.path()); - } - } - - candidates.sort_by_key(|path| { - let ext = path - .extension() - .map(|e| e.to_string_lossy().to_ascii_lowercase()); - match ext.as_deref() { - Some("md") => 0, - Some("markdown") => 1, - Some("mdown") => 2, - Some("mkdn") => 3, - Some(_) => 5, - None => 4, - } - }); - - Ok(candidates - .into_iter() - .next() - .and_then(|path| path.strip_prefix(workdir).ok().map(Path::to_path_buf))) - } - - pub fn snapshot(&self) -> Result { - let readme_path = self.find_readme()?; - let readme = match &readme_path { - Some(path) => self.read(path)?, - None => None, - }; - Ok(WorktreeSnapshot::new( - self.entries()?, - readme_path, - readme, - self.current_branch(), - self.head_commit().unwrap_or(None), - self.branches().unwrap_or_default(), - self.tags().unwrap_or_default(), - )) - } - - pub fn checkout_branch(&self, name: &str) -> Result<()> { - let full = format!("refs/heads/{name}"); - - let branch = gix::refs::FullName::try_from(full.as_str()) - .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?; - - let mut reference = self.inner.find_reference(&full)?; - let tree = reference.peel_to_tree()?.id; - - let (signature, mut time_buf) = Self::repository_signature(); - let signature = signature.to_ref(&mut time_buf); - - self.move_head( - signature, - gix::refs::Target::Symbolic(branch), - &format!("checkout: moving to {name}"), - )?; - - self.force_checkout(&tree)?; - - Ok(()) - } - - pub fn checkout_tag(&self, name: &str) -> Result<()> { - let full = format!("refs/tags/{name}"); - - let mut reference = self.inner.find_reference(&full)?; - - let commit = reference.peel_to_id()?; - let tree = reference.peel_to_tree()?.id; - - let (signature, mut time_buf) = Self::repository_signature(); - let signature = signature.to_ref(&mut time_buf); - - self.move_head( - signature, - gix::refs::Target::Object(commit.detach()), - &format!("checkout: moving to {name}"), - )?; - - self.force_checkout(&tree)?; - - Ok(()) - } - - pub(crate) fn force_checkout(&self, tree: &gix::hash::oid) -> Result<()> { - let workdir = self - .inner - .workdir() - .context("repository has no worktree")? - .to_path_buf(); - - let mut index = self.inner.index_from_tree(tree)?; - - if let Ok(previous) = self.inner.index_or_empty() { - let keep: HashSet = index - .entries() - .iter() - .map(|entry| { - PathBuf::from(String::from_utf8_lossy(entry.path(&index)).into_owned()) - }) - .collect(); - for entry in previous.entries() { - let rel = entry.path(&previous); - let rel = PathBuf::from(String::from_utf8_lossy(rel).into_owned()); - - if keep.contains(&rel) { - continue; - } - - let path = workdir.join(&rel); - - match std::fs::remove_file(&path) { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => { - return Err(error) - .with_context(|| format!("failed to remove {}", path.display())); - } - } - } - } - - let mut options = self - .inner - .checkout_options(gix_worktree::stack::state::attributes::Source::IdMapping)?; - options.overwrite_existing = true; - - let objects = self.inner.objects.clone().into_arc()?; - let files = gix::progress::Discard; - let bytes = gix::progress::Discard; - - gix_worktree_state::checkout( - &mut index, - workdir, - objects, - &files, - &bytes, - &gix::interrupt::IS_INTERRUPTED, - options, - ) - .into_anyhow()?; - - index - .write(gix::index::write::Options::default()) - .into_anyhow()?; - - Ok(()) - } - - fn move_head( - &self, - signature: gix::actor::SignatureRef<'_>, - target: gix::refs::Target, - message: &str, - ) -> Result<()> { - use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; - - let head = gix::refs::FullName::try_from("HEAD") - .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?; - - self.inner.edit_references_as( - [RefEdit { - change: Change::Update { - log: LogChange { - mode: RefLog::AndReference, - force_create_reflog: false, - message: message.into(), - }, - expected: PreviousValue::Any, - new: target, - }, - name: head, - deref: false, - }], - Some(signature), - )?; - - Ok(()) - } - - fn collect_entries(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, bool)>) -> Result<()> { - for entry in std::fs::read_dir(dir)? { - let entry = entry?; - if entry.file_name() == ".git" { - continue; - } - - let is_dir = entry.file_type()?.is_dir(); - let path = entry.path(); - let rel = path.strip_prefix(root)?.to_path_buf(); - out.push((rel, is_dir)); - - if is_dir { - Self::collect_entries(root, &path, out)?; - } - } - Ok(()) - } -}