diff --git a/crates/signed_git/src/cache.rs b/crates/signed_git/src/cache.rs index fdfa932..106dd01 100644 --- a/crates/signed_git/src/cache.rs +++ b/crates/signed_git/src/cache.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use signed_core::{Announcement, RepoAddr}; -use crate::remote::{clone_repo, fetch_all}; +use crate::repo::Repo; /// On-disk cache of cloned repositories, keyed by owner pubkey / repo id. #[derive(Debug, Clone)] @@ -23,13 +23,13 @@ impl GitCache { pub fn repo_path(&self, addr: &RepoAddr) -> PathBuf { self.root .join(addr.public_key().to_hex()) - .join(sanitize_path_component(addr.identifier())) + .join(Self::sanitize_path_component(addr.identifier())) } - pub fn open(&self, addr: &RepoAddr) -> Result> { + pub fn open(&self, addr: &RepoAddr) -> Result> { let path = self.repo_path(addr); match gix::open(&path) { - Ok(repo) => Ok(Some(repo)), + Ok(repo) => Ok(Some(Repo { inner: repo })), Err(gix::open::Error::NotARepository { .. }) => Ok(None), Err(gix::open::Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(e) => Err(e.into()), @@ -37,15 +37,11 @@ impl GitCache { } /// Open the existing clone, fetching it first. - pub fn ensure_clone>( - &self, - addr: &RepoAddr, - clone_urls: &[U], - ) -> Result { + pub fn ensure_clone>(&self, addr: &RepoAddr, clone_urls: &[U]) -> Result { let path = self.repo_path(addr); if let Some(repo) = self.open(addr)? { - fetch_all(&repo).ok(); + repo.fetch().ok(); return Ok(repo); } @@ -54,37 +50,35 @@ impl GitCache { .with_context(|| format!("failed to create {}", parent.display()))?; } - clone_repo(clone_urls, &path)?; - self.open(addr)? - .ok_or_else(|| anyhow::anyhow!("clone finished but the repository cannot be opened")) - } -} - -/// Map an untrusted repository id or display name to a safe single path component. -pub fn sanitize_path_component(id: &str) -> String { - let sanitized: String = id - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { - c - } else { - '_' - } - }) - .collect(); - - if sanitized == "." || sanitized == ".." { - return "_".to_owned(); + Repo::clone(clone_urls, &path) } - sanitized -} + /// Map an untrusted repository id or display name to a safe single path component. + pub fn sanitize_path_component(id: &str) -> String { + let sanitized: String = id + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { + c + } else { + '_' + } + }) + .collect(); -/// The refs namespace of a fork's import in the target mirror. -pub fn fork_namespace(announcement: &Announcement) -> String { - format!( - "{}/{}", - announcement.owner.to_hex(), - sanitize_path_component(&announcement.id) - ) + if sanitized == "." || sanitized == ".." { + return "_".to_owned(); + } + + sanitized + } + + /// The refs namespace of a fork's import in the target mirror. + pub fn fork_namespace(announcement: &Announcement) -> String { + format!( + "{}/{}", + announcement.owner.to_hex(), + Self::sanitize_path_component(&announcement.id) + ) + } } diff --git a/crates/signed_git/src/diff.rs b/crates/signed_git/src/diff.rs index 8e0afb3..ed8a394 100644 --- a/crates/signed_git/src/diff.rs +++ b/crates/signed_git/src/diff.rs @@ -1,8 +1,8 @@ -use std::path::Path; - use anyhow::Result; use gix::diff::blob::unified_diff::{ConsumeHunk, DiffLineKind as GixLineKind, HunkHeader}; +use crate::repo::Repo; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DiffLineKind { /// An unchanged context line, present on both sides. @@ -66,159 +66,162 @@ pub struct CommitDiff { pub files: Vec, } -/// The changes of the commit `id`, short or full, in the repository at `workdir`. -/// -/// Compared against its first parent, the empty tree for the root commit. -pub fn worktree_commit_diff(workdir: &Path, id: &str) -> Result { - commit_diff(&gix::open(workdir)?, id) -} - -fn commit_diff(repo: &gix::Repository, id: &str) -> Result { - let commit_id = repo.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, - }; - tree_diff(repo, old_tree.as_ref(), &new_tree) -} - -/// The changes between two commits, `base`..`tip`, like `git diff base tip`. -/// -/// Directories and submodules are skipped, files are sorted by path. -pub fn worktree_commit_range_diff(workdir: &Path, base: &str, tip: &str) -> Result { - let repo = gix::open(workdir)?; - let base_tree = repo - .rev_parse_single(base.as_bytes())? - .object()? - .into_commit() - .tree()?; - let tip_tree = repo - .rev_parse_single(tip.as_bytes())? - .object()? - .into_commit() - .tree()?; - tree_diff(&repo, Some(&base_tree), &tip_tree) -} - -fn tree_diff( - repo: &gix::Repository, - 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.diff_tree_to_tree(old_tree, Some(new_tree), None)?; - - let mut cache = repo.diff_resource_cache_for_tree_diff()?; - let mut files = Vec::new(); - - for change in changes { - let attached = Change::from_change_ref(change.to_ref(), repo, repo); - - // Skip directory trees and submodule gitlinks, only files are listed. - 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, +impl Repo { + /// The changes of the commit `id`, short or full. + /// + /// Compared against its 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, }; - - // Always diff with the built-in algorithm. - // External diff drivers would shell out, out of scope for a read-only viewer. - let platform = attached.diff(&mut cache)?; - platform - .resource_cache - .options - .skip_internal_diff_if_external_is_configured = true; - let outcome = platform.resource_cache.prepare_diff()?; - - 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, - }); + Self::tree_diff(self, old_tree.as_ref(), &new_tree) } - files.sort_by(|a, b| a.path.cmp(&b.path)); + /// The changes between two commits, `base`..`tip`, like `git diff base tip`. + /// + /// 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) + } - Ok(CommitDiff { files }) + 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); + + // Skip directory trees and submodule gitlinks, only files are listed. + 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, + }; + + // Always diff with the built-in algorithm. + // External diff drivers would shell out, out of scope for a read-only viewer. + let platform = attached.diff(&mut cache)?; + platform + .resource_cache + .options + .skip_internal_diff_if_external_is_configured = true; + let outcome = platform.resource_cache.prepare_diff()?; + + 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 }) + } } /// Collects the hunks of one blob diff while tracking per-line numbers. diff --git a/crates/signed_git/src/history.rs b/crates/signed_git/src/history.rs index f99a105..2669c4f 100644 --- a/crates/signed_git/src/history.rs +++ b/crates/signed_git/src/history.rs @@ -3,11 +3,7 @@ use std::path::{Path, PathBuf}; use anyhow::Result; -/// In-memory object cache for history walks, see [`open_with_cache`]. -/// -/// Without one, a walk re-decodes the same commit objects from the object database. -/// Sized generously: a walk can cover a large portion of the repository's history. -const OBJECT_CACHE_BYTES: usize = 64 * 1024 * 1024; +use crate::repo::Repo; /// Metadata of a commit, as shown in the repository browser's file header. #[derive(Debug, Clone)] @@ -25,121 +21,192 @@ pub struct FileCommit { pub time: i64, } -/// Open the repository at `workdir` with an in-memory object cache. -/// -/// Only history walks use it, they re-decode the same commit objects repeatedly. -/// Single-object reads open the repository plain. -pub(crate) fn open_with_cache(workdir: &Path) -> Result { - let mut repo = gix::open(workdir)?; - repo.object_cache_size_if_unset(OBJECT_CACHE_BYTES); - Ok(repo) -} - -/// A [`FileCommit`] with author, message title, body and shortened id. -/// -/// The diff panel fetches the full commit on demand. -fn file_commit(commit: &gix::Commit<'_>) -> Result { - file_commit_with_description(commit, true) -} - -/// A [`FileCommit`] without the message body, for history lists that never display it. -/// -/// Skipping the body saves an allocation per listed commit. -fn file_commit_summary(commit: &gix::Commit<'_>) -> Result { - file_commit_with_description(commit, false) -} - -fn file_commit_with_description( - commit: &gix::Commit<'_>, - include_description: bool, -) -> Result { - let author = commit.author()?; - let message = commit.message()?; - - Ok(FileCommit { - id: commit.id().shorten_or_id().to_string(), - summary: String::from_utf8_lossy(message.title).trim().to_string(), - description: if include_description { - message - .body - .map(|body| String::from_utf8_lossy(body).trim().to_string()) - .filter(|body| !body.is_empty()) - } else { - None - }, - author: String::from_utf8_lossy(author.name).trim().to_string(), - time: author.time()?.seconds, - }) -} - -/// Newest commit touching each of `rels`, like `git log -1 -- ` per path. -/// `rels` are paths relative to the worktree. -/// -/// Paths without any commit, like untracked files, are absent from the result. -pub fn worktree_last_commits( - workdir: &Path, - rels: &[PathBuf], -) -> Result> { - last_commits(&open_with_cache(workdir)?, rels) -} - -/// Stops as soon as every pending path has its commit. -fn last_commits(repo: &gix::Repository, rels: &[PathBuf]) -> Result> { - use gix::traverse::commit::simple::CommitTimeOrder; - - let Some(head) = repo.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()); - } +impl FileCommit { + /// A [`FileCommit`] with author, message title, body and shortened id. + /// + /// The diff panel fetches the full commit on demand. + fn from_commit(commit: &gix::Commit<'_>) -> Result { + Self::from_commit_with_description(commit, true) } - let walk = repo - .rev_walk([head]) - .sorting(gix::revision::walk::Sorting::ByCommitTime( - CommitTimeOrder::NewestFirst, - )); + /// A [`FileCommit`] without the message body, for history lists that never display it. + /// + /// Skipping the body saves an allocation per listed commit. + fn from_commit_summary(commit: &gix::Commit<'_>) -> Result { + Self::from_commit_with_description(commit, false) + } - 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, + fn from_commit_with_description( + commit: &gix::Commit<'_>, + include_description: bool, + ) -> Result { + let author = commit.author()?; + let message = commit.message()?; + + Ok(FileCommit { + id: commit.id().shorten_or_id().to_string(), + summary: String::from_utf8_lossy(message.title).trim().to_string(), + description: if include_description { + message + .body + .map(|body| String::from_utf8_lossy(body).trim().to_string()) + .filter(|body| !body.is_empty()) + } else { + None + }, + author: String::from_utf8_lossy(author.name).trim().to_string(), + time: author.time()?.seconds, + }) + } +} + +impl Repo { + /// Newest commit touching each of `rels`, like `git log -1 -- ` per path. + /// `rels` are paths relative to the worktree. + /// + /// 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()); }; - // 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)?, + 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, }; - if blob.map(|entry| entry.id().detach()) != parent_blob.map(|entry| entry.id().detach()) - { - found.push((rel.clone(), file_commit(&commit)?)); - pending.swap_remove(ix); - } else { - ix += 1; + // 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) } - Ok(found) + /// All commits reachable from `HEAD`, newest first, with author and summary. + /// + /// Returns an empty list for a repository without any commits yet. + pub fn all_commits(&self) -> Result { + 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 }) + } + + /// Commits in the range `base`..`tip`, newest first, like `git log base..tip`. + 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) + } + + /// The commit HEAD points to, like `git log -1`. + /// + /// `Ok(None)` for a repository without commits yet, 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)?)) + } + + /// Full metadata of the commit `id`, short or full. + /// Like [`Repo::head_commit`] for an arbitrary commit. + /// + /// `Ok(None)` when the id cannot be resolved. + 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), + } + } } /// Cap on [`CommitList::commits`]. The virtual list renders a window at a time, @@ -155,94 +222,3 @@ pub struct CommitList { /// Newest commits, capped at [`MAX_LISTED_COMMITS`]. pub commits: Vec, } - -/// All commits reachable from `HEAD`, newest first, with author and summary. -/// -/// Returns an empty list for a repository without any commits yet. -pub fn all_commits(repo: &gix::Repository) -> Result { - use gix::traverse::commit::simple::CommitTimeOrder; - - let Some(head) = repo.head_id().ok() else { - return Ok(CommitList { - total: 0, - commits: Vec::new(), - }); - }; - - let walk = repo - .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(file_commit_summary(&info.object()?)?); - } - } - - Ok(CommitList { total, commits }) -} - -/// For non-bare clones the clone root is the worktree. -pub fn worktree_all_commits(workdir: &Path) -> Result { - all_commits(&open_with_cache(workdir)?) -} - -/// Commits in the range `base`..`tip`, newest first, like `git log base..tip`. -pub fn worktree_commit_range_commits( - workdir: &Path, - base: &str, - tip: &str, -) -> Result> { - use gix::traverse::commit::simple::CommitTimeOrder; - - let repo = open_with_cache(workdir)?; - let base_id = repo.rev_parse_single(base.as_bytes())?; - let tip_id = repo.rev_parse_single(tip.as_bytes())?; - let walk = repo - .rev_walk([tip_id]) - .sorting(gix::revision::walk::Sorting::ByCommitTime( - CommitTimeOrder::NewestFirst, - )) - .with_hidden([base_id]); - - let mut commits = Vec::new(); - - for info in walk.all()? { - let info = info?; - commits.push(file_commit_summary(&info.object()?)?); - } - - Ok(commits) -} -/// The commit HEAD points to, like `git log -1`. -/// -/// `Ok(None)` for a repository without commits yet, an unborn HEAD. -pub fn head_commit(repo: &gix::Repository) -> Result> { - let Some(head) = repo.head_id().ok() else { - return Ok(None); - }; - let commit = head.object()?.into_commit(); - Ok(Some(file_commit(&commit)?)) -} - -/// Full metadata of the commit `id`, short or full, in the repository at `workdir`. -/// Like [`head_commit`] for an arbitrary commit. -/// -/// `Ok(None)` when the id cannot be resolved. -pub fn worktree_commit(workdir: &Path, id: &str) -> Result> { - let repo = gix::open(workdir)?; - match repo.rev_parse_single(id.as_bytes()) { - Ok(commit_id) => { - let commit = commit_id.object()?.into_commit(); - Ok(Some(file_commit(&commit)?)) - } - Err(_) => Ok(None), - } -} diff --git a/crates/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs index 470aaee..c967fe4 100644 --- a/crates/signed_git/src/lib.rs +++ b/crates/signed_git/src/lib.rs @@ -11,41 +11,18 @@ mod worktree; #[cfg(test)] mod tests; -pub use cache::{GitCache, fork_namespace, sanitize_path_component}; -pub use diff::{ - CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileDiff, worktree_commit_diff, - worktree_commit_range_diff, -}; -pub use history::{ - CommitList, FileCommit, MAX_LISTED_COMMITS, all_commits, head_commit, worktree_all_commits, - worktree_commit, worktree_commit_range_commits, worktree_last_commits, -}; -pub use nip34::{ - GraspSignals, Nip34Binding, Nip34Kind, detect_nip34, is_grasp_url, set_nostr_repo, -}; -pub use patch::{ - apply_patch, format_patch_between, patch_commits, patch_diffs, split_patch_series, -}; -pub use remote::{ - clone_repo, ensure_origin, fetch_all, fetch_repo_refs, origin_url, push_all, push_commit_ref, - push_main, remote_has_refs, set_origin, -}; -pub use repo::{ - RepoRefState, commits_since, current_branch, delete_refs_with_prefix, fast_forward_branches, - head_commit_id, init_repository, merge_base, refs_with_prefix, repo_branches, repo_ref_state, - repo_tags, root_commit, worktree_branches, worktree_current_branch, worktree_ref_exists, - worktree_ref_state, -}; +pub use cache::GitCache; +pub use diff::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileDiff}; +pub use history::{CommitList, FileCommit, MAX_LISTED_COMMITS}; +pub use nip34::{GraspSignals, Nip34Binding, Nip34Kind}; +pub use patch::PatchParser; +pub use repo::{Repo, RepoRefState}; pub use scan::{LocalRepo, find_git_repos}; -pub use worktree::{ - WorktreeSnapshot, find_readme, worktree_checkout_branch, worktree_checkout_tag, - worktree_commits_ahead, worktree_dirty, worktree_entries, worktree_read, worktree_snapshot, -}; +pub use worktree::WorktreeSnapshot; -/// The terminal prompt is disabled so a credential request fails instead of hanging. #[cfg(test)] fn git_in(dir: &std::path::Path, args: &[&str]) -> anyhow::Result { - let output = remote::git_output(dir, args, "git")?; + let output = Repo::run_git(dir, args, "git")?; if !output.status.success() { anyhow::bail!( diff --git a/crates/signed_git/src/nip34.rs b/crates/signed_git/src/nip34.rs index 7c50c13..36aa032 100644 --- a/crates/signed_git/src/nip34.rs +++ b/crates/signed_git/src/nip34.rs @@ -1,9 +1,9 @@ -use std::path::Path; - use anyhow::Result; use gix::bstr::ByteSlice; use nostr::prelude::*; +use crate::repo::Repo; + /// The kind of NIP-34 relationship a local repository has on disk. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Nip34Kind { @@ -53,222 +53,225 @@ struct Nip34Json { owner: Option, } -pub fn detect_nip34(repo_path: &Path) -> Option { - let repo = gix::open(repo_path).ok()?; - let common_dir = repo.common_dir().to_path_buf(); - let workdir = repo.workdir().map(Path::to_path_buf); +impl Repo { + /// What the repository's on-disk state says about its NIP-34 binding. + /// + /// `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(); + 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(non_empty); - owner = config - .owner - .as_deref() - .and_then(|value| PublicKey::parse(value).ok()); - } + 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)) = 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 workdir.join("maintainers.yaml").is_file() { + signals.maintainers_yaml = 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/"); + if let Ok(exclude) = std::fs::read_to_string(common_dir.join("info/exclude")) + && exclude.contains("nip34.json") + { + signals.nip34_excluded = true; + } - for url in section.values("url") { - let url = url.to_str_lossy(); + // `ngit` keeps its repository event cache in the Git common directory. + if common_dir.join("nostr-cache.lmdb").is_file() { + signals.nostr_cache = true; + } - 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)) = parse_nostr_url(&url) - { - owner = Some(key); - identifier = Some(id); + // `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 is_grasp_url(&url) { - signals.grasp_remote = true; - signals.nip34_grasp_remote |= nak_grasp_remote; - grasp_urls.push(url.to_string()); + 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)) = grasp_parts(&url) - { - owner = Some(key); - identifier = Some(id); + 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 { + kind, + signals, + owner, + identifier, + grasp_urls, + }) } - // `nak` materializes a kind-30618 state as `refs/heads/nip34/state/*`. - if let Ok(platform) = repo.references() - && let Ok(mut refs) = platform.prefixed(b"refs/heads/nip34/state/") - && refs.next().is_some() - { - signals.nip34_state_refs = true; + /// Record the repository's NIP-34 coordinate in its local `nostr.repo` config. + pub fn set_nostr_repo(&self, naddr: &str) -> Result<()> { + self.edit_local_config(|config| { + config.set_raw_value("nostr.repo", naddr)?; + Ok(()) + }) } - if !signals.any() { - return None; + /// 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() } - 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 - }; + 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()); - Some(Nip34Binding { - kind, - signals, - owner, - identifier, - grasp_urls, - }) -} + let owner = PublicKey::parse(segments.next()?).ok()?; + let identifier = Self::non_empty(segments.next()?.trim_end_matches(".git"))?; -/// Record a repository's NIP-34 coordinate in its local `nostr.repo` config. -pub fn set_nostr_repo(repo_path: &Path, naddr: &str) -> Result<()> { - let repo = gix::open(repo_path)?; - - crate::remote::edit_local_config(&repo, |config| { - config.set_raw_value("nostr.repo", naddr)?; - Ok(()) - }) -} - -/// Mirrors `nak`'s `IsGraspURL`: two path segments, a path of at least 65 bytes, -/// and a first segment that decodes as an `npub`. -pub 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; + Some((owner, identifier)) } - let path = parsed.path(); - if path.matches('/').count() != 2 || path.len() < 65 { - return false; + 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)) } - grasp_parts(url).is_some() -} + /// 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://")?; -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()); + if rest.starts_with("naddr1") { + return Self::coordinate_from_naddr(rest); + } - let owner = PublicKey::parse(segments.next()?).ok()?; - let identifier = non_empty(segments.next()?.trim_end_matches(".git"))?; + let rest = rest.rsplit_once('@').map_or(rest, |(_, after)| after); + let mut parts: Vec<&str> = rest.split('/').filter(|part| !part.is_empty()).collect(); - Some((owner, identifier)) -} + if parts + .first() + .is_some_and(|first| matches!(*first, "ssh" | "https" | "http")) + { + parts.remove(0); + } -fn coordinate_from_naddr(value: &str) -> Option<(PublicKey, String)> { - let coordinate = Nip19Coordinate::from_bech32(value).ok()?; - if coordinate.kind != Kind::GitRepoAnnouncement { - return None; + // `[owner, (relay), identifier]`. + 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)) } - let identifier = 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 coordinate_from_naddr(rest); + fn non_empty(value: impl Into) -> Option { + let value = value.into(); + (!value.is_empty()).then_some(value) } - - 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); - } - - // `[owner, (relay), identifier]`. - if parts.len() < 2 { - return None; - } - - let owner = PublicKey::parse(parts[0]).ok()?; - let identifier = 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::*; @@ -306,10 +309,14 @@ mod tests { .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!(detect_nip34(&path).is_none()); + assert!(binding_of(&path).is_none()); } #[test] @@ -323,7 +330,7 @@ mod tests { ) .expect("write"); - let binding = detect_nip34(&path).expect("binding"); + let binding = binding_of(&path).expect("binding"); assert_eq!(binding.kind, Nip34Kind::Initialized); assert!(binding.signals.nip34_json); assert_eq!(binding.owner, Some(owner)); @@ -335,7 +342,7 @@ mod tests { let (_dir, path) = init_repo(); std::fs::write(path.join("nip34.json"), b"not json").expect("write"); - assert!(detect_nip34(&path).is_none()); + assert!(binding_of(&path).is_none()); } #[test] @@ -351,7 +358,7 @@ mod tests { &["update-ref", "refs/heads/nip34/state/HEAD", "HEAD"], ); - let binding = detect_nip34(&path).expect("binding"); + let binding = binding_of(&path).expect("binding"); assert_eq!(binding.kind, Nip34Kind::Initialized); assert!(binding.signals.nip34_excluded); assert!(binding.signals.nip34_state_refs); @@ -364,7 +371,7 @@ mod tests { let naddr = naddr(Kind::GitRepoAnnouncement, owner, "my-repo"); git(&path, &["config", "nostr.repo", &naddr]); - let binding = detect_nip34(&path).expect("binding"); + 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)); @@ -377,9 +384,12 @@ mod tests { let owner = key(); let naddr = naddr(Kind::GitRepoAnnouncement, owner, "my-repo"); - set_nostr_repo(&path, &naddr).expect("write marker"); + Repo::open(&path) + .expect("open") + .set_nostr_repo(&naddr) + .expect("write marker"); - let binding = detect_nip34(&path).expect("binding"); + 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)); @@ -394,7 +404,7 @@ mod tests { let url = format!("nostr://{npub}/relay.ngit.dev/my-repo"); git(&path, &["remote", "add", "origin", &url]); - let binding = detect_nip34(&path).expect("binding"); + let binding = binding_of(&path).expect("binding"); assert_eq!(binding.kind, Nip34Kind::Cloned); assert!(binding.signals.nostr_remote); assert_eq!(binding.owner, Some(owner)); @@ -412,7 +422,7 @@ mod tests { &["config", "remote.nip34/grasp/gitnostr.com.url", &url], ); - let binding = detect_nip34(&path).expect("binding"); + let binding = binding_of(&path).expect("binding"); assert_eq!(binding.kind, Nip34Kind::Initialized); assert!(binding.signals.nip34_grasp_remote); assert!(binding.signals.grasp_remote); @@ -426,7 +436,7 @@ mod tests { let (_dir, path) = init_repo(); std::fs::write(path.join(".git/nostr-cache.lmdb"), b"cache").expect("write"); - let binding = detect_nip34(&path).expect("binding"); + let binding = binding_of(&path).expect("binding"); assert_eq!(binding.kind, Nip34Kind::ToolingOnly); assert!(binding.signals.nostr_cache); } @@ -436,18 +446,18 @@ mod tests { let owner = key(); let npub = owner.to_bech32().expect("npub"); - assert!(is_grasp_url(&format!( + assert!(Repo::is_grasp_url(&format!( "https://gitnostr.com/{npub}/my-repo.git" ))); - assert!(is_grasp_url(&format!( + assert!(Repo::is_grasp_url(&format!( "grasp://gitnostr.com/{npub}/my-repo.git" ))); - assert!(!is_grasp_url("https://gitnostr.com/my-repo.git")); - assert!(!is_grasp_url( + 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!(!is_grasp_url(&format!( + 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 cf67bdc..05a1216 100644 --- a/crates/signed_git/src/patch.rs +++ b/crates/signed_git/src/patch.rs @@ -1,6 +1,5 @@ use std::io::Write; -use std::path::Path; -use std::process::{Command, Stdio}; +use std::process::Stdio; use anyhow::{Context, Result, bail}; use diffy::patch_set::{FileOperation, FilePatch, ParseOptions, PatchSet}; @@ -8,315 +7,372 @@ use diffy::{Hunk, Line}; use crate::diff::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileDiff}; use crate::history::FileCommit; +use crate::repo::Repo; -/// Apply a `git format-patch` patch or series with `git am`. -/// -/// Uses the git CLI because it handles the mbox format natively. -/// -/// TODO: replace with a pure-Rust implementation later without changing callers. -pub fn apply_patch(repo_path: &Path, patch: &str) -> Result<()> { - let mut child = Command::new("git") - .arg("am") - .current_dir(repo_path) - .stdin(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .context("failed to spawn `git am`")?; +/// Pure parsing of `git format-patch` output, no repository involved. +pub struct PatchParser; - child - .stdin - .as_mut() - .expect("stdin piped") - .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)); +impl PatchParser { + /// Split a `git format-patch` series into its individual patches, mbox messages. + /// + /// A single patch yields one element. + /// A malformed input yields one element covering it. + pub fn split_patch_series(patch: &str) -> Vec<&str> { + Self::envelopes(patch) + .into_iter() + .map(|message| message.text) + .collect() } - Ok(()) -} - -/// The `git format-patch` mbox series of `base..tip`, like `git format-patch --stdout`. -/// Fails when the range has no commits. -/// -/// The mbox is returned untrimmed. Trailing newlines are part of the format. -pub fn format_patch_between(repo_path: &Path, base: &str, tip: &str) -> Result { - let output = Command::new("git") - .arg("-C") - .arg(repo_path) - .args(["format-patch", "--stdout", &format!("{base}..{tip}")]) - .env("GIT_TERMINAL_PROMPT", "0") - .stderr(Stdio::piped()) - .output() - .context("failed to spawn `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) -} - -/// Split a `git format-patch` series into its individual patches, mbox messages. -/// -/// A single patch yields one element. -/// A malformed input yields one element covering it. -pub fn split_patch_series(patch: &str) -> Vec<&str> { - let mut starts = vec![0usize]; - let mut search_from = 1; - - while let Some(rel) = patch[search_from..].find("\nFrom ") { - let ix = search_from + rel + 1; - let hex = patch[ix + 5..] - .split(|c: char| !c.is_ascii_hexdigit()) - .next() - .unwrap_or(""); - if hex.len() == 40 { - starts.push(ix); + /// Parse `git format-patch` output, a single patch or a series. + /// + /// Backed by [`diffy::patch_set`], which implements git's extended diff format: + /// `diff --git` headers, rename and copy detection, binary detection, and + /// C-style quoted or octal-escaped paths. + pub fn patch_diffs(patch: &str) -> Result { + if !patch.lines().any(|line| line.starts_with("diff --git ")) { + return Ok(CommitDiff { files: Vec::new() }); } - search_from = ix + 1; + + let mut files = Vec::new(); + + for file in PatchSet::parse(patch, ParseOptions::gitdiff()) { + files.push(Self::file_diff(file?)?); + } + + Ok(CommitDiff { files }) } - starts - .iter() - .enumerate() - .map(|(i, &start)| { - let end = starts.get(i + 1).copied().unwrap_or(patch.len()); - &patch[start..end] + /// Commits of a `git format-patch` output, a single patch or a series. + /// + /// Entries appear in patch order, oldest first as `git format-patch` produces them. + 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), + }) + .collect() + } + + /// Splits a `git format-patch` mbox into its messages by their envelopes, + /// the one parser [`Self::split_patch_series`] and [`Self::patch_commits`] share. + /// + /// A malformed input yields one message covering the whole input. + fn envelopes(patch: &str) -> Vec> { + let mut messages: Vec> = Vec::new(); + // Byte offset of the current envelope line, its commit id, and its headers. + let mut current: Option<(usize, &str, Vec<&str>)> = None; + // Headers run up to the blank line before the commit message. + let mut headers_closed = false; + + let mut offset = 0usize; + for line in patch.lines() { + let line_start = offset; + offset += line.len() + 1; + + // A message starts at its `From ` envelope line. + let is_envelope = line + .strip_prefix("From ") + .and_then(|rest| rest.split_whitespace().next()) + .is_some_and(|id| id.len() == 40); + + if is_envelope { + if let Some((start, id, headers)) = current.take() { + messages.push(Envelope { + text: &patch[start..], + id, + headers, + }); + } + + let id = line + .strip_prefix("From ") + .and_then(|rest| rest.split_whitespace().next()) + .unwrap_or(""); + + current = Some((line_start, id, Vec::new())); + headers_closed = false; + continue; + } + + if let Some((_, _, headers)) = &mut current { + if headers_closed { + continue; + } + if line.is_empty() { + headers_closed = true; + } else { + headers.push(line); + } + } + } + + if let Some((start, id, headers)) = current.take() { + messages.push(Envelope { + text: &patch[start..], + id, + headers, + }); + } + + if messages.is_empty() { + // Not an mbox at all: one message covering the whole input. + messages.push(Envelope { + text: patch, + id: "", + headers: Vec::new(), + }); + } + + messages + } + + fn name_from_address(from: &str) -> String { + match from.trim().find('<') { + Some(ix) => from[..ix].trim().to_string(), + None => from.trim().to_string(), + } + } + + /// Strip the patch prefix from a `Subject:` header. + /// + /// Examples are `[PATCH]`, `[PATCH 1/2]` and `[RFC PATCH]`. + fn strip_patch_prefix(subject: &str) -> String { + let trimmed = subject.trim(); + let Some(rest) = trimmed.strip_prefix('[') else { + return trimmed.to_string(); + }; + let Some(end) = rest.find(']') else { + return trimmed.to_string(); + }; + if rest[..end].to_ascii_lowercase().contains("patch") { + rest[end + 1..].trim().to_string() + } else { + trimmed.to_string() + } + } + + fn file_diff(file: FilePatch<'_, str>) -> Result { + // The `---`/`+++` paths carry the `a/`/`b/` prefix, so the first path + // component is dropped, the same way `git apply -p1` does. + // Rename and copy paths come from their own headers, unprefixed. + let stripped; + let operation = match file.operation() { + operation @ (FileOperation::Rename { .. } | FileOperation::Copy { .. }) => operation, + operation => { + stripped = operation.strip_prefix(1); + &stripped + } + }; + + let (path, old_path, status) = match operation { + FileOperation::Create(path) => (path.as_ref(), None, DiffStatus::Added), + FileOperation::Delete(path) => (path.as_ref(), None, DiffStatus::Deleted), + FileOperation::Modify { modified, .. } => { + (modified.as_ref(), None, DiffStatus::Modified) + } + FileOperation::Rename { from, to } => { + (to.as_ref(), Some(from.as_ref()), DiffStatus::Renamed) + } + FileOperation::Copy { from, to } => { + (to.as_ref(), Some(from.as_ref()), DiffStatus::Copied) + } + }; + + let mut insertions = 0usize; + let mut deletions = 0usize; + let mut hunks = Vec::new(); + + let patch = file.patch(); + + if let Some(text) = patch.as_text() { + for hunk in text.hunks() { + let hunk = Self::hunk_diff(hunk); + insertions += hunk + .lines + .iter() + .filter(|line| line.kind == DiffLineKind::Addition) + .count(); + deletions += hunk + .lines + .iter() + .filter(|line| line.kind == DiffLineKind::Deletion) + .count(); + hunks.push(hunk); + } + } + + Ok(FileDiff { + path: path.to_owned(), + old_path: old_path.map(str::to_owned), + status, + insertions, + deletions, + binary: patch.is_binary(), + hunks, }) - .collect() -} - -/// Parse `git format-patch` output, a single patch or a series. -/// -/// Backed by [`diffy::patch_set`], which implements git's extended diff format: -/// `diff --git` headers, rename and copy detection, binary detection, and -/// C-style quoted or octal-escaped paths. -pub fn patch_diffs(patch: &str) -> Result { - if !patch.lines().any(|line| line.starts_with("diff --git ")) { - return Ok(CommitDiff { files: Vec::new() }); } - let mut files = Vec::new(); + /// The [`DiffHunk`] of one parsed hunk, including the line number of every line. + /// + /// `diffy` reports only the hunk header ranges. The per-line numbers are + /// counted from them the way the header encodes them: context lines advance + /// both sides, deletions only the old, insertions only the new. + fn hunk_diff(hunk: &Hunk<'_, str>) -> DiffHunk { + let old_range = hunk.old_range(); + let new_range = hunk.new_range(); - for file in PatchSet::parse(patch, ParseOptions::gitdiff()) { - files.push(file_diff(file?)?); - } + let mut old = old_range.start() as u32; + let mut new = new_range.start() as u32; + let mut lines = Vec::with_capacity(hunk.lines().len()); - Ok(CommitDiff { files }) -} + for line in hunk.lines() { + let (kind, text) = match line { + Line::Context(text) => (DiffLineKind::Context, *text), + Line::Delete(text) => (DiffLineKind::Deletion, *text), + Line::Insert(text) => (DiffLineKind::Addition, *text), + }; -fn file_diff(file: FilePatch<'_, str>) -> Result { - // The `---`/`+++` paths carry the `a/`/`b/` prefix, so the first path - // component is dropped, the same way `git apply -p1` does. - // Rename and copy paths come from their own headers, unprefixed. - let stripped; - let operation = match file.operation() { - operation @ (FileOperation::Rename { .. } | FileOperation::Copy { .. }) => operation, - operation => { - stripped = operation.strip_prefix(1); - &stripped + let (old_no, new_no) = match kind { + DiffLineKind::Context => { + let numbers = (Some(old), Some(new)); + old += 1; + new += 1; + numbers + } + DiffLineKind::Addition => { + let number = Some(new); + new += 1; + (None, number) + } + DiffLineKind::Deletion => { + let number = Some(old); + old += 1; + (number, None) + } + }; + + lines.push(DiffLine { + kind, + old: old_no, + new: new_no, + text: Self::line_text(text), + }); } - }; - let (path, old_path, status) = match operation { - FileOperation::Create(path) => (path.as_ref(), None, DiffStatus::Added), - FileOperation::Delete(path) => (path.as_ref(), None, DiffStatus::Deleted), - FileOperation::Modify { modified, .. } => (modified.as_ref(), None, DiffStatus::Modified), - FileOperation::Rename { from, to } => { - (to.as_ref(), Some(from.as_ref()), DiffStatus::Renamed) - } - FileOperation::Copy { from, to } => (to.as_ref(), Some(from.as_ref()), DiffStatus::Copied), - }; - - let mut insertions = 0usize; - let mut deletions = 0usize; - let mut hunks = Vec::new(); - - let patch = file.patch(); - - if let Some(text) = patch.as_text() { - for hunk in text.hunks() { - let hunk = hunk_diff(hunk); - insertions += hunk - .lines - .iter() - .filter(|line| line.kind == DiffLineKind::Addition) - .count(); - deletions += hunk - .lines - .iter() - .filter(|line| line.kind == DiffLineKind::Deletion) - .count(); - hunks.push(hunk); + 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, + lines, } } - Ok(FileDiff { - path: path.to_owned(), - old_path: old_path.map(str::to_owned), - status, - insertions, - deletions, - binary: patch.is_binary(), - hunks, - }) -} - -/// The [`DiffHunk`] of one parsed hunk, including the line number of every line. -/// -/// `diffy` reports only the hunk header ranges. The per-line numbers are -/// counted from them the way the header encodes them: context lines advance -/// both sides, deletions only the old, insertions only the new. -fn hunk_diff(hunk: &Hunk<'_, str>) -> DiffHunk { - let old_range = hunk.old_range(); - let new_range = hunk.new_range(); - - let mut old = old_range.start() as u32; - let mut new = new_range.start() as u32; - let mut lines = Vec::with_capacity(hunk.lines().len()); - - for line in hunk.lines() { - let (kind, text) = match line { - Line::Context(text) => (DiffLineKind::Context, *text), - Line::Delete(text) => (DiffLineKind::Deletion, *text), - Line::Insert(text) => (DiffLineKind::Addition, *text), - }; - - let (old_no, new_no) = match kind { - DiffLineKind::Context => { - let numbers = (Some(old), Some(new)); - old += 1; - new += 1; - numbers - } - DiffLineKind::Addition => { - let number = Some(new); - new += 1; - (None, number) - } - DiffLineKind::Deletion => { - let number = Some(old); - old += 1; - (number, None) - } - }; - - lines.push(DiffLine { - kind, - old: old_no, - new: new_no, - text: line_text(text), - }); - } - - 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, - lines, + /// The content of a parsed line without its line ending. + /// + /// `diffy` keeps the trailing `\n`, the way `str::lines` splits it off. + fn line_text(text: &str) -> String { + let text = text.strip_suffix('\n').unwrap_or(text); + text.strip_suffix('\r').unwrap_or(text).to_owned() } } -/// The content of a parsed line without its line ending. -/// -/// `diffy` keeps the trailing `\n`, the way `str::lines` splits it off. -fn line_text(text: &str) -> String { - let text = text.strip_suffix('\n').unwrap_or(text); - text.strip_suffix('\r').unwrap_or(text).to_owned() +/// A `git format-patch` mbox message, split on its `From <40-hex> ` envelope. +struct Envelope<'a> { + /// The whole message, envelope and diff. + text: &'a str, + /// Commit id from the `From ` line. + id: &'a str, + /// Header lines between the envelope and the commit message. + headers: Vec<&'a str>, } -/// Commits of a `git format-patch` output, a single patch or a series. -/// -/// Entries appear in patch order, oldest first as `git format-patch` produces them. -pub fn patch_commits(patch: &str) -> Vec { - let lines: Vec<&str> = patch.lines().collect(); +impl Envelope<'_> { + fn header(&self, name: &str) -> Option<&str> { + let prefix = format!("{name}: "); + self.headers + .iter() + .find(|line| line.starts_with(&prefix)) + .map(|line| &line[prefix.len()..]) + } +} - let mut commits = Vec::new(); - let mut i = 0; +impl Repo { + /// Apply a `git format-patch` patch or series with `git am`. + /// + /// Uses the git CLI because it handles the mbox format natively. + /// + /// TODO: replace with a pure-Rust implementation later without changing callers. + pub fn apply_patch(&self, patch: &str) -> Result<()> { + let workdir = self + .inner + .workdir() + .context("repository has no worktree")? + .to_path_buf(); - while i < lines.len() { - // A patch starts with its `From ` envelope line. - let Some(rest) = lines[i].strip_prefix("From ") else { - i += 1; - continue; - }; + let mut child = Repo::git_command(&workdir) + .args(["am"]) + .stdin(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("failed to spawn `git am`")?; - let Some(id) = rest.split_whitespace().next() else { - i += 1; - continue; - }; + child + .stdin + .as_mut() + .context("git am has no stdin pipe")? + .write_all(patch.as_bytes())?; - if id.len() != 40 { - i += 1; - continue; + let output = child.wait_with_output()?; + + if !output.status.success() { + bail!("git am failed: {}", String::from_utf8_lossy(&output.stderr)); } - let mut author = String::new(); - let mut summary = String::new(); - let mut time = 0i64; + Ok(()) + } - // Envelope headers run up to the blank line before the commit message. - i += 1; - while i < lines.len() && !lines[i].is_empty() { - let header = lines[i]; - if let Some(value) = header.strip_prefix("From: ") { - author = name_from_address(value); - } else if let Some(value) = header.strip_prefix("Subject: ") { - summary = strip_patch_prefix(value); - } else if let Some(value) = header.strip_prefix("Date: ") { - time = gix::date::parse(value.trim(), None) - .map(|t| t.seconds) - .unwrap_or(0); - } - i += 1; + /// The `git format-patch` mbox series of `base..tip`, like `git format-patch --stdout`. + /// Fails when the range has no commits. + /// + /// The mbox is returned untrimmed. Trailing newlines are part of the format. + 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() + ); } - commits.push(FileCommit { - id: id.to_string(), - summary, - description: None, - author, - time, - }); - } + let patch = String::from_utf8_lossy(&output.stdout).into_owned(); - commits -} + if patch.trim().is_empty() { + bail!("no commits between {base} and {tip}"); + } -fn name_from_address(from: &str) -> String { - match from.trim().find('<') { - Some(ix) => from[..ix].trim().to_string(), - None => from.trim().to_string(), - } -} - -/// Strip the patch prefix from a `Subject:` header. -/// -/// Examples are `[PATCH]`, `[PATCH 1/2]` and `[RFC PATCH]`. -fn strip_patch_prefix(subject: &str) -> String { - let trimmed = subject.trim(); - let Some(rest) = trimmed.strip_prefix('[') else { - return trimmed.to_string(); - }; - let Some(end) = rest.find(']') else { - return trimmed.to_string(); - }; - if rest[..end].to_ascii_lowercase().contains("patch") { - rest[end + 1..].trim().to_string() - } else { - trimmed.to_string() + Ok(patch) } } diff --git a/crates/signed_git/src/remote.rs b/crates/signed_git/src/remote.rs index 78d2452..3c78ffe 100644 --- a/crates/signed_git/src/remote.rs +++ b/crates/signed_git/src/remote.rs @@ -6,346 +6,304 @@ use anyhow::{Context, Result, bail}; use gix::interrupt::IS_INTERRUPTED; use gix::progress::Discard; -/// Clone into `path` from the first working URL in `clone_urls`. -/// -/// Unlike [`GitCache::ensure_clone`], the clone is not kept in any cache. -pub fn clone_repo>(clone_urls: &[U], path: &Path) -> Result<()> { - if path.exists() { - bail!("destination {} already exists", path.display()); - } +use crate::repo::Repo; - try_each_url(clone_urls, "clone", |url| { - let repo = clone(url, path)?; - // The initial clone uses the default refspecs. Also fetch the `refs/nostr/*` PR refs. - fetch_all(&repo).ok(); - Ok(()) - }) -} - -/// Fetch all configured refspecs from `origin`, plus the `refs/nostr/*` namespace. -pub fn fetch_all(repo: &gix::Repository) -> Result<()> { - let options = gix::remote::ref_map::Options { - extra_refspecs: vec![ - gix::refspec::parse( - gix::bstr::BStr::new("+refs/nostr/*:refs/nostr/*"), - gix::refspec::parse::Operation::Fetch, - )? - .to_owned(), - ], - ..Default::default() - }; - repo.find_remote("origin")? - .connect(gix::remote::Direction::Fetch)? - .prepare_fetch(Discard, options)? - .receive(Discard, &IS_INTERRUPTED)?; - Ok(()) -} - -pub fn push_commit_ref(repo_path: &Path, url: &str, commit: &str, reference: &str) -> Result<()> { - let output = Command::new("git") - .arg("-C") - .arg(repo_path) - .args(["push"]) - .arg(url) - .arg(format!("{commit}:{reference}")) - .env("GIT_TERMINAL_PROMPT", "0") - .stderr(Stdio::piped()) - .output() - .context("failed to spawn `git push`")?; - - if !output.status.success() { - bail!( - "git push failed: {}", - String::from_utf8_lossy(&output.stderr).trim() - ); - } - Ok(()) -} - -/// Rewrite a grasp server URL to the https URL the git transport actually uses. -/// -/// GRASP servers announce `grasp:////` clone URLs. -/// The transport is git smart HTTP, so the scheme is rewritten for gix. -fn transport_url(url: &str) -> String { - url.strip_prefix("grasp://") - .map(|rest| format!("https://{rest}")) - .unwrap_or_else(|| url.to_owned()) -} - -/// Run `attempt` against each URL in `urls` until one succeeds. -/// -/// Returns the last error wrapped in `failed to {verb} from any mirror`, -/// or `no clone URLs provided` when the list is empty. -fn try_each_url, F>(urls: &[U], verb: &str, mut attempt: F) -> Result<()> -where - F: FnMut(&str) -> Result<()>, -{ - let mut last_err: Option = None; - - for url in urls { - match attempt(url.as_ref()) { - Ok(()) => return Ok(()), - Err(e) => last_err = Some(e), - } - } - - match last_err { - Some(e) => Err(e).context(format!("failed to {verb} from any mirror")), - None => bail!("no clone URLs provided"), - } -} - -fn clone(url: &str, path: &Path) -> Result { - let url = transport_url(url); - let url = gix::url::parse(url).context("invalid clone URL")?; - - let mut prepare = gix::prepare_clone(url, path)?; - let (mut checkout, _fetch) = prepare.fetch_then_checkout(Discard, &IS_INTERRUPTED)?; - let (repo, _checkout) = checkout.main_worktree(Discard, &IS_INTERRUPTED)?; - - Ok(repo) -} - -pub fn push_main(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> { - push_refspecs( - repo_path, - base_url, - owner, - repo_id, - &["refs/heads/main:refs/heads/main"], - ) -} - -/// Push every local branch and tag of the repository at `repo_path` to a grasp server. -/// -/// This mirrors an initialized repository's whole history. -pub fn push_all(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> { - push_refspecs( - repo_path, - base_url, - owner, - repo_id, - &["refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"], - ) -} - -fn push_refspecs( - repo_path: &Path, - 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 = git_output(repo_path, &args, "git push")?; - - if !output.status.success() { - bail!( - "git push to {base_url} failed: {}", - String::from_utf8_lossy(&output.stderr).trim() - ); - } - Ok(()) -} - -/// Whether `url` advertises every ref in `expected` at the given commit. -/// -/// Extra advertised refs are ignored: the question is whether the data this -/// push wanted to land is already there, not whether the remote is an exact mirror. -/// This is the convergence probe for a push that lost the compare-and-swap race -/// to the grasp server's own background ref alignment. -pub fn remote_has_refs(repo_path: &Path, url: &str, expected: &[(String, String)]) -> Result { - if expected.is_empty() { - return Ok(true); - } - - let repo = gix::open(repo_path)?; - let url = transport_url(url); - - // A URL-created remote has no configured fetch refspecs, and `ref_map` only - // keeps refs that match one. Match each expected ref by its exact name, - // like `git ls-remote ` would; ref maps never write to the repository. - 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::, _>>() - .context("invalid refspec")?; - - let options = gix::remote::ref_map::Options { - extra_refspecs: refspecs, - ..Default::default() - }; - - let (refs, _) = repo - .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, so mapping - // each advertised ref to its direct oid matches `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))) -} - -/// Add `origin` pointing at `url` when the repository has no remote yet. -/// -/// No-op if `origin` already exists. -pub fn ensure_origin(repo_path: &Path, url: &str) -> Result<()> { - let repo = gix::open(repo_path)?; - if repo.find_remote("origin").is_ok() { - return Ok(()); - } - - // `git remote add` also configures the default fetch refspec. - edit_local_config(&repo, |config| { - config.set_raw_value("remote.origin.url", url)?; - config.set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")?; - Ok(()) - }) -} - -/// Point `origin` at `url`, replacing an existing remote, -/// used after a clone whose `origin` points at the cloned-from path. -/// -/// A working copy cloned from a local mirror is re-targeted at the grasp server. -pub fn set_origin(repo_path: &Path, url: &str) -> Result<()> { - let repo = gix::open(repo_path)?; - let had_origin = repo.find_remote("origin").is_ok(); - - edit_local_config(&repo, |config| { - // Replaces the existing url, like `git remote set-url origin `. - // A pre-existing fetch refspec is left untouched. - config.set_raw_value("remote.origin.url", url)?; - - if !had_origin { - config.set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")?; - } - - Ok(()) - }) -} - -/// Apply `edit` to the repository-local configuration and persist it. -pub(crate) fn edit_local_config( - repo: &gix::Repository, - edit: impl FnOnce(&mut gix::config::File) -> Result<()>, -) -> Result<()> { - let config_path = repo.common_dir().join("config"); - - let mut lock = gix::lock::File::acquire_to_update_resource( - &config_path, - gix::lock::acquire::Fail::Immediately, - None, - ) - .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, - // A repository without a config file yet starts from scratch. - Err(gix::config::file::init::from_paths::Error::Io { source, .. }) - if source.kind() == std::io::ErrorKind::NotFound => - { - gix::config::File::default() - } - Err(error) => return Err(error).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(()) -} - -/// Fetch `refspec` into `repo_path` from the first working URL in `urls`. -/// When no URL works, the last error is returned. -/// -/// Never touches the checked-out refs or the worktree. -pub fn fetch_repo_refs>(repo_path: &Path, urls: &[U], refspec: &str) -> Result<()> { - let repo = gix::open(repo_path)?; - let refspec = gix::refspec::parse( - gix::bstr::BStr::new(refspec), - gix::refspec::parse::Operation::Fetch, - ) - .context("invalid fetch refspec")? - .to_owned(); - - try_each_url(urls, "fetch", |url| { - let url = transport_url(url); +impl Repo { + /// Fetch all configured refspecs from `origin`, plus the `refs/nostr/*` namespace. + pub fn fetch(&self) -> Result<()> { let options = gix::remote::ref_map::Options { - extra_refspecs: vec![refspec.clone()], + extra_refspecs: vec![ + gix::refspec::parse( + gix::bstr::BStr::new("+refs/nostr/*:refs/nostr/*"), + gix::refspec::parse::Operation::Fetch, + )? + .to_owned(), + ], ..Default::default() }; - repo.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"))?; + self.inner + .find_remote("origin")? + .connect(gix::remote::Direction::Fetch)? + .prepare_fetch(Discard, options)? + .receive(Discard, &IS_INTERRUPTED)?; Ok(()) - }) -} - -/// The URL of the `origin` remote of the repository at `workdir`. -/// -/// `None` when it has no `origin` yet. -pub fn origin_url(workdir: &Path) -> Result> { - let Ok(repo) = gix::open(workdir) else { - return Ok(None); - }; - - let Ok(remote) = repo.find_remote("origin") else { - return Ok(None); - }; - - Ok(remote - .url(gix::remote::Direction::Fetch) - .map(|url| url.to_string())) -} - -/// Run `git -C dir args`, disabling the terminal prompt and capturing stderr. -/// -/// `what` names the command in the spawn error. -pub(crate) fn git_output(dir: &Path, args: &[&str], what: &str) -> Result { - Command::new("git") - .arg("-C") - .arg(dir) - .args(args) - .env("GIT_TERMINAL_PROMPT", "0") - .stderr(Stdio::piped()) - .output() - .with_context(|| format!("failed to spawn `{what}`")) + } + + /// Push `commit` to `reference` at `url`. + pub fn push_ref(&self, url: &str, commit: &str, reference: &str) -> Result<()> { + let output = Self::run_git( + self.workdir_or_dot(), + &["push", url, &format!("{commit}:{reference}")], + "git push", + )?; + + if !output.status.success() { + bail!( + "git push failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(()) + } + + /// Push the local `main` branch to a grasp server. + pub fn push_main(&self, base_url: &str, owner: &str, repo_id: &str) -> Result<()> { + self.push_refspecs( + base_url, + owner, + repo_id, + &["refs/heads/main:refs/heads/main"], + ) + } + + /// Push every local branch and tag to a grasp server. + /// + /// This mirrors an initialized repository's whole history. + pub fn push_all(&self, base_url: &str, owner: &str, repo_id: &str) -> Result<()> { + self.push_refspecs( + base_url, + 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(()) + } + + /// Whether `url` advertises every ref in `expected` at the given commit. + /// + /// Extra advertised refs are ignored: the question is whether the data this + /// push wanted to land is already there, not whether the remote is an exact mirror. + /// This is the convergence probe for a push that lost the compare-and-swap race + /// to the grasp server's own background ref alignment. + 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); + + // A URL-created remote has no configured fetch refspecs, and `ref_map` only + // keeps refs that match one. Match each expected ref by its exact name, + // like `git ls-remote ` would; ref maps never write to the repository. + 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::, _>>() + .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, so mapping + // each advertised ref to its direct oid matches `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))) + } + + /// Add `origin` pointing at `url` when the repository has no remote yet. + /// + /// No-op if `origin` already exists. + pub fn ensure_origin(&self, url: &str) -> Result<()> { + if self.inner.find_remote("origin").is_ok() { + return Ok(()); + } + + // `git remote add` also configures the default fetch refspec. + self.edit_local_config(|config| { + config.set_raw_value("remote.origin.url", url)?; + config.set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")?; + Ok(()) + }) + } + + /// Point `origin` at `url`, replacing an existing remote, + /// used after a clone whose `origin` points at the cloned-from path. + /// + /// A working copy cloned from a local mirror is re-targeted at the grasp server. + pub fn set_origin(&self, url: &str) -> Result<()> { + let had_origin = self.inner.find_remote("origin").is_ok(); + + self.edit_local_config(|config| { + // Replaces the existing url, like `git remote set-url origin `. + // A pre-existing fetch refspec is left untouched. + config.set_raw_value("remote.origin.url", url)?; + + if !had_origin { + config + .set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")?; + } + + Ok(()) + }) + } + + /// The URL of the `origin` remote. + /// + /// `None` when the repository has no `origin` yet. + 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())) + } + + /// Fetch `refspec` from the first working URL in `urls`. + /// When no URL works, the last error is returned. + /// + /// Never touches the checked-out refs or the worktree. + 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, + ) + .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"), + } + } + + /// Apply `edit` to the repository-local configuration and persist it. + pub(crate) fn edit_local_config( + &self, + edit: impl FnOnce(&mut gix::config::File) -> Result<()>, + ) -> 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, + ) + .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, + // A repository without a config file yet starts from scratch. + Err(gix::config::file::init::from_paths::Error::Io { source, .. }) + if source.kind() == std::io::ErrorKind::NotFound => + { + gix::config::File::default() + } + Err(error) => return Err(error).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(".")) + } + + /// Run `git -C dir args`, disabling the terminal prompt and capturing stderr. + /// + /// `what` names the command in the spawn error. + pub(crate) fn run_git(dir: &Path, args: &[&str], what: &str) -> Result { + Self::git_command(dir) + .args(args) + .stderr(Stdio::piped()) + .output() + .with_context(|| format!("failed to spawn `{what}`")) + } + + /// A `git -C dir` command with the terminal prompt disabled. + pub(crate) fn git_command(dir: &Path) -> Command { + let mut command = Command::new("git"); + command.arg("-C").arg(dir).env("GIT_TERMINAL_PROMPT", "0"); + command + } } diff --git a/crates/signed_git/src/repo.rs b/crates/signed_git/src/repo.rs index 93d5998..29cbedc 100644 --- a/crates/signed_git/src/repo.rs +++ b/crates/signed_git/src/repo.rs @@ -2,342 +2,442 @@ use std::path::Path; use anyhow::{Context, Result}; -use crate::history::open_with_cache; -use crate::worktree::{force_checkout, worktree_dirty}; +/// In-memory object cache for history walks, see [`Repo::open_cached`]. +/// +/// Without one, a walk re-decodes the same commit objects from the object database. +/// Sized generously: a walk can cover a large portion of the repository's history. +const OBJECT_CACHE_BYTES: usize = 64 * 1024 * 1024; -/// The merge base of two revisions in the repository at `repo_path`, -/// revisions may be branch names, remote-tracking refs or commit ids. +/// An opened git repository. /// -/// `Ok(None)` when the revisions share no common ancestor. -/// -/// Unresolvable revisions are errors. -pub fn merge_base(repo_path: &Path, a: &str, b: &str) -> Result> { - let repo = open_with_cache(repo_path)?; - let a = repo.rev_parse_single(a.as_bytes())?; - let b = repo.rev_parse_single(b.as_bytes())?; - match repo.merge_base(a, b) { - Ok(id) => Ok(Some(id.to_string())), - // No common ancestor, a valid outcome for a proposal. - Err(gix::repository::merge_base::Error::NotFound { .. }) => Ok(None), - Err(e) => Err(e.into()), - } +/// One `open` per operation instead of every helper re-opening by path. +pub struct Repo { + pub(crate) inner: gix::Repository, } -/// The commit HEAD points to in the repository at `repo_path`. -/// -/// `None` when the repository has no commits yet, an unborn HEAD. -pub fn head_commit_id(repo_path: &Path) -> Result> { - let Ok(repo) = gix::open(repo_path) else { - return Ok(None); - }; - - match repo.head_id() { - Ok(id) => Ok(Some(id.to_string())), - Err(_) => Ok(None), - } -} - -/// The commits in `base..HEAD` of the repository at `repo_path`, oldest first. -/// This is the order `git am` creates them. -/// -/// `HEAD` alone when `base` is `None`. -pub fn commits_since(repo_path: &Path, base: Option<&str>) -> Result> { - let repo = match gix::open(repo_path) { - Ok(repo) => repo, - Err(_) if base.is_none() => return Ok(Vec::new()), - Err(e) => return Err(e.into()), - }; - - let head = match repo.head_id() { - Ok(head) => head, - Err(_) if base.is_none() => return Ok(Vec::new()), - Err(e) => return Err(e).context("repository has no commits"), - }; - - let Some(base) = base else { - return Ok(vec![head.to_string()]); - }; - - let base = repo.rev_parse_single(base.as_bytes())?; - let mut commits = Vec::new(); - - for info in repo - .rev_walk([head]) - .sorting(gix::revision::walk::Sorting::ByCommitTime( - gix::traverse::commit::simple::CommitTimeOrder::NewestFirst, - )) - .with_hidden([base]) - .all()? - { - commits.push(info?.id().to_string()); - } - - // Oldest first, like `git rev-list --reverse`, the order `git am` creates them. - commits.reverse(); - - Ok(commits) -} - -/// The identity written to reflogs and commits created by this crate itself. -/// -/// Like `git -c user.name=… -c user.email=…` per invocation: the repository works -/// without a global git identity, and `gix` runs no hooks and never signs. -pub(crate) fn repository_signature() -> (gix::actor::Signature, gix::date::parse::TimeBuf) { - let seconds = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|duration| duration.as_secs() as i64) - .unwrap_or_default(); - - let signature = gix::actor::Signature { - name: gix::bstr::BString::from("Signed"), - email: gix::bstr::BString::from("signed@localhost"), - time: gix::date::Time { seconds, offset: 0 }, - }; - - (signature, gix::date::parse::TimeBuf::default()) -} - -/// Create a repository at `path` with an initial `main` branch. -/// Write a `README.md` from `name` and `description`, then create the initial commit. -/// -/// Returns the initial commit id. -pub fn init_repository(path: &Path, name: &str, description: &str) -> Result { - use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; - - std::fs::create_dir_all(path) - .with_context(|| format!("failed to create {}", path.display()))?; - - let repo = gix::init(path)?; - - let (signature, mut time_buf) = repository_signature(); - let signature = signature.to_ref(&mut time_buf); - - // The initial branch is `main`, regardless of `init.defaultBranch` in - // the user's git configuration: point the unborn HEAD there. - let head = gix::refs::FullName::try_from("HEAD") - .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?; - - repo.edit_references_as( - [RefEdit { - change: Change::Update { - log: LogChange { - mode: RefLog::AndReference, - force_create_reflog: false, - message: "checkout: moving to main".into(), - }, - expected: PreviousValue::Any, - new: gix::refs::Target::Symbolic( - gix::refs::FullName::try_from("refs/heads/main") - .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?, - ), - }, - name: head, - deref: false, - }], - Some(signature), - )?; - - let readme = if description.trim().is_empty() { - format!("# {name}\n") - } else { - format!("# {name}\n\n{description}\n") - }; - - std::fs::write(path.join("README.md"), &readme).context("failed to write README.md")?; - - let blob = repo.write_object(gix::objs::Blob { - data: readme.into_bytes(), - })?; - - let tree = repo.write_object(gix::objs::Tree { - entries: vec![gix::objs::tree::Entry { - mode: gix::objs::tree::EntryKind::Blob.into(), - filename: gix::bstr::BString::from("README.md"), - oid: blob.into(), - }], - })?; - - let commit = repo.commit_as( - signature, - signature, - "HEAD", - "Initial commit", - tree, - 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())?; - - Ok(commit.to_string()) -} - -/// The earliest unique commit of the repository at `repo_path`. -/// Used as the NIP-34 announcement's `euc` marker. -/// -/// `None` for a repository without commits. -pub fn root_commit(repo_path: &Path) -> Result> { - let Ok(repo) = gix::open(repo_path) else { - return Ok(None); - }; - - let Ok(head) = repo.head_id() else { - return Ok(None); - }; - - for info in repo - .rev_walk([head]) - .sorting(gix::revision::walk::Sorting::ByCommitTime( - gix::traverse::commit::simple::CommitTimeOrder::NewestFirst, - )) - .all()? - { - let info = info?; - if info.parent_ids().next().is_none() { - return Ok(Some(info.id().to_string())); - } - } - - Ok(None) -} - -/// Full ref names under `prefix`, sorted lexicographically, like `git for-each-ref`. -/// `prefix` is a ref namespace like `refs/fork//`. -/// -/// Returns an empty list when nothing matches. -pub fn refs_with_prefix(repo_path: &Path, prefix: &str) -> Result> { - let pattern = prefix.trim_end_matches('/'); - let repo = gix::open(repo_path)?; - let mut names = Vec::new(); - - for reference in repo.references()?.all()? { - let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; - let name = String::from_utf8_lossy(reference.name().as_bstr()).into_owned(); - - // Match the pattern itself and everything beneath it, like `git for-each-ref`. - let under_pattern = name - .strip_prefix(pattern) - .is_some_and(|rest| rest.is_empty() || rest.starts_with('/')); - - if under_pattern { - names.push(name); - } - } - - names.sort(); - - Ok(names) -} - -/// Delete every ref under `prefix` of the repository at `repo_path`. -/// `prefix` is a ref namespace like `refs/fork//`. -pub fn delete_refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<()> { - use gix::refs::transaction::{Change, PreviousValue, RefEdit, RefLog}; - - let refs = refs_with_prefix(repo_path, prefix)?; - if refs.is_empty() { - return Ok(()); - } - - let repo = gix::open(repo_path)?; - let edits: Vec = refs - .iter() - .map(|name| { - let full = gix::refs::FullName::try_from(name.as_str()) - .map_err(|e| anyhow::anyhow!("invalid ref name {name}: {e}"))?; - Ok(RefEdit { - change: Change::Delete { - expected: PreviousValue::Any, - log: RefLog::AndReference, - }, - name: full, - deref: false, - }) +impl Repo { + /// Open the repository at `workdir`. + pub fn open(workdir: &Path) -> Result { + Ok(Self { + inner: gix::open(workdir)?, }) - .collect::>>()?; + } - repo.edit_references(edits)?; + /// Open the repository at `workdir`, `None` when the path is not one. + pub fn try_open(workdir: &Path) -> Option { + Self::open(workdir).ok() + } - Ok(()) -} + /// Open the repository at `workdir` with an in-memory object cache. + /// + /// Only history walks benefit from it, they re-decode the same commit + /// objects repeatedly. Single-object reads open the repository plain. + 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 }) + } -/// Short name of the branch HEAD points to at `workdir`, -/// `None` when detached or unreadable, like `git branch --show-current`. -pub fn worktree_current_branch(workdir: &Path) -> Option { - let repo = gix::open(workdir).ok()?; - let head = repo.head().ok()?; - let name = head.referent_name()?; - Some(String::from_utf8_lossy(name.shorten()).into_owned()) -} + /// Create a repository at `path` with an initial `main` branch. + /// Write a `README.md` from `name` and `description`, then create the initial commit. + /// + /// Returns the initial commit id. + pub fn init(path: &Path, name: &str, description: &str) -> Result { + use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; -pub fn worktree_ref_exists(workdir: &Path, name: &str) -> bool { - let Ok(repo) = gix::open(workdir) else { - return false; - }; - repo.find_reference(name).is_ok() -} + std::fs::create_dir_all(path) + .with_context(|| format!("failed to create {}", path.display()))?; -/// Fast-forward local branches that trail their remote-tracking counterpart. -/// -/// Returns whether any branch moved. -pub fn fast_forward_branches(workdir: &Path) -> Result { - let repo = gix::open(workdir)?; - let current = worktree_current_branch(workdir); - let heads = refs_with_prefix(workdir, "refs/heads")?; + let repo = gix::init(path)?; - let (signature, mut time_buf) = repository_signature(); - let signature = signature.to_ref(&mut time_buf); + let (signature, mut time_buf) = Self::repository_signature(); + let signature = signature.to_ref(&mut time_buf); - let mut moved = false; - - for head in heads { - let Some(branch) = head.strip_prefix("refs/heads/") else { - continue; - }; - - let remote = format!("refs/remotes/origin/{branch}"); - // No remote-tracking counterpart means the remote lacks this branch. - let Ok(mut remote_reference) = repo.find_reference(&remote) else { - continue; - }; - - let Ok(mut local_reference) = repo.find_reference(&head) else { - continue; - }; - - let Ok(remote_oid) = remote_reference.peel_to_id() else { - continue; - }; - - let Ok(local_oid) = local_reference.peel_to_id() else { - continue; - }; - - let remote_oid = remote_oid.detach(); - let local_oid = local_oid.detach(); - - if local_oid == remote_oid { - continue; - } - - // Only fast-forward. - // Local-only commits or diverged history must never be rewritten by a refresh. - let Ok(base) = repo.merge_base(local_oid, remote_oid) else { - continue; - }; - - if base != local_oid { - continue; - } - - let full = gix::refs::FullName::try_from(head.as_str()) + // The initial branch is `main`, regardless of `init.defaultBranch` in + // the user's git configuration: point the unborn HEAD there. + let head = gix::refs::FullName::try_from("HEAD") .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?; - let edit = |new: gix::refs::Target| { - use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; - RefEdit { + repo.edit_references_as( + [RefEdit { + change: Change::Update { + log: LogChange { + mode: RefLog::AndReference, + force_create_reflog: false, + message: "checkout: moving to main".into(), + }, + expected: PreviousValue::Any, + new: gix::refs::Target::Symbolic( + gix::refs::FullName::try_from("refs/heads/main") + .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?, + ), + }, + name: head, + deref: false, + }], + Some(signature), + )?; + + let readme = if description.trim().is_empty() { + format!("# {name}\n") + } else { + format!("# {name}\n\n{description}\n") + }; + + std::fs::write(path.join("README.md"), &readme).context("failed to write README.md")?; + + let blob = repo.write_object(gix::objs::Blob { + data: readme.into_bytes(), + })?; + + let tree = repo.write_object(gix::objs::Tree { + entries: vec![gix::objs::tree::Entry { + mode: gix::objs::tree::EntryKind::Blob.into(), + filename: gix::bstr::BString::from("README.md"), + oid: blob.into(), + }], + })?; + + let commit = repo.commit_as( + signature, + signature, + "HEAD", + "Initial commit", + tree, + 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())?; + + Ok(commit.to_string()) + } + + /// Clone into `path` from the first working URL in `clone_urls`. + /// + /// Unlike [`crate::GitCache::ensure_clone`], the clone is not kept in any cache. + pub fn clone>(clone_urls: &[U], path: &Path) -> Result { + if path.exists() { + anyhow::bail!("destination {} already exists", path.display()); + } + + let mut last_error = None; + + for url in clone_urls { + match Self::clone_from(url.as_ref(), path) { + Ok(repo) => { + // The initial clone uses the default refspecs. Also fetch the `refs/nostr/*` PR refs. + repo.fetch().ok(); + return Ok(repo); + } + Err(error) => last_error = Some(error), + } + } + + match last_error { + Some(error) => Err(error).context("failed to clone from any mirror"), + None => anyhow::bail!("no clone URLs provided"), + } + } + + fn clone_from(url: &str, path: &Path) -> Result { + let url = Self::transport_url(url); + let url = gix::url::parse(url).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)?; + + Ok(Self { inner: repo }) + } + + pub fn inner(&self) -> &gix::Repository { + &self.inner + } + + /// The worktree directory, `None` for a bare repository. + pub fn workdir(&self) -> Option<&Path> { + self.inner.workdir() + } + + /// The commit id HEAD points to. + /// + /// `None` when the repository has no commits yet, an unborn HEAD. + pub fn head(&self) -> Option { + self.inner.head_id().ok().map(|id| id.to_string()) + } + + /// The merge base of two revisions, + /// revisions may be branch names, remote-tracking refs or commit ids. + /// + /// `Ok(None)` when the revisions share no common ancestor. + /// + /// Unresolvable revisions are errors. + 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())?; + match self.inner.merge_base(a, b) { + Ok(id) => Ok(Some(id.to_string())), + // No common ancestor, a valid outcome for a proposal. + Err(gix::repository::merge_base::Error::NotFound { .. }) => Ok(None), + Err(e) => Err(e.into()), + } + } + + /// The commits in `base..HEAD`, oldest first. + /// This is the order `git am` creates them. + /// + /// `HEAD` alone when `base` is `None`. + pub fn commits_since(&self, base: Option<&str>) -> Result> { + let head = match self.inner.head_id() { + Ok(head) => head, + Err(_) if base.is_none() => return Ok(Vec::new()), + Err(e) => return Err(e).context("repository has no commits"), + }; + + let Some(base) = base else { + return Ok(vec![head.to_string()]); + }; + + let base = self.inner.rev_parse_single(base.as_bytes())?; + let mut commits = Vec::new(); + + for info in self + .inner + .rev_walk([head]) + .sorting(gix::revision::walk::Sorting::ByCommitTime( + gix::traverse::commit::simple::CommitTimeOrder::NewestFirst, + )) + .with_hidden([base]) + .all()? + { + commits.push(info?.id().to_string()); + } + + // Oldest first, like `git rev-list --reverse`, the order `git am` creates them. + commits.reverse(); + + Ok(commits) + } + + /// The earliest unique commit of the repository. + /// Used as the NIP-34 announcement's `euc` marker. + /// + /// `None` for a repository without commits. + pub fn root_commit(&self) -> Result> { + let Ok(head) = self.inner.head_id() else { + return Ok(None); + }; + + for info in self + .inner + .rev_walk([head]) + .sorting(gix::revision::walk::Sorting::ByCommitTime( + gix::traverse::commit::simple::CommitTimeOrder::NewestFirst, + )) + .all()? + { + let info = info?; + if info.parent_ids().next().is_none() { + return Ok(Some(info.id().to_string())); + } + } + + Ok(None) + } + + /// Full ref names under `prefix`, sorted lexicographically, like `git for-each-ref`. + /// `prefix` is a ref namespace like `refs/fork//`. + /// + /// Returns an empty list when nothing matches. + pub fn refs_with_prefix(&self, prefix: &str) -> Result> { + let pattern = prefix.trim_end_matches('/'); + let mut names = Vec::new(); + + for reference in self.inner.references()?.all()? { + let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; + let name = String::from_utf8_lossy(reference.name().as_bstr()).into_owned(); + + // Match the pattern itself and everything beneath it, like `git for-each-ref`. + let under_pattern = name + .strip_prefix(pattern) + .is_some_and(|rest| rest.is_empty() || rest.starts_with('/')); + + if under_pattern { + names.push(name); + } + } + + names.sort(); + + Ok(names) + } + + /// Delete every ref under `prefix`. + /// `prefix` is a ref namespace like `refs/fork//`. + pub fn delete_refs_with_prefix(&self, prefix: &str) -> Result<()> { + use gix::refs::transaction::{Change, PreviousValue, RefEdit, RefLog}; + + let refs = self.refs_with_prefix(prefix)?; + if refs.is_empty() { + return Ok(()); + } + + let edits: Vec = refs + .iter() + .map(|name| { + let full = gix::refs::FullName::try_from(name.as_str()) + .map_err(|e| anyhow::anyhow!("invalid ref name {name}: {e}"))?; + Ok(RefEdit { + change: Change::Delete { + expected: PreviousValue::Any, + log: RefLog::AndReference, + }, + name: full, + deref: false, + }) + }) + .collect::>>()?; + + self.inner.edit_references(edits)?; + + Ok(()) + } + + /// Short name of the branch HEAD points to, + /// `None` when detached or unreadable, like `git branch --show-current`. + pub fn current_branch(&self) -> Option { + let head = self.inner.head().ok()?; + let name = head.referent_name()?; + Some(String::from_utf8_lossy(name.shorten()).into_owned()) + } + + pub fn ref_exists(&self, name: &str) -> bool { + self.inner.find_reference(name).is_ok() + } + + /// Short names of local branches, `refs/heads/*`, sorted alphabetically. + pub fn branches(&self) -> Result> { + let mut names = Vec::new(); + for reference in self.inner.references()?.local_branches()? { + let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; + names.push(String::from_utf8_lossy(reference.name().shorten()).into_owned()); + } + names.sort(); + Ok(names) + } + + /// Short names of tags, `refs/tags/*`, sorted alphabetically. + pub fn tags(&self) -> Result> { + let mut names = Vec::new(); + for reference in self.inner.references()?.tags()? { + let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; + names.push(String::from_utf8_lossy(reference.name().shorten()).into_owned()); + } + names.sort(); + Ok(names) + } + + /// Branch, tag and HEAD refs of the repository. + /// + /// Ready for a NIP-34 kind-30618 repository state announcement. + pub fn ref_state(&self) -> Result { + let mut refs = Vec::new(); + + for reference in self.inner.references()?.local_branches()? { + let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; + refs.push(( + String::from_utf8_lossy(reference.name().as_bstr()).into_owned(), + reference.id().to_string(), + )); + } + + for reference in self.inner.references()?.tags()? { + let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; + refs.push(( + String::from_utf8_lossy(reference.name().as_bstr()).into_owned(), + reference.id().to_string(), + )); + } + refs.sort(); + + let head = match self.inner.head() { + Ok(head) => head + .referent_name() + .filter(|name| name.as_bstr().starts_with(b"refs/heads/")) + .map(|name| String::from_utf8_lossy(name.shorten()).into_owned()), + Err(_) => None, + }; + + Ok(RepoRefState { refs, head }) + } + + /// Fast-forward local branches that trail their remote-tracking counterpart. + /// + /// Returns whether any branch moved. + pub fn fast_forward_branches(&self) -> Result { + use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; + + if self.workdir().is_none() { + return Ok(false); + } + + let current = self.current_branch(); + let heads = self.refs_with_prefix("refs/heads")?; + + let (signature, mut time_buf) = Self::repository_signature(); + let signature = signature.to_ref(&mut time_buf); + + let mut moved = false; + + for head in heads { + let Some(branch) = head.strip_prefix("refs/heads/") else { + continue; + }; + + let remote = format!("refs/remotes/origin/{branch}"); + // No remote-tracking counterpart means the remote lacks this branch. + let Ok(mut remote_reference) = self.inner.find_reference(&remote) else { + continue; + }; + + let Ok(mut local_reference) = self.inner.find_reference(&head) else { + continue; + }; + + let Ok(remote_oid) = remote_reference.peel_to_id() else { + continue; + }; + + let Ok(local_oid) = local_reference.peel_to_id() else { + continue; + }; + + let remote_oid = remote_oid.detach(); + let local_oid = local_oid.detach(); + + if local_oid == remote_oid { + continue; + } + + // Only fast-forward. + // Local-only commits or diverged history must never be rewritten by a refresh. + let Ok(base) = self.inner.merge_base(local_oid, remote_oid) else { + continue; + }; + + if base != local_oid { + continue; + } + + let full = gix::refs::FullName::try_from(head.as_str()) + .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?; + + let edit = |new: gix::refs::Target| RefEdit { change: Change::Update { log: LogChange { mode: RefLog::AndReference, @@ -351,80 +451,69 @@ pub fn fast_forward_branches(workdir: &Path) -> Result { }, name: full.clone(), deref: false, + }; + + if current.as_deref() == Some(branch) { + // Merge so the checked-out worktree follows the branch. + // Only proceed on a clean worktree, like `git merge --ff-only`. + if self.is_dirty() { + continue; + } + + let tree = self.inner.find_object(remote_oid)?.peel_to_tree()?.id; + + self.force_checkout(&tree)?; + + self.inner.edit_references_as( + [edit(gix::refs::Target::Object(remote_oid))], + Some(signature), + )?; + + moved = true; + } else { + self.inner.edit_references_as( + [edit(gix::refs::Target::Object(remote_oid))], + Some(signature), + )?; + + moved = true; } + } + + Ok(moved) + } + + /// The identity written to reflogs and commits created by this crate itself. + /// + /// Like `git -c user.name=… -c user.email=…` per invocation: the repository works + /// without a global git identity, and `gix` runs no hooks and never signs. + pub(crate) fn repository_signature() -> (gix::actor::Signature, gix::date::parse::TimeBuf) { + let seconds = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs() as i64) + .unwrap_or_default(); + + let signature = gix::actor::Signature { + name: gix::bstr::BString::from("Signed"), + email: gix::bstr::BString::from("signed@localhost"), + time: gix::date::Time { seconds, offset: 0 }, }; - if current.as_deref() == Some(branch) { - // Merge so the checked-out worktree follows the branch. - // Only proceed on a clean worktree, like `git merge --ff-only`. - if worktree_dirty(workdir) { - continue; - } - - let tree = repo.find_object(remote_oid)?.peel_to_tree()?.id; - - force_checkout(&repo, &tree)?; - - repo.edit_references_as( - [edit(gix::refs::Target::Object(remote_oid))], - Some(signature), - )?; - - moved = true; - } else { - repo.edit_references_as( - [edit(gix::refs::Target::Object(remote_oid))], - Some(signature), - )?; - - moved = true; - } + (signature, gix::date::parse::TimeBuf::default()) } - Ok(moved) -} - -/// Short names of local branches, `refs/heads/*`, of `repo`, sorted alphabetically. -pub fn repo_branches(repo: &gix::Repository) -> Result> { - let mut names = Vec::new(); - for reference in repo.references()?.local_branches()? { - let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; - names.push(String::from_utf8_lossy(reference.name().shorten()).into_owned()); + /// Rewrite a grasp server URL to the https URL the git transport actually uses. + /// + /// GRASP servers announce `grasp:////` clone URLs. + /// The transport is git smart HTTP, so the scheme is rewritten for gix. + pub(crate) fn transport_url(url: &str) -> String { + url.strip_prefix("grasp://") + .map(|rest| format!("https://{rest}")) + .unwrap_or_else(|| url.to_owned()) } - names.sort(); - Ok(names) -} - -/// Short names of tags, `refs/tags/*`, of `repo`, sorted alphabetically. -pub fn repo_tags(repo: &gix::Repository) -> Result> { - let mut names = Vec::new(); - for reference in repo.references()?.tags()? { - let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; - names.push(String::from_utf8_lossy(reference.name().shorten()).into_owned()); - } - names.sort(); - Ok(names) -} - -/// Short names of local branches, `refs/heads/*`, sorted alphabetically. -pub fn worktree_branches(workdir: &Path) -> Result> { - repo_branches(&gix::open(workdir)?) -} - -/// Short name of the branch HEAD points to, or `None` when detached. -/// -/// Detached after checking out a tag or a commit directly. -pub fn current_branch(repo: &gix::Repository) -> Result> { - let head = repo.head()?; - let Some(name) = head.referent_name() else { - return Ok(None); - }; - Ok(Some(String::from_utf8_lossy(name.shorten()).into_owned())) } /// Branch, tag and HEAD refs of a repository. -/// -/// Ready for a NIP-34 kind-30618 repository state announcement. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RepoRefState { /// `(full refname, commit id)` pairs for heads and tags, sorted. @@ -432,38 +521,3 @@ pub struct RepoRefState { /// Short branch name HEAD points to, or `None` when detached. pub head: Option, } - -pub fn repo_ref_state(repo: &gix::Repository) -> Result { - let mut refs = Vec::new(); - - for reference in repo.references()?.local_branches()? { - let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; - refs.push(( - String::from_utf8_lossy(reference.name().as_bstr()).into_owned(), - reference.id().to_string(), - )); - } - - for reference in repo.references()?.tags()? { - let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; - refs.push(( - String::from_utf8_lossy(reference.name().as_bstr()).into_owned(), - reference.id().to_string(), - )); - } - refs.sort(); - - let head = match repo.head() { - Ok(head) => head - .referent_name() - .filter(|name| name.as_bstr().starts_with(b"refs/heads/")) - .map(|name| String::from_utf8_lossy(name.shorten()).into_owned()), - Err(_) => None, - }; - - Ok(RepoRefState { refs, head }) -} - -pub fn worktree_ref_state(workdir: &Path) -> Result { - repo_ref_state(&gix::open(workdir)?) -} diff --git a/crates/signed_git/src/scan.rs b/crates/signed_git/src/scan.rs index 6e64fc0..8bb3830 100644 --- a/crates/signed_git/src/scan.rs +++ b/crates/signed_git/src/scan.rs @@ -2,7 +2,8 @@ use std::path::{Path, PathBuf}; use ignore::WalkBuilder; -use crate::nip34::{Nip34Binding, detect_nip34}; +use crate::nip34::Nip34Binding; +use crate::repo::Repo; /// Caps nesting so pathological trees can't stall the scan. const SCAN_MAX_DEPTH: usize = 12; @@ -49,7 +50,7 @@ pub fn find_git_repos(root: &Path) -> Vec { roots .into_iter() .map(|path| { - let nip34 = detect_nip34(&path); + let nip34 = Repo::open(&path).ok().and_then(|repo| repo.nip34_binding()); LocalRepo { path, nip34 } }) .collect() diff --git a/crates/signed_git/src/tests.rs b/crates/signed_git/src/tests.rs index 189a708..e470ff6 100644 --- a/crates/signed_git/src/tests.rs +++ b/crates/signed_git/src/tests.rs @@ -5,11 +5,11 @@ use super::*; #[test] fn blocks_parent_components() { - assert_eq!(sanitize_path_component(".."), "_"); - assert_eq!(sanitize_path_component("."), "_"); + assert_eq!(GitCache::sanitize_path_component(".."), "_"); + assert_eq!(GitCache::sanitize_path_component("."), "_"); // Separators are neutralized before the check, so these stay safe. - assert_eq!(sanitize_path_component("../.."), ".._.."); - assert_eq!(sanitize_path_component("a/../b"), "a_.._b"); + assert_eq!(GitCache::sanitize_path_component("../.."), ".._.."); + assert_eq!(GitCache::sanitize_path_component("a/../b"), "a_.._b"); } #[test] @@ -18,14 +18,14 @@ fn root_commit_reports_the_first_ancestor() { commit_all(&repo, "initial"); let dir = dir.path(); - let root = root_commit(dir).expect("root").expect("commit"); + let root = repo.root_commit().expect("root").expect("commit"); assert_eq!(root.len(), 40); // The root commit does not change when history grows. std::fs::write(dir.join("b.txt"), b"two").expect("write"); commit_all(&repo, "second"); assert_eq!( - root_commit(dir).expect("root").as_deref(), + repo.root_commit().expect("root").as_deref(), Some(root.as_str()) ); } @@ -48,7 +48,10 @@ fn push_all_mirrors_branches_and_tags() { git_run(dir, &["tag", "v1.0"]); let base_url = format!("file://{}", server.path().display()); - push_all(dir, &base_url, "npub1test", "my-repo").expect("push"); + Repo::open(dir) + .expect("open") + .push_all(&base_url, "npub1test", "my-repo") + .expect("push"); let refs = git_in(&server_repo, &["show-ref"]).expect("server refs"); assert!(refs.contains("refs/heads/main")); @@ -66,34 +69,33 @@ fn remote_has_refs_reports_whether_pushed_refs_landed() { let main = git_in(dir, &["rev-parse", "refs/heads/main"]).expect("main oid"); let url = format!("file://{}/npub1test/my-repo.git", server.path().display()); let expected = vec![("refs/heads/main".to_owned(), main.clone())]; + let repo = Repo::open(dir).expect("open"); - assert!(!remote_has_refs(dir, &url, &expected).expect("probe")); + assert!(!repo.remote_has_refs(&url, &expected).expect("probe")); - push_all( - dir, + repo.push_all( &format!("file://{}", server.path().display()), "npub1test", "my-repo", ) .expect("push"); - assert!(remote_has_refs(dir, &url, &expected).expect("probe")); + assert!(repo.remote_has_refs(&url, &expected).expect("probe")); // A stale expectation - the exact race a retry resolves - is false. let stale = vec![("refs/heads/main".to_owned(), "0".repeat(40))]; - assert!(!remote_has_refs(dir, &url, &stale).expect("probe")); + assert!(!repo.remote_has_refs(&url, &stale).expect("probe")); // Extra remote refs (e.g. a tag pushed later) do not invalidate the // refs this push wanted to land. git_run(dir, &["tag", "v1.0"]); - push_all( - dir, + repo.push_all( &format!("file://{}", server.path().display()), "npub1test", "my-repo", ) .expect("push"); - assert!(remote_has_refs(dir, &url, &expected).expect("probe")); + assert!(repo.remote_has_refs(&url, &expected).expect("probe")); } #[test] @@ -102,9 +104,9 @@ fn repo_ref_state_lists_branches_tags_and_head() { commit_all(&repo, "initial"); let workdir = repo.workdir().expect("workdir").to_path_buf(); - let state = repo_ref_state(&repo).expect("refs"); + let state = repo.ref_state().expect("refs"); - let branch = current_branch(&repo).expect("branch").expect("on a branch"); + let branch = repo.current_branch().expect("on a branch"); assert_eq!(state.head.as_deref(), Some(branch.as_str())); assert_eq!(state.refs.len(), 1); assert_eq!(state.refs[0].0, format!("refs/heads/{branch}")); @@ -113,7 +115,7 @@ fn repo_ref_state_lists_branches_tags_and_head() { git_run(&workdir, &["branch", "feature"]); git_run(&workdir, &["tag", "v1.0"]); - let state = repo_ref_state(&repo).expect("refs"); + let state = repo.ref_state().expect("refs"); let mut expected: Vec = vec![ format!("refs/heads/{branch}"), "refs/heads/feature".to_owned(), @@ -130,15 +132,15 @@ fn repo_ref_state_lists_branches_tags_and_head() { ); git_run(&workdir, &["checkout", "--detach"]); - let state = repo_ref_state(&repo).expect("refs"); + let state = repo.ref_state().expect("refs"); assert!(state.head.is_none()); assert_eq!(state.refs.len(), 3); } /// Build a throwaway non-bare repository from `(rel, bytes)` file pairs. -fn fixture(files: &[(&str, &[u8])]) -> (tempfile::TempDir, gix::Repository) { +fn fixture(files: &[(&str, &[u8])]) -> (tempfile::TempDir, Repo) { let dir = tempfile::tempdir().expect("tempdir"); - let repo = gix::init(&dir).expect("init"); + gix::init(&dir).expect("init"); for (rel, bytes) in files { let path = dir.path().join(rel); @@ -146,12 +148,13 @@ fn fixture(files: &[(&str, &[u8])]) -> (tempfile::TempDir, gix::Repository) { std::fs::write(&path, bytes).expect("write"); } + let repo = Repo::open(dir.path()).expect("open"); (dir, repo) } /// Stage everything and create a commit with the git CLI. -/// Like [`apply_patch`], the crate already shells out to the CLI. -fn commit_all(repo: &gix::Repository, message: &str) { +/// Like [`Repo::apply_patch`], the crate already shells out to the CLI. +fn commit_all(repo: &Repo, message: &str) { git_run(repo.workdir().expect("workdir"), &["add", "-A"]); git_run(repo.workdir().expect("workdir"), &["commit", "-m", message]); } @@ -160,19 +163,20 @@ fn commit_all(repo: &gix::Repository, message: &str) { fn merge_base_finds_the_fork_point_and_reports_unrelated_history() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("repo"); - let initial = init_repository(&path, "My Repo", "desc").expect("init"); + let initial = Repo::init(&path, "My Repo", "desc").expect("init"); + let repo = Repo::open(&path).expect("open"); // A feature branch and a mainline commit diverge from the initial commit. // The initial commit is their merge base. git_run(&path, &["checkout", "-b", "feature"]); std::fs::write(path.join("feature.txt"), "feature\n").expect("write"); - commit_all(&gix::open(&path).expect("open"), "feature commit"); + commit_all(&repo, "feature commit"); git_run(&path, &["checkout", "main"]); std::fs::write(path.join("main.txt"), "main\n").expect("write"); - commit_all(&gix::open(&path).expect("open"), "mainline commit"); + commit_all(&repo, "mainline commit"); assert_eq!( - merge_base(&path, "feature", "main") + repo.merge_base("feature", "main") .expect("merge base") .as_deref(), Some(initial.as_str()) @@ -181,27 +185,30 @@ fn merge_base_finds_the_fork_point_and_reports_unrelated_history() { // An orphan branch shares no history with main, so `Ok(None)`. git_run(&path, &["checkout", "--orphan", "orphan"]); std::fs::write(path.join("orphan.txt"), "orphan\n").expect("write"); - commit_all(&gix::open(&path).expect("open"), "orphan commit"); - assert_eq!(merge_base(&path, "orphan", "main").expect("ok"), None); + commit_all(&repo, "orphan commit"); + assert_eq!(repo.merge_base("orphan", "main").expect("ok"), None); // An unresolvable revision is an error, not a missing ancestor. - assert!(merge_base(&path, "orphan", "no-such-ref").is_err()); + assert!(repo.merge_base("orphan", "no-such-ref").is_err()); } #[test] fn split_patch_series_splits_real_multi_commit_mboxes() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("repo"); - let initial = init_repository(&path, "My Repo", "desc").expect("init"); + let initial = Repo::init(&path, "My Repo", "desc").expect("init"); + let repo = Repo::open(&path).expect("open"); git_run(&path, &["checkout", "-b", "feature"]); std::fs::write(path.join("one.txt"), "one\n").expect("write"); - commit_all(&gix::open(&path).expect("open"), "first commit"); + commit_all(&repo, "first commit"); std::fs::write(path.join("two.txt"), "two\n").expect("write"); - commit_all(&gix::open(&path).expect("open"), "second commit"); + commit_all(&repo, "second commit"); - let series = format_patch_between(&path, &initial, "feature").expect("series"); - let parts = split_patch_series(&series); + let series = repo + .format_patch_between(&initial, "feature") + .expect("series"); + let parts = PatchParser::split_patch_series(&series); assert_eq!(parts.len(), 2); assert!(parts[0].contains("Subject: [PATCH 1/2] first commit")); @@ -217,33 +224,31 @@ fn split_patch_series_splits_real_multi_commit_mboxes() { fn head_commit_and_commits_since_track_applied_commits() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("repo"); - let initial = init_repository(&path, "My Repo", "desc").expect("init"); + let initial = Repo::init(&path, "My Repo", "desc").expect("init"); + let repo = Repo::open(&path).expect("open"); - assert_eq!( - head_commit_id(&path).expect("head").as_deref(), - Some(initial.as_str()) - ); + assert_eq!(repo.head().as_deref(), Some(initial.as_str())); // No base given, `HEAD` alone. assert_eq!( - commits_since(&path, None).expect("commits"), + repo.commits_since(None).expect("commits"), vec![initial.clone()] ); std::fs::write(path.join("one.txt"), "one\n").expect("write"); - commit_all(&gix::open(&path).expect("open"), "first commit"); - let first = head_commit_id(&path).expect("head").expect("on a branch"); + commit_all(&repo, "first commit"); + let first = repo.head().expect("on a branch"); std::fs::write(path.join("two.txt"), "two\n").expect("write"); - commit_all(&gix::open(&path).expect("open"), "second commit"); - let second = head_commit_id(&path).expect("head").expect("on a branch"); + commit_all(&repo, "second commit"); + let second = repo.head().expect("on a branch"); // Oldest first, like the order `git am` creates them. assert_eq!( - commits_since(&path, Some(&initial)).expect("commits"), + repo.commits_since(Some(&initial)).expect("commits"), vec![first.clone(), second.clone()] ); assert_eq!( - commits_since(&path, Some(&first)).expect("commits"), + repo.commits_since(Some(&first)).expect("commits"), vec![second] ); } @@ -256,25 +261,29 @@ fn working_copy_cloned_from_the_mirror_matches_head_and_origin() { // the first time a repository is opened. let dir = tempfile::tempdir().expect("tempdir"); let mirror = dir.path().join("mirror"); - let commit = init_repository(&mirror, "My Repo", "Does things.").expect("init"); - ensure_origin(&mirror, "https://gitnostr.com/npub1test/my-repo.git").expect("origin"); + let commit = Repo::init(&mirror, "My Repo", "Does things.").expect("init"); + Repo::open(&mirror) + .expect("open") + .ensure_origin("https://gitnostr.com/npub1test/my-repo.git") + .expect("origin"); // The working copy is cloned from the mirror. // It then shares the announced history exactly. // `origin` is re-pointed at the grasp server instead of the mirror path. let destination = dir.path().join("folder").join("My_Repo"); std::fs::create_dir_all(destination.parent().unwrap()).expect("parent"); - clone_repo(&[format!("file://{}", mirror.display())], &destination).expect("clone"); - set_origin(&destination, "https://gitnostr.com/npub1test/my-repo.git").expect("set origin"); + Repo::clone(&[format!("file://{}", mirror.display())], &destination).expect("clone"); + Repo::open(&destination) + .expect("open") + .set_origin("https://gitnostr.com/npub1test/my-repo.git") + .expect("set origin"); + let working = Repo::open(&destination).expect("open"); assert_eq!( - origin_url(&destination).expect("url").as_deref(), + working.origin_url().expect("url").as_deref(), Some("https://gitnostr.com/npub1test/my-repo.git") ); - assert_eq!( - head_commit_id(&destination).expect("head").as_deref(), - Some(commit.as_str()) - ); + assert_eq!(working.head().as_deref(), Some(commit.as_str())); assert!(destination.join("README.md").is_file()); } @@ -288,7 +297,9 @@ fn fast_forward_branches_moves_the_mirror_and_keeps_local_work() { commit_all(&work_repo, "initial"); let work = work_dir.path(); let base_url = format!("file://{}", dir.path().display()); - push_all(work, &base_url, "npub1test", "repo").expect("push"); + work_repo + .push_all(&base_url, "npub1test", "repo") + .expect("push"); // A mirror clone, like the app's GitCache clones. let mirror = dir.path().join("mirror"); @@ -302,12 +313,15 @@ fn fast_forward_branches_moves_the_mirror_and_keeps_local_work() { ], ); let initial = git_in(&mirror, &["rev-parse", "HEAD"]).expect("initial"); + let repo = Repo::open(&mirror).expect("open"); // The owner pushes a new commit. // The mirror fetches it, but its local `main` and worktree stay behind. std::fs::write(work.join("new.txt"), b"new\n").expect("write"); - commit_all(&gix::open(work).expect("open"), "new commit"); - push_all(work, &base_url, "npub1test", "repo").expect("push"); + commit_all(&work_repo, "new commit"); + work_repo + .push_all(&base_url, "npub1test", "repo") + .expect("push"); git_run(&mirror, &["fetch", "origin"]); let remote = git_in(&mirror, &["rev-parse", "refs/remotes/origin/main"]).expect("remote"); assert_eq!( @@ -318,20 +332,20 @@ fn fast_forward_branches_moves_the_mirror_and_keeps_local_work() { // Fast-forwarding catches the branch and its worktree up. // The second call has nothing left to move. - assert!(fast_forward_branches(&mirror).expect("ff")); + assert!(repo.fast_forward_branches().expect("ff")); assert_eq!( git_in(&mirror, &["rev-parse", "HEAD"]).expect("local"), remote ); assert!(mirror.join("new.txt").is_file()); - assert!(!fast_forward_branches(&mirror).expect("idle")); + assert!(!repo.fast_forward_branches().expect("idle")); // A branch with local commits of its own is never touched. git_run(&mirror, &["checkout", "-b", "wip"]); std::fs::write(mirror.join("wip.txt"), b"wip\n").expect("write"); - commit_all(&gix::open(&mirror).expect("open"), "local wip"); + commit_all(&repo, "local wip"); let wip = git_in(&mirror, &["rev-parse", "HEAD"]).expect("wip"); - assert!(!fast_forward_branches(&mirror).expect("wip skipped")); + assert!(!repo.fast_forward_branches().expect("wip skipped")); assert_eq!( git_in(&mirror, &["rev-parse", "HEAD"]).expect("wip kept"), wip @@ -350,13 +364,13 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() { commit_all(&upstream_repo, "initial"); let upstream_path = upstream_dir.path(); let initial = git_in(upstream_path, &["rev-parse", "HEAD"]).expect("initial"); - push_all( - upstream_path, - &format!("file://{}", dir.path().display()), - "npub1base", - "base", - ) - .expect("push"); + upstream_repo + .push_all( + &format!("file://{}", dir.path().display()), + "npub1base", + "base", + ) + .expect("push"); let base_url = format!("file://{}", base_server.display()); let mirror = dir.path().join("mirror"); @@ -364,6 +378,7 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() { dir.path(), &["clone", "-q", &base_url, mirror.to_str().unwrap()], ); + let mirror_repo = Repo::open(&mirror).expect("open"); // The fork server has the same initial commit. // It also carries a feature commit on its own `feature` branch. @@ -374,58 +389,68 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() { ); git_run(&fork_work, &["checkout", "-b", "feature"]); std::fs::write(fork_work.join("feature.txt"), "feature\n").expect("write"); - commit_all(&gix::open(&fork_work).expect("open"), "feature commit"); + commit_all(&Repo::open(&fork_work).expect("open"), "feature commit"); let tip = git_in(&fork_work, &["rev-parse", "HEAD"]).expect("tip"); let fork_server = bare_server(dir.path(), "npub1fork", "fork"); - push_commit_ref( - &fork_work, - &format!("file://{}", fork_server.display()), - &tip, - "refs/heads/feature", - ) - .expect("push"); + Repo::open(&fork_work) + .expect("open") + .push_ref( + &format!("file://{}", fork_server.display()), + &tip, + "refs/heads/feature", + ) + .expect("push"); // Import the fork's heads into the mirror under a private prefix. // The first dead URL is skipped, the second works. let dead = format!("file://{}/missing.git", dir.path().display()); - fetch_repo_refs( - &mirror, - &[dead, format!("file://{}", fork_server.display())], - "+refs/heads/*:refs/fork/npub1fork/fork/*", - ) - .expect("fetch"); + mirror_repo + .fetch_refs( + &[dead, format!("file://{}", fork_server.display())], + "+refs/heads/*:refs/fork/npub1fork/fork/*", + ) + .expect("fetch"); assert_eq!( - refs_with_prefix(&mirror, "refs/fork/npub1fork/fork").expect("refs"), + mirror_repo + .refs_with_prefix("refs/fork/npub1fork/fork") + .expect("refs"), vec!["refs/fork/npub1fork/fork/feature"] ); // Nothing leaked into the normal ref namespaces. assert_eq!( - refs_with_prefix(&mirror, "refs/heads/fork").expect("refs"), + mirror_repo + .refs_with_prefix("refs/heads/fork") + .expect("refs"), Vec::::new() ); // The mirror can now range across both histories. // The fork point is the shared initial commit, the proposal covers the fork commit. assert_eq!( - merge_base( - &mirror, - "refs/remotes/origin/main", - "refs/fork/npub1fork/fork/feature", - ) - .expect("merge base") - .as_deref(), + mirror_repo + .merge_base( + "refs/remotes/origin/main", + "refs/fork/npub1fork/fork/feature", + ) + .expect("merge base") + .as_deref(), Some(initial.as_str()) ); - let patch = - format_patch_between(&mirror, &initial, "refs/fork/npub1fork/fork/feature").expect("patch"); + let patch = mirror_repo + .format_patch_between(&initial, "refs/fork/npub1fork/fork/feature") + .expect("patch"); assert!(patch.contains("Subject: [PATCH] feature commit")); assert!(patch.contains("feature.txt")); - delete_refs_with_prefix(&mirror, "refs/fork/npub1fork/fork").expect("delete"); + mirror_repo + .delete_refs_with_prefix("refs/fork/npub1fork/fork") + .expect("delete"); assert_eq!( - refs_with_prefix(&mirror, "refs/fork/npub1fork/fork").expect("refs"), + mirror_repo + .refs_with_prefix("refs/fork/npub1fork/fork") + .expect("refs"), Vec::::new() ); } @@ -490,7 +515,7 @@ diff --git a/b.txt b/b.txt +y "#; - let commits = patch_commits(patch); + let commits = PatchParser::patch_commits(patch); assert_eq!(commits.len(), 2); assert_eq!(commits[0].id, "1111111111111111111111111111111111111111"); @@ -536,7 +561,7 @@ fn parses_real_format_patch_output() { let output = git_run(dir.path(), &["format-patch", "-1", "--stdout"]); let patch = String::from_utf8(output.stdout).expect("patch is utf-8"); - let diff = patch_diffs(&patch).expect("parse real format-patch output"); + let diff = PatchParser::patch_diffs(&patch).expect("parse real format-patch output"); let by_path = |path: &str| { diff.files diff --git a/crates/signed_git/src/worktree.rs b/crates/signed_git/src/worktree.rs index dd68418..bb566c4 100644 --- a/crates/signed_git/src/worktree.rs +++ b/crates/signed_git/src/worktree.rs @@ -4,140 +4,315 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use gix::progress::Discard; -use crate::history::{FileCommit, head_commit}; -use crate::repo::{current_branch, repository_signature}; +use crate::history::FileCommit; +use crate::repo::Repo; -/// Whether the worktree of `workdir` has uncommitted changes. -/// -/// Best-effort: any read failure is reported as clean. -pub fn worktree_dirty(workdir: &Path) -> bool { - let Ok(repo) = gix::open(workdir) else { - return false; - }; - - // Changes to tracked files, staged or not; untracked files are excluded. - match repo.is_dirty() { - Ok(true) => return true, - Ok(false) => {} - Err(_) => return false, - } - - // Untracked files surface as `DirectoryContents` items of the index-vs-worktree walk, - // tracked files only appear there when modified. - let Ok(platform) = repo.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(_) => {} +impl Repo { + /// Whether the worktree has uncommitted changes. + /// + /// Best-effort: any read failure is reported as clean. + pub fn is_dirty(&self) -> bool { + // Changes to tracked files, staged or not; untracked files are excluded. + match self.inner.is_dirty() { + Ok(true) => return true, + Ok(false) => {} Err(_) => return false, } + + // Untracked files surface as `DirectoryContents` items of the index-vs-worktree walk, + // tracked files only appear there when modified. + 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 } - false -} + /// Commits in `base..branch`. + /// + /// Best-effort: 0 when the range cannot be computed. + pub fn commits_ahead(&self, base: &str, branch: &str) -> u32 { + let (Some(base), Some(branch)) = (self.resolve_commit(base), self.resolve_commit(branch)) + else { + return 0; + }; -/// Commits in `base..branch` of the checkout at `workdir`. -/// -/// Best-effort: 0 when the range cannot be computed. -pub fn worktree_commits_ahead(workdir: &Path, base: &str, branch: &str) -> u32 { - let Ok(repo) = gix::open(workdir) else { - return 0; - }; + let Ok(walk) = self.inner.rev_walk([branch]).with_hidden([base]).all() else { + return 0; + }; - let (Some(base), Some(branch)) = (resolve_commit(&repo, base), resolve_commit(&repo, branch)) - else { - return 0; - }; - - let Ok(walk) = repo.rev_walk([branch]).with_hidden([base]).all() else { - return 0; - }; - - walk.filter_map(Result::ok).count().min(u32::MAX as usize) as u32 -} - -/// Resolve `rev` to a commit id, accepting full refs or the bare branch names -/// callers pass. `gix`'s revision parser already applies git's ref DWIM. -fn resolve_commit<'a>(repo: &'a gix::Repository, rev: &str) -> Option> { - repo.rev_parse_single(rev.as_bytes()).ok() -} - -/// Relative paths of all entries in the worktree, files and directories. -/// -/// The `.git` directory is skipped. -pub fn worktree_entries(repo: &gix::Repository) -> Result> { - let workdir = repo.workdir().context("repository has no worktree")?; - - let mut entries: Vec<(PathBuf, bool)> = Vec::new(); - 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()) -} - -/// Read a file from the worktree. -/// -/// Returns `Ok(None)` if the path is missing or not a regular file. -pub fn worktree_read(repo: &gix::Repository, rel: &Path) -> Result>> { - let workdir = repo.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())), + walk.filter_map(Result::ok).count().min(u32::MAX as usize) as u32 } -} -/// Find the README file in the repository root. -/// -/// Falls back to any other file whose name starts with `readme`. -pub fn find_readme(repo: &gix::Repository) -> Result> { - let Some(workdir) = repo.workdir() else { - return Ok(None); - }; + /// Resolve `rev` to a commit id, accepting full refs or the bare branch names + /// callers pass. `gix`'s revision parser already applies git's ref DWIM. + fn resolve_commit<'a>(&'a self, rev: &str) -> Option> { + self.inner.rev_parse_single(rev.as_bytes()).ok() + } - 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()); + /// Relative paths of all entries in the worktree, files and directories. + /// + /// The `.git` directory is skipped. + 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()) + } + + /// Read a file from the worktree. + /// + /// Returns `Ok(None)` if the path is missing or not a regular file. + 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())), } } - 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, - } - }); + /// Find the README file in the repository root. + /// + /// Falls back to any other file whose name starts with `readme`. + pub fn find_readme(&self) -> Result> { + let Some(workdir) = self.inner.workdir() else { + return Ok(None); + }; - Ok(candidates - .into_iter() - .next() - .and_then(|path| path.strip_prefix(workdir).ok().map(Path::to_path_buf))) + 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))) + } + + /// Everything the browser needs to refresh after a branch or tag switch. + 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 { + entries: self.entries()?, + readme_path, + readme, + current_branch: self.current_branch(), + head_commit: self.head_commit().unwrap_or(None), + branches: self.branches().unwrap_or_default(), + tags: self.tags().unwrap_or_default(), + }) + } + + /// Check out the local branch `name`, HEAD stays attached to it. + 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(()) + } + + /// Check out the tag `name`, HEAD becomes detached at the tagged commit. + 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)?; + + // Files the previous index tracked but `tree` no longer contains are removed, + // like git deleting files that vanish between branches. + if let Ok(previous) = self.inner.index_or_empty() { + let keep: HashSet = 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, + )?; + + index.write(gix::index::write::Options::default())?; + + Ok(()) + } + + /// Point `HEAD` at `target` and record the switch in the reflog. + 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(()) + } + + /// Relative paths of all entries below `dir`, relative to `root`. + fn collect_entries(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, bool)>) -> Result<()> { + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + 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(()) + } } /// Everything the browser needs to refresh after a branch or tag switch. @@ -150,185 +325,10 @@ pub struct WorktreeSnapshot { pub readme: Option>, /// Branch HEAD points to, `None` when detached, for example on a tag. pub current_branch: Option, - /// Commit HEAD points to, if any, see [`head_commit`]. + /// Commit HEAD points to, if any, see [`Repo::head_commit`]. pub head_commit: Option, -} - -/// Collects entries, the README, the branch HEAD points to and its commit. -pub fn worktree_snapshot(workdir: &Path) -> Result { - let repo = gix::open(workdir)?; - let readme_path = find_readme(&repo)?; - let readme = match &readme_path { - Some(path) => worktree_read(&repo, path)?, - None => None, - }; - Ok(WorktreeSnapshot { - entries: worktree_entries(&repo)?, - readme_path, - readme, - current_branch: current_branch(&repo)?, - head_commit: head_commit(&repo)?, - }) -} - -pub(crate) fn force_checkout(repo: &gix::Repository, tree: &gix::hash::oid) -> Result<()> { - let workdir = repo - .workdir() - .context("repository has no worktree")? - .to_path_buf(); - - let mut index = repo.index_from_tree(tree)?; - - // Files the previous index tracked but `tree` no longer contains are removed, - // like git deleting files that vanish between branches. - if let Ok(previous) = repo.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 = - repo.checkout_options(gix_worktree::stack::state::attributes::Source::IdMapping)?; - options.overwrite_existing = true; - - let objects = repo.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, - )?; - - index.write(gix::index::write::Options::default())?; - - Ok(()) -} - -/// Point `HEAD` at `target` and record the switch in the reflog. -fn move_head( - repo: &gix::Repository, - 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}"))?; - - repo.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(()) -} - -/// Check out the local branch `name`, HEAD stays attached to it. -pub fn worktree_checkout_branch(workdir: &Path, name: &str) -> Result<()> { - let repo = gix::open(workdir)?; - 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 = repo.find_reference(&full)?; - let tree = reference.peel_to_tree()?.id; - - let (signature, mut time_buf) = repository_signature(); - let signature = signature.to_ref(&mut time_buf); - - move_head( - &repo, - signature, - gix::refs::Target::Symbolic(branch), - &format!("checkout: moving to {name}"), - )?; - - force_checkout(&repo, &tree)?; - - Ok(()) -} - -/// Check out the tag `name`, HEAD becomes detached at the tagged commit. -pub fn worktree_checkout_tag(workdir: &Path, name: &str) -> Result<()> { - let repo = gix::open(workdir)?; - let full = format!("refs/tags/{name}"); - - let mut reference = repo.find_reference(&full)?; - - let commit = reference.peel_to_id()?; - let tree = reference.peel_to_tree()?.id; - - let (signature, mut time_buf) = repository_signature(); - let signature = signature.to_ref(&mut time_buf); - - move_head( - &repo, - signature, - gix::refs::Target::Object(commit.detach()), - &format!("checkout: moving to {name}"), - )?; - - force_checkout(&repo, &tree)?; - - 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 { - collect_entries(root, &path, out)?; - } - } - Ok(()) + /// Short names of local branches, sorted alphabetically. + pub branches: Vec, + /// Short names of tags, sorted alphabetically. + pub tags: Vec, } diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index 2929ac3..df799bf 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -12,6 +12,7 @@ use nostr_connect::prelude::*; use nostr_sdk::client::SyncSummary; use nostr_sdk::prelude::*; use signed_core::{Announcement, Filters, RepoAddr, RepoState, filters}; +use signed_git::{GitCache, Repo}; use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update}; use crate::git_store::repo_mirror_path; @@ -408,7 +409,7 @@ impl Backend { // Initialize directly at the user's chosen destination. let destination = { - let dir_name = signed_git::sanitize_path_component(&name); + let dir_name = GitCache::sanitize_path_component(&name); let dir_name = if dir_name.is_empty() { "repository".to_owned() } else { @@ -431,11 +432,11 @@ impl Backend { bail!("destination {} already exists", destination.display()); } - let commit = signed_git::init_repository(&destination, &name, &description)?; + let commit = Repo::init(&destination, &name, &description)?; if let Some(base) = servers.first().and_then(grasp_base_url) { let url = format!("{base}/{owner}/{repo_id}.git"); - signed_git::set_origin(&destination, &url)?; + Repo::open(&destination)?.set_origin(&url)?; } Ok::<_, Error>(commit) @@ -500,7 +501,9 @@ impl Backend { &owner, &servers, &executor, - signed_git::push_main, + |path, base, owner, repo_id| { + Repo::open(path)?.push_main(base, owner, repo_id) + }, ) .await } @@ -585,8 +588,9 @@ impl Backend { let work = cx.background_spawn({ let path = path.clone(); async move { - let state = signed_git::worktree_ref_state(&path)?; - let euc = signed_git::root_commit(&path)?; + let repo = Repo::open(&path)?; + let state = repo.ref_state()?; + let euc = repo.root_commit()?; Ok::<_, Error>((state, euc)) } }); @@ -651,7 +655,9 @@ impl Backend { &owner, &servers, &executor, - signed_git::push_all, + |path, base, owner, repo_id| { + Repo::open(path)?.push_all(base, owner, repo_id) + }, ) .await } @@ -687,7 +693,9 @@ impl Backend { let url = format!("{base}/{owner}/{repo_id}.git"); let path = path.clone(); cx.background_spawn(async move { - signed_git::ensure_origin(&path, &url).ok(); + Repo::open(&path) + .and_then(|repo| repo.ensure_origin(&url)) + .ok(); }) .await; } @@ -699,7 +707,9 @@ impl Backend { Ok(naddr) => { let path = path.clone(); cx.background_spawn(async move { - if let Err(error) = signed_git::set_nostr_repo(&path, &naddr) { + if let Err(error) = + Repo::open(&path).and_then(|repo| repo.set_nostr_repo(&naddr)) + { log::warn!( "failed to record the NIP-34 marker for {}: {error}", path.display() @@ -783,7 +793,7 @@ impl Backend { let mut state = { let work = cx.background_spawn({ let path = path.clone(); - async move { signed_git::worktree_ref_state(&path) } + async move { Repo::open(&path).and_then(|repo| repo.ref_state()) } }); work.await? }; @@ -837,7 +847,9 @@ impl Backend { &owner, &relays, &executor, - signed_git::push_all, + |path, base, owner, repo_id| { + Repo::open(path)?.push_all(base, owner, repo_id) + }, ) .await } @@ -1571,7 +1583,7 @@ async fn push_staged_to_grasps( owner: &str, servers: &[RelayUrl], executor: &BackgroundExecutor, - push: fn(&Path, &str, &str, &str) -> Result<(), Error>, + push: impl Fn(&Path, &str, &str, &str) -> Result<(), Error>, ) -> PushOutcome { let mut outcome = PushOutcome::default(); @@ -1647,7 +1659,9 @@ async fn push_staged_to_grasps( // pushed data is already there. if let Some(last_reason) = &reason && is_stale_advertisement_race(last_reason) - && signed_git::remote_has_refs(path, &git_url, refs).unwrap_or(false) + && Repo::open(path) + .and_then(|repo| repo.remote_has_refs(&git_url, refs)) + .unwrap_or(false) { if let Some(event) = staged_event { keep_newest(&mut outcome.state_event, event); diff --git a/crates/signed_state/src/checkouts.rs b/crates/signed_state/src/checkouts.rs index 67f48f5..0809319 100644 --- a/crates/signed_state/src/checkouts.rs +++ b/crates/signed_state/src/checkouts.rs @@ -7,6 +7,7 @@ use gpui::{App, AppContext, Context, Entity, Global, Subscription}; use nostr::prelude::*; use settings::{CheckoutRecord, SettingsStore}; use signed_core::{Announcement, RepoAddr}; +use signed_git::Repo; use utils::same_repo_url; use crate::backend::{Backend, BackendEvent}; @@ -338,8 +339,14 @@ impl CheckoutsStore { { continue; } - let origin = signed_git::origin_url(path).ok().flatten(); - let root = signed_git::root_commit(path).ok().flatten(); + let origin = Repo::open(path) + .and_then(|repo| repo.origin_url()) + .ok() + .flatten(); + let root = Repo::open(path) + .and_then(|repo| repo.root_commit()) + .ok() + .flatten(); facts.push((path.clone(), origin, root)); } @@ -555,14 +562,15 @@ fn resolve_associations<'a>( } fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option { - let branches = signed_git::worktree_branches(path).ok()?; + let repo = Repo::try_open(path)?; + let branches = repo.branches().ok()?; - if branches.is_empty() || signed_git::worktree_dirty(path) { + if branches.is_empty() || repo.is_dirty() { return None; } - let branch = signed_git::worktree_current_branch(path)?; - let head = signed_git::head_commit_id(path).ok().flatten()?; + let branch = repo.current_branch()?; + let head = repo.head()?; let base = announced_head .filter(|name| branches.iter().any(|b| b == name)) .map(str::to_owned) @@ -573,7 +581,7 @@ fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option 0).then_some(CheckoutStatus { path: path.to_path_buf(), branch, @@ -585,31 +593,33 @@ fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option Option { - if signed_git::worktree_dirty(path) { + let repo = Repo::try_open(path)?; + if repo.is_dirty() { return None; } - let branch = signed_git::worktree_current_branch(path)?; - let head = signed_git::head_commit_id(path).ok().flatten()?; - let origin = signed_git::origin_url(path).ok().flatten()?; + let branch = repo.current_branch()?; + let head = repo.head()?; + let origin = repo.origin_url().ok().flatten()?; if fetch { - signed_git::fetch_repo_refs(path, &[origin], "+refs/heads/*:refs/remotes/origin/*").ok(); + repo.fetch_refs(&[origin], "+refs/heads/*:refs/remotes/origin/*") + .ok(); } let remote = format!("refs/remotes/origin/{branch}"); // A branch never fetched or pushed yet compares against the remote HEAD. // The remote HEAD is the fork point in practice. - let base = if signed_git::worktree_ref_exists(path, &remote) { + let base = if repo.ref_exists(&remote) { remote - } else if signed_git::worktree_ref_exists(path, "refs/remotes/origin/HEAD") { + } else if repo.ref_exists("refs/remotes/origin/HEAD") { "refs/remotes/origin/HEAD".to_owned() } else { return None; }; - let ahead = signed_git::worktree_commits_ahead(path, &base, &branch); + let ahead = repo.commits_ahead(&base, &branch); (ahead > 0).then_some(CheckoutStatus { path: path.to_path_buf(), @@ -730,7 +740,7 @@ mod tests { fn checkout_status_reports_ahead_branches_only() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("repo"); - let _initial = signed_git::init_repository(&path, "My Repo", "").expect("init"); + let _initial = Repo::init(&path, "My Repo", "").expect("init"); let run = |args: &[&str]| { let status = Command::new("git") .current_dir(&path) @@ -777,7 +787,7 @@ mod tests { // Act like a grasp server and allow them. let dir = tempfile::tempdir().expect("tempdir"); let remote = dir.path().join("remote"); - signed_git::init_repository(&remote, "My Repo", "").expect("init"); + Repo::init(&remote, "My Repo", "").expect("init"); let config = Command::new("git") .args(["config", "receive.denyCurrentBranch", "ignore"]) .current_dir(&remote) diff --git a/crates/signed_state/src/git_store.rs b/crates/signed_state/src/git_store.rs index 141a127..961136c 100644 --- a/crates/signed_state/src/git_store.rs +++ b/crates/signed_state/src/git_store.rs @@ -2,10 +2,9 @@ use std::path::PathBuf; use std::sync::OnceLock; use anyhow::Result; -use gix::Repository; use nostr::prelude::Url; use signed_core::RepoAddr; -use signed_git::GitCache; +use signed_git::{GitCache, Repo}; static GIT_CACHE: OnceLock = OnceLock::new(); @@ -26,12 +25,12 @@ pub fn repo_mirror_path(addr: &RepoAddr) -> PathBuf { } /// Open the mirror of `addr`, if it has been cloned. -pub fn open_repo_mirror(addr: &RepoAddr) -> Result> { +pub fn open_repo_mirror(addr: &RepoAddr) -> Result> { git_cache().open(addr) } /// Open the mirror of `addr`, cloning it first when it does not exist yet. -pub fn ensure_repo_mirror(addr: &RepoAddr, clone_urls: &[Url]) -> Result { +pub fn ensure_repo_mirror(addr: &RepoAddr, clone_urls: &[Url]) -> Result { git_cache().ensure_clone(addr, clone_urls) } diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index b1e09bf..3d5e139 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -12,7 +12,7 @@ use signed_core::{ Announcement, Deletions, Filters, GitEvent, PullRequest, RepoAddr, RepoState, RepoStatus, filters, }; -use signed_git::Nip34Binding; +use signed_git::{Nip34Binding, PatchParser, Repo}; use signed_nostr::UniversalSigner; use crate::backend::{ @@ -700,7 +700,7 @@ impl RepoStore { return; }; - let series: Vec = signed_git::split_patch_series(&patch) + let series: Vec = PatchParser::split_patch_series(&patch) .into_iter() .map(str::to_owned) .collect(); @@ -906,9 +906,9 @@ impl RepoStore { let mut pushed = 0; for (url, label) in &targets { - match signed_git::push_commit_ref( - &path, url, &tip, &reference, - ) { + match Repo::open(&path) + .and_then(|repo| repo.push_ref(url, &tip, &reference)) + { Ok(()) => pushed += 1, Err(e) => failures.push(format!("{label}: {e}")), } @@ -986,7 +986,7 @@ impl RepoStore { let merge_base = merge_base.clone(); let compare_ref = compare_ref.clone(); async move { - signed_git::format_patch_between(&repo_path, &merge_base, &compare_ref) + Repo::open(&repo_path)?.format_patch_between(&merge_base, &compare_ref) } }) .await; @@ -1037,7 +1037,7 @@ impl RepoStore { return; } - let series: Vec = signed_git::split_patch_series(&patch) + let series: Vec = PatchParser::split_patch_series(&patch) .into_iter() .map(str::to_owned) .collect(); @@ -1368,7 +1368,9 @@ impl RepoStore { let clone = { let destination = destination.clone(); - cx.background_spawn(async move { signed_git::clone_repo(&clone_urls, &destination) }) + // The clone's repository handle is dropped in the task: gix handles + // are not `Send`, they must not cross the spawn boundary. + cx.background_spawn(async move { Repo::clone(&clone_urls, &destination).map(|_| ()) }) }; cx.spawn(async move |this, cx| { @@ -1596,11 +1598,10 @@ fn comment_builder( #[cfg(test)] mod tests { - use signed_core::GitEvent; - use std::collections::HashSet; use nostr_sdk::prelude::*; + use signed_core::GitEvent; use super::{RepoStore, comment_builder, patch_current_commit}; diff --git a/crates/workspace/src/views/commit_diff/mod.rs b/crates/workspace/src/views/commit_diff/mod.rs index 17799c8..e91702c 100644 --- a/crates/workspace/src/views/commit_diff/mod.rs +++ b/crates/workspace/src/views/commit_diff/mod.rs @@ -17,7 +17,9 @@ use gpui_component::tree::{TreeEntry, TreeItem, TreeState, tree}; use gpui_component::{ ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list, }; -use signed_git::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff}; +use signed_git::{ + CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff, Repo, +}; use signed_state::RepoStore; use signed_ui::{placeholder, tree_row}; use utils::relative_time_secs; @@ -355,14 +357,14 @@ impl CommitDiffView { .background_spawn({ let worktree = worktree.clone(); let id = id.clone(); - async move { signed_git::worktree_commit(&worktree, &id) } + async move { Repo::open(&worktree).and_then(|repo| repo.commit(&id)) } }) .await; let diff = cx .background_spawn({ let worktree = worktree.clone(); let id = id.clone(); - async move { signed_git::worktree_commit_diff(&worktree, &id) } + async move { Repo::open(&worktree).and_then(|repo| repo.commit_diff(&id)) } }) .await; diff --git a/crates/workspace/src/views/pull_requests/detail.rs b/crates/workspace/src/views/pull_requests/detail.rs index c32c71d..7b75eff 100644 --- a/crates/workspace/src/views/pull_requests/detail.rs +++ b/crates/workspace/src/views/pull_requests/detail.rs @@ -22,7 +22,7 @@ use gpui_component::{ }; use nostr::prelude::{Event, EventId, Kind, Url}; use signed_core::{GitEvent, PullRequest, RepoAddr}; -use signed_git::{FileCommit, patch_commits, patch_diffs}; +use signed_git::{FileCommit, PatchParser}; use signed_state::{Backend, ProfileStore, RepoStore, ensure_repo_mirror}; use signed_ui::{Avatar, CountBadge, placeholder, status_badge}; use utils::{relative_time, relative_time_secs}; @@ -233,14 +233,14 @@ impl PullRequestDetailView { let nostr_diff = cx .background_spawn({ let patch = patch.clone(); - async move { patch_diffs(&patch) } + async move { PatchParser::patch_diffs(&patch) } }) .await; let nostr_commits = cx .background_spawn({ let patch = patch.clone(); - async move { patch_commits(&patch) } + async move { PatchParser::patch_commits(&patch) } }) .await; @@ -262,8 +262,9 @@ impl PullRequestDetailView { Some( cx.background_spawn(async move { let repo = ensure_repo_mirror(&addr, &clone_urls)?; + let gix_repo = repo.inner(); - let workdir = repo + let workdir = gix_repo .workdir() .ok_or_else(|| anyhow::anyhow!("repository has no worktree"))? .to_path_buf(); @@ -275,17 +276,16 @@ impl PullRequestDetailView { Some(base) => base, // No `merge-base` tag. Use the merge base of the tip and the default branch. None => { - let head = repo + let head = gix_repo .head_id() .map_err(|_| anyhow::anyhow!("repository has no HEAD"))?; - let tip_id = repo.rev_parse_single(tip.as_bytes())?; - repo.merge_base(tip_id, head)?.to_string() + let tip_id = gix_repo.rev_parse_single(tip.as_bytes())?; + gix_repo.merge_base(tip_id, head)?.to_string() } }; - let diff = signed_git::worktree_commit_range_diff(&workdir, &base, &tip)?; - let commits = - signed_git::worktree_commit_range_commits(&workdir, &base, &tip)?; + let diff = repo.range_diff(&base, &tip)?; + let commits = repo.commit_range(&base, &tip)?; Ok::<_, anyhow::Error>((diff, commits, workdir)) }) diff --git a/crates/workspace/src/views/pull_requests/new.rs b/crates/workspace/src/views/pull_requests/new.rs index 2762655..86e6744 100644 --- a/crates/workspace/src/views/pull_requests/new.rs +++ b/crates/workspace/src/views/pull_requests/new.rs @@ -22,10 +22,7 @@ use gpui_component::{ }; use nostr::prelude::*; use signed_core::{Announcement, RepoAddr}; -use signed_git::{ - delete_refs_with_prefix, fetch_repo_refs, fork_namespace, merge_base, refs_with_prefix, - worktree_commit_range_commits, worktree_commit_range_diff, -}; +use signed_git::{GitCache, Repo}; use signed_state::{ Backend, CheckoutsStore, RepoListStore, RepoStore, ensure_repo_mirror, repo_mirror_path, }; @@ -399,10 +396,12 @@ impl NewPullRequestView { .background_spawn({ let path = path.clone(); async move { - let repo = gix::open(Path::new(&path)).ok()?; - let branches = - signed_git::worktree_branches(Path::new(&path)).unwrap_or_default(); - let current = signed_git::current_branch(&repo).ok().flatten(); + let repo = Repo::try_open(Path::new(&path)); + let branches = repo + .as_ref() + .and_then(|repo| repo.branches().ok()) + .unwrap_or_default(); + let current = repo.as_ref().and_then(|repo| repo.current_branch()); Some((branches, current)) } }) @@ -533,7 +532,7 @@ impl NewPullRequestView { return; }; let mirror_path = repo_mirror_path(&base); - let namespace = fork_namespace(&announcement); + let namespace = GitCache::fork_namespace(&announcement); let clone_urls = announcement.clone.clone(); let base_clone_urls: Vec = self @@ -568,13 +567,13 @@ impl NewPullRequestView { let mirror_path = mirror_path.clone(); async move { ensure_repo_mirror(&base, &base_clone_urls)?; + let repo = Repo::open(&mirror_path)?; // Prune stale imports of any fork. Then import this fork's heads under its namespace. - delete_refs_with_prefix(&mirror_path, "refs/fork")?; + repo.delete_refs_with_prefix("refs/fork")?; // Fetch the fork's refs and import them under the fork's namespace. - fetch_repo_refs( - &mirror_path, + repo.fetch_refs( &clone_urls, &format!("+refs/heads/*:refs/fork/{namespace}/*"), )?; @@ -594,12 +593,12 @@ impl NewPullRequestView { }; let base_branches = strip( - refs_with_prefix(&mirror_path, "refs/remotes/origin")?, + repo.refs_with_prefix("refs/remotes/origin")?, "refs/remotes/origin", ); let compare_branches = strip( - refs_with_prefix(&mirror_path, &format!("refs/fork/{namespace}"))?, + repo.refs_with_prefix(&format!("refs/fork/{namespace}"))?, &format!("refs/fork/{namespace}"), ); @@ -773,24 +772,17 @@ impl NewPullRequestView { let base_name = base_name.clone(); let compare_name = compare_name.clone(); async move { - let merge_base = merge_base(Path::new(&repo_path), &base, &compare)? - .ok_or_else(|| { + let repo = Repo::open(Path::new(&repo_path))?; + let merge_base = + repo.merge_base(&base, &compare)?.ok_or_else(|| { anyhow::anyhow!( "{base_name} and {compare_name} share no common ancestor" ) })?; - let commits = worktree_commit_range_commits( - Path::new(&repo_path), - &merge_base, - &compare, - )?; + let commits = repo.commit_range(&merge_base, &compare)?; - let diff = worktree_commit_range_diff( - Path::new(&repo_path), - &merge_base, - &compare, - )?; + let diff = repo.range_diff(&merge_base, &compare)?; Ok::<_, anyhow::Error>((merge_base, commits, diff)) } diff --git a/crates/workspace/src/views/repo/files.rs b/crates/workspace/src/views/repo/files.rs index ebd3fe9..c9f1bba 100644 --- a/crates/workspace/src/views/repo/files.rs +++ b/crates/workspace/src/views/repo/files.rs @@ -11,7 +11,7 @@ use gpui_component::spinner::Spinner; use gpui_component::text::{TextView, TextViewState}; use gpui_component::tree::{TreeEntry, TreeState, tree}; use gpui_component::{ActiveTheme, Sizable, StyledExt, h_flex, v_flex}; -use signed_git::{FileCommit, WorktreeSnapshot}; +use signed_git::{FileCommit, Repo, WorktreeSnapshot}; use signed_ui::{placeholder, tree_row}; use crate::views::tree::{TreeItemSeed, tree_items}; @@ -611,9 +611,9 @@ impl RepoFilesView { let task: Task> = cx.spawn(async move |this, cx| { let rels: Vec = paths.iter().map(PathBuf::from).collect(); let result = cx - .background_spawn( - async move { signed_git::worktree_last_commits(&worktree, &rels) }, - ) + .background_spawn(async move { + Repo::open_cached(&worktree).and_then(|repo| repo.last_commits(&rels)) + }) .await; this.update(cx, |this, cx| { diff --git a/crates/workspace/src/views/repo/history.rs b/crates/workspace/src/views/repo/history.rs index 21360ed..52dda2f 100644 --- a/crates/workspace/src/views/repo/history.rs +++ b/crates/workspace/src/views/repo/history.rs @@ -8,7 +8,7 @@ use gpui::{Context, Entity, Pixels, Render, Size, Task, WeakEntity, Window, div, use gpui_component::scroll::Scrollbar; use gpui_component::spinner::Spinner; use gpui_component::{ActiveTheme, Sizable, VirtualListScrollHandle, v_flex, v_virtual_list}; -use signed_git::CommitList; +use signed_git::{CommitList, Repo}; use signed_state::RepoStore; use signed_ui::placeholder; @@ -69,7 +69,9 @@ impl RepoHistoryView { let task: Task> = cx.spawn(async move |this, cx| { let result = cx - .background_spawn(async move { signed_git::worktree_all_commits(&worktree) }) + .background_spawn(async move { + Repo::open_cached(&worktree).and_then(|repo| repo.all_commits()) + }) .await; this.update(cx, |this, cx| { diff --git a/crates/workspace/src/views/repo/mod.rs b/crates/workspace/src/views/repo/mod.rs index 23f9dac..30a8dd4 100644 --- a/crates/workspace/src/views/repo/mod.rs +++ b/crates/workspace/src/views/repo/mod.rs @@ -5,7 +5,6 @@ use std::rc::Rc; use anyhow::Error; use assets::CustomIconName; use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handle}; -use gix::Repository; use gpui::prelude::*; use gpui::{ Action, Anchor, AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, @@ -24,7 +23,7 @@ use gpui_component::{ use nostr::nips::nip19::Nip19Coordinate; use nostr::prelude::{RelayUrl, ToBech32, Url}; use signed_core::{Announcement, RepoAddr, RepoStatus}; -use signed_git::FileCommit; +use signed_git::{FileCommit, GitCache, Repo}; use signed_state::{ Backend, CheckoutStatus, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, ProfileStore, RepoListStore, RepoStore, ensure_repo_mirror, open_repo_mirror, @@ -326,7 +325,7 @@ impl RepoDetailView { let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { let data = cx .background_spawn(async move { - let repo = gix::open(&local_path)?; + let repo = Repo::open(&local_path)?; load_repo_data(&repo) }) .await; @@ -409,37 +408,22 @@ impl RepoDetailView { }; // Best-effort, a fetch failure, e.g. offline, keeps the cached state. - signed_git::fetch_all(&repo).ok(); + repo.fetch().ok(); - let worktree = repo.workdir().map(Path::to_path_buf); + let moved = repo.fast_forward_branches().unwrap_or(false); - let moved = match &worktree { - Some(worktree) => { - signed_git::fast_forward_branches(worktree).unwrap_or(false) - } - None => false, - }; - - let (branches, tags) = match &worktree { - Some(_) => ( - signed_git::repo_branches(&repo).unwrap_or_default(), - signed_git::repo_tags(&repo).unwrap_or_default(), - ), - None => (Vec::new(), Vec::new()), - }; - - let current_branch = signed_git::current_branch(&repo).unwrap_or(None); - let head_commit = signed_git::head_commit(&repo).unwrap_or(None); - - Ok::<_, Error>(Some((moved, branches, tags, current_branch, head_commit))) + Ok::<_, Error>(Some((moved, repo.snapshot()?))) }) } .await; this.update_in(cx, |this, window, cx| { - if let Ok(Some((moved, branches, tags, current_branch, head_commit))) = refresh { - let branches: Vec = branches.iter().map(Into::into).collect(); - let tags: Vec = tags.iter().map(Into::into).collect(); + if let Ok(Some((moved, snapshot))) = refresh { + let branches: Vec = + snapshot.branches.iter().map(Into::into).collect(); + let tags: Vec = snapshot.tags.iter().map(Into::into).collect(); + let current_branch = snapshot.current_branch.clone(); + let head_commit = snapshot.head_commit.clone(); let branches_changed = this.refs.set_branches( branches, @@ -532,7 +516,7 @@ impl RepoDetailView { .map(|name| name.to_string()) .filter(|name| !name.trim().is_empty()) .unwrap_or_else(|| addr.identifier().to_owned()); - let name = signed_git::sanitize_path_component(&name); + let name = GitCache::sanitize_path_component(&name); if name.is_empty() { "repository".to_owned() @@ -617,13 +601,12 @@ impl RepoDetailView { let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { let result = cx .background_spawn(async move { + let repo = Repo::open(&worktree); match kind { RefKind::Branch => { - signed_git::worktree_checkout_branch(&worktree, &checkout_name) - } - RefKind::Tag => { - signed_git::worktree_checkout_tag(&worktree, &checkout_name) + repo.and_then(|repo| repo.checkout_branch(&checkout_name)) } + RefKind::Tag => repo.and_then(|repo| repo.checkout_tag(&checkout_name)), } }) .await; @@ -665,7 +648,7 @@ impl RepoDetailView { let task: gpui::Task> = cx.spawn(async move |this, cx| { let result = cx .background_spawn(async move { - let snapshot = signed_git::worktree_snapshot(&worktree)?; + let snapshot = Repo::open(&worktree)?.snapshot()?; // Build the tree off the main thread, like [`Self::load_repo`]. let tree = build_tree_items(&snapshot.entries); let paths = sorted_worktree_paths(&snapshot.entries); @@ -716,7 +699,7 @@ impl RepoDetailView { let task: gpui::Task> = cx.spawn(async move |this, cx| { let result = cx .background_spawn(async move { - let snapshot = signed_git::worktree_snapshot(&worktree)?; + let snapshot = Repo::open(&worktree)?.snapshot()?; let tree = build_tree_items(&snapshot.entries); let paths = sorted_worktree_paths(&snapshot.entries); Ok::<_, Error>((snapshot, tree, paths)) @@ -1952,38 +1935,21 @@ fn truncate_naddr_link(url: &str, tail: usize) -> String { format!("{}...{}", &url[..end], &url[url.len() - tail..]) } -fn load_repo_data(repo: &Repository) -> Result { - let entries = signed_git::worktree_entries(repo)?; - let tree = build_tree_items(&entries); - let readme_path = signed_git::find_readme(repo)?; - - let readme = match &readme_path { - Some(path) => signed_git::worktree_read(repo, path)?, - None => None, - }; - +fn load_repo_data(repo: &Repo) -> Result { + let snapshot = repo.snapshot()?; let worktree = repo.workdir().map(Path::to_path_buf); - let head_commit = signed_git::head_commit(repo).unwrap_or(None); - - let (branches, tags, current_branch) = match &worktree { - Some(_) => ( - signed_git::repo_branches(repo).unwrap_or_default(), - signed_git::repo_tags(repo).unwrap_or_default(), - signed_git::current_branch(repo).unwrap_or(None), - ), - None => (Vec::new(), Vec::new(), None), - }; + let tree = build_tree_items(&snapshot.entries); Ok(RepoData { tree, - entries, - readme_path, - readme, + entries: snapshot.entries, + readme_path: snapshot.readme_path, + readme: snapshot.readme, worktree, - branches, - tags, - current_branch, - head_commit, + branches: snapshot.branches, + tags: snapshot.tags, + current_branch: snapshot.current_branch, + head_commit: snapshot.head_commit, }) }