use std::collections::{HashMap, HashSet}; use std::io::Write; use std::path::{Path, PathBuf}; use std::process::Stdio; use anyhow::{Context, Result, bail}; use gix::bstr::ByteSlice; use gix::interrupt::IS_INTERRUPTED; use gix::progress::Discard; use nostr::prelude::*; use crate::GixResultExt as _; use crate::diff::{CommitDiff, DiffStatus, FileDiff, HunkCollector}; use crate::history::{CommitList, FileCommit, MAX_LISTED_COMMITS}; use crate::nip34::{GraspSignals, Nip34Binding, Nip34Json, Nip34Kind}; use crate::worktree::WorktreeSnapshot; const OBJECT_CACHE_BYTES: usize = 64 * 1024 * 1024; #[derive(Debug, Clone, PartialEq, Eq)] pub struct RepoRefState { pub refs: Vec<(String, String)>, pub head: Option, } impl RepoRefState { /// Creates a ref state from its refs and current branch. fn new(refs: Vec<(String, String)>, head: Option) -> Self { Self { refs, head } } } pub struct Repo { pub(crate) inner: gix::Repository, } impl Repo { /// Wraps an already opened gix repository. pub(crate) fn new(inner: gix::Repository) -> Self { Self { inner } } /// Opens the repository at `workdir`. pub fn open(workdir: &Path) -> Result { Ok(Self::new(gix::open(workdir)?)) } /// Opens the repository at `workdir`, or `None` on failure. pub fn try_open(workdir: &Path) -> Option { Self::open(workdir).ok() } /// Opens the repository at `workdir` with a larger object cache for big diffs. pub fn open_cached(workdir: &Path) -> Result { let mut repo = gix::open(workdir)?; repo.object_cache_size_if_unset(OBJECT_CACHE_BYTES); Ok(Self::new(repo)) } /// Initializes a repository at `path` with a README and an initial commit. pub fn init(path: &Path, name: &str, description: &str) -> Result { use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; std::fs::create_dir_all(path) .with_context(|| format!("failed to create {}", path.display()))?; let repo = gix::init(path)?; let (signature, mut time_buf) = Self::repository_signature(); let signature = signature.to_ref(&mut time_buf); 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(), )?; let mut index = repo.index_from_tree(&tree)?; index .write(gix::index::write::Options::default()) .into_anyhow()?; Ok(commit.to_string()) } /// Clones the first working URL into `path` and fetches the NIP-34 refs. pub fn clone>(clone_urls: &[U], path: &Path) -> Result { if path.exists() { anyhow::bail!("destination {} already exists", path.display()); } let mut last_error = None; for url in clone_urls { match Self::clone_from(url.as_ref(), path) { Ok(repo) => { if let Err(error) = repo.fetch() { log::warn!("failed to fetch NIP-34 refs after cloning: {error:#}"); } 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"), } } /// Clones a single URL into `path`. fn clone_from(url: &str, path: &Path) -> Result { let url = Self::transport_url(url); let url = gix::url::parse(url) .into_anyhow() .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(Self::new(repo)) } /// Returns the underlying gix repository. pub fn inner(&self) -> &gix::Repository { &self.inner } /// Returns the worktree directory, or `None` for a bare repository. pub fn workdir(&self) -> Option<&Path> { self.inner.workdir() } /// Returns the worktree directory, or `.` for a bare repository. pub(crate) fn workdir_or_dot(&self) -> &Path { self.inner.workdir().unwrap_or_else(|| Path::new(".")) } /// Returns the HEAD commit id, or `None` for an unborn HEAD. pub fn head(&self) -> Option { self.inner.head_id().ok().map(|id| id.to_string()) } /// Returns the merge base of `a` and `b`, or `None` for unrelated histories. pub fn merge_base(&self, a: &str, b: &str) -> Result> { let a = self.inner.rev_parse_single(a.as_bytes())?; let b = self.inner.rev_parse_single(b.as_bytes())?; let bases = self.inner.merge_bases_many(a.detach(), &[b.detach()])?; Ok(bases.first().map(|id| id.to_string())) } /// Lists commit ids reachable from HEAD but not `base`, oldest first. pub fn commits_since(&self, base: Option<&str>) -> Result> { let head = match self.inner.head_id() { Ok(head) => head, 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()); } commits.reverse(); Ok(commits) } /// Returns the first commit of the history, or `None` without commits. pub fn root_commit(&self) -> Result> { let Ok(head) = self.inner.head_id() else { return Ok(None); }; 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) } /// Lists sorted ref names equal to or nested under `prefix`. pub fn refs_with_prefix(&self, prefix: &str) -> Result> { let pattern = prefix.trim_end_matches('/'); let mut names = Vec::new(); 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(); 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) } /// Deletes every ref under `prefix`. 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(()) } /// Returns the checked out branch name, or `None` when detached. pub fn current_branch(&self) -> Option { let head = self.inner.head().ok()?; let name = head.referent_name()?; Some(String::from_utf8_lossy(name.shorten()).into_owned()) } /// Returns whether a ref named `name` exists. pub fn ref_exists(&self, name: &str) -> bool { self.inner.find_reference(name).is_ok() } /// Lists sorted local branch names. pub fn branches(&self) -> Result> { let mut names = Vec::new(); for reference in self.inner.references()?.local_branches()? { let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; names.push(String::from_utf8_lossy(reference.name().shorten()).into_owned()); } names.sort(); Ok(names) } /// Lists sorted tag names. 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) } /// Snapshots branch and tag refs with their oids plus the current branch. 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::new(refs, head)) } /// Fast-forwards local branches to their origin counterparts, reporting any move. pub fn fast_forward_branches(&self) -> Result { use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; 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}"); 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; } 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, force_create_reflog: false, message: format!("merge {remote}: Fast-forward").into(), }, expected: PreviousValue::ExistingMustMatch(gix::refs::Target::Object( local_oid, )), new, }, name: full.clone(), deref: false, }; if current.as_deref() == Some(branch) { 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) } /// Diffs a commit against its first parent, skipping trees and submodules. pub fn commit_diff(&self, id: &str) -> Result { let commit_id = self.inner.rev_parse_single(id.as_bytes())?; let commit = commit_id.object()?.into_commit(); let new_tree = commit.tree()?; let old_tree = match commit.parent_ids().next() { Some(parent) => Some(parent.object()?.into_commit().tree()?), None => None, }; Self::tree_diff(self, old_tree.as_ref(), &new_tree) } /// Diffs the trees of `base` and `tip` directly, files sorted by path. pub fn range_diff(&self, base: &str, tip: &str) -> Result { let base_tree = self .inner .rev_parse_single(base.as_bytes())? .object()? .into_commit() .tree()?; let tip_tree = self .inner .rev_parse_single(tip.as_bytes())? .object()? .into_commit() .tree()?; Self::tree_diff(self, Some(&base_tree), &tip_tree) } /// Produces file diffs between two trees, skipping trees and submodules. fn tree_diff( repo: &Repo, old_tree: Option<&gix::Tree<'_>>, new_tree: &gix::Tree<'_>, ) -> Result { use gix::diff::blob::platform::prepare_diff::Operation; use gix::object::tree::diff::Change; use gix::objs::tree::EntryKind; let changes = repo .inner .diff_tree_to_tree(old_tree, Some(new_tree), None)?; let mut cache = repo.inner.diff_resource_cache_for_tree_diff()?; let mut files = Vec::new(); for change in changes { let attached = Change::from_change_ref(change.to_ref(), &repo.inner, &repo.inner); let (path, old_path, status) = match attached { Change::Addition { location, entry_mode, .. } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => { (location.to_owned(), None, DiffStatus::Added) } Change::Deletion { location, entry_mode, .. } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => { (location.to_owned(), None, DiffStatus::Deleted) } Change::Modification { location, previous_entry_mode, entry_mode, .. } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) && !matches!( previous_entry_mode.kind(), EntryKind::Tree | EntryKind::Commit ) => { (location.to_owned(), None, DiffStatus::Modified) } Change::Rewrite { location, source_location, source_entry_mode, entry_mode, copy, .. } if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) && !matches!( source_entry_mode.kind(), EntryKind::Tree | EntryKind::Commit ) => { let status = if copy { DiffStatus::Copied } else { DiffStatus::Renamed }; ( location.to_owned(), Some(source_location.to_owned()), status, ) } _ => continue, }; let platform = attached.diff(&mut cache)?; platform .resource_cache .options .skip_internal_diff_if_external_is_configured = true; let outcome = platform.resource_cache.prepare_diff().into_anyhow()?; let (binary, hunks, insertions, deletions) = match outcome.operation { Operation::InternalDiff { algorithm } => { let input = outcome.interned_input(); let diff = gix::diff::blob::diff_with_slider_heuristics(algorithm, &input); let mut hunks = Vec::new(); let mut insertions = 0usize; let mut deletions = 0usize; let collector = HunkCollector::new(&mut hunks, &mut insertions, &mut deletions); gix::diff::blob::UnifiedDiff::new(&diff, &input, collector, Default::default()) .consume()?; (false, hunks, insertions, deletions) } Operation::SourceOrDestinationIsBinary => (true, Vec::new(), 0, 0), Operation::ExternalCommand { .. } => { unreachable!("external diff drivers are disabled") } }; files.push(FileDiff::new( String::from_utf8_lossy(&path).into_owned(), old_path.map(|p| String::from_utf8_lossy(&p).into_owned()), status, insertions, deletions, binary, hunks, )); } files.sort_by(|a, b| a.path.cmp(&b.path)); Ok(CommitDiff::new(files)) } /// Finds the newest commit touching each path, skipping paths never committed. pub fn last_commits(&self, rels: &[PathBuf]) -> Result> { use gix::traverse::commit::simple::CommitTimeOrder; let Some(head) = self.inner.head_id().ok() else { return Ok(Vec::new()); }; let mut pending: Vec = Vec::with_capacity(rels.len()); let mut seen: HashSet<&Path> = HashSet::with_capacity(rels.len()); for rel in rels { if seen.insert(rel.as_path()) { pending.push(rel.clone()); } } let walk = self .inner .rev_walk([head]) .sorting(gix::revision::walk::Sorting::ByCommitTime( CommitTimeOrder::NewestFirst, )); let mut found = Vec::new(); for info in walk.all()? { if pending.is_empty() { break; } let info = info?; let commit = info.object()?; let tree = commit.tree()?; let parent_tree = match info.parent_ids().next() { Some(parent) => Some(parent.object()?.into_commit().tree()?), None => None, }; let mut ix = 0; while ix < pending.len() { let rel = &pending[ix]; let blob = tree.lookup_entry_by_path(rel)?; let parent_blob = match &parent_tree { Some(tree) => tree.lookup_entry_by_path(rel)?, None => None, }; if blob.map(|entry| entry.id().detach()) != parent_blob.map(|entry| entry.id().detach()) { found.push((rel.clone(), FileCommit::from_commit(&commit)?)); pending.swap_remove(ix); } else { ix += 1; } } } Ok(found) } /// Lists at most `MAX_LISTED_COMMITS` commits plus the real total. pub fn all_commits(&self) -> Result { use gix::traverse::commit::simple::CommitTimeOrder; let Some(head) = self.inner.head_id().ok() else { return Ok(CommitList::new(0, Vec::new())); }; let walk = self .inner .rev_walk([head]) .sorting(gix::revision::walk::Sorting::ByCommitTime( CommitTimeOrder::NewestFirst, )); let mut commits = Vec::new(); let mut total = 0; for info in walk.all()? { let info = info?; total += 1; if commits.len() < MAX_LISTED_COMMITS { commits.push(FileCommit::from_commit_summary(&info.object()?)?); } } Ok(CommitList::new(total, commits)) } /// Lists commits in `base..tip`, newest first. pub fn commit_range(&self, base: &str, tip: &str) -> Result> { use gix::traverse::commit::simple::CommitTimeOrder; let base_id = self.inner.rev_parse_single(base.as_bytes())?; let tip_id = self.inner.rev_parse_single(tip.as_bytes())?; let walk = self .inner .rev_walk([tip_id]) .sorting(gix::revision::walk::Sorting::ByCommitTime( CommitTimeOrder::NewestFirst, )) .with_hidden([base_id]); let mut commits = Vec::new(); for info in walk.all()? { let info = info?; commits.push(FileCommit::from_commit_summary(&info.object()?)?); } Ok(commits) } /// Returns the HEAD commit, or `None` for an unborn HEAD. pub fn head_commit(&self) -> Result> { let Some(head) = self.inner.head_id().ok() else { return Ok(None); }; let commit = head.object()?.into_commit(); Ok(Some(FileCommit::from_commit(&commit)?)) } /// Resolves `id` to a commit, or `None` when unknown. pub fn commit(&self, id: &str) -> Result> { match self.inner.rev_parse_single(id.as_bytes()) { Ok(commit_id) => { let commit = commit_id.object()?.into_commit(); Ok(Some(FileCommit::from_commit(&commit)?)) } Err(_) => Ok(None), } } /// Returns whether the worktree has index or untracked changes. pub fn is_dirty(&self) -> bool { match self.inner.is_dirty() { Ok(true) => return true, Ok(false) => {} Err(_) => return false, } let Ok(platform) = self.inner.status(Discard) else { return false; }; let Ok(mut changes) = platform.into_index_worktree_iter(Vec::::new()) else { return false; }; for change in changes.by_ref() { match change { Ok(gix::status::index_worktree::Item::DirectoryContents { .. }) => return true, Ok(_) => {} Err(_) => return false, } } false } /// Counts commits in `branch` since `base`, or 0 when either is unknown. pub fn commits_ahead(&self, base: &str, branch: &str) -> u32 { let (Some(base), Some(branch)) = (self.resolve_commit(base), self.resolve_commit(branch)) else { return 0; }; let Ok(walk) = self.inner.rev_walk([branch]).with_hidden([base]).all() else { return 0; }; walk.filter_map(Result::ok).count().min(u32::MAX as usize) as u32 } /// Resolves a revision to a gix id. fn resolve_commit<'a>(&'a self, rev: &str) -> Option> { self.inner.rev_parse_single(rev.as_bytes()).ok() } /// Lists every worktree path relative to the root, directories first. pub fn entries(&self) -> Result> { let workdir = self.inner.workdir().context("repository has no worktree")?; let mut entries: Vec<(PathBuf, bool)> = Vec::new(); Self::collect_entries(workdir, workdir, &mut entries)?; entries.sort_by(|(a, a_is_dir), (b, b_is_dir)| { b_is_dir .cmp(a_is_dir) .then_with(|| a.as_os_str().cmp(b.as_os_str())) }); Ok(entries.into_iter().map(|(path, _)| path).collect()) } /// Reads a worktree file, or `None` when missing. pub fn read(&self, rel: &Path) -> Result>> { let workdir = self.inner.workdir().context("repository has no worktree")?; let path = workdir.join(rel); match std::fs::read(&path) { Ok(bytes) => Ok(Some(bytes)), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(e) if e.kind() == std::io::ErrorKind::IsADirectory => Ok(None), Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())), } } /// Finds the top-level README, preferring markdown extensions. pub fn find_readme(&self) -> Result> { let Some(workdir) = self.inner.workdir() else { return Ok(None); }; let mut candidates: Vec = Vec::new(); for entry in std::fs::read_dir(workdir)? { let entry = entry?; let name = entry.file_name(); let Some(name) = name.to_str() else { continue }; if name.to_ascii_lowercase().starts_with("readme") { candidates.push(entry.path()); } } candidates.sort_by_key(|path| { let ext = path .extension() .map(|e| e.to_string_lossy().to_ascii_lowercase()); match ext.as_deref() { Some("md") => 0, Some("markdown") => 1, Some("mdown") => 2, Some("mkdn") => 3, Some(_) => 5, None => 4, } }); Ok(candidates .into_iter() .next() .and_then(|path| path.strip_prefix(workdir).ok().map(Path::to_path_buf))) } /// Captures worktree entries, README, and ref state in one pass. pub fn snapshot(&self) -> Result { let readme_path = self.find_readme()?; let readme = match &readme_path { Some(path) => self.read(path)?, None => None, }; Ok(WorktreeSnapshot::new( self.entries()?, readme_path, readme, self.current_branch(), self.head_commit()?, self.branches()?, self.tags()?, )) } /// Checks out a local branch, replacing worktree contents. pub fn checkout_branch(&self, name: &str) -> Result<()> { let full = format!("refs/heads/{name}"); let branch = gix::refs::FullName::try_from(full.as_str()) .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?; let mut reference = self.inner.find_reference(&full)?; let tree = reference.peel_to_tree()?.id; let (signature, mut time_buf) = Self::repository_signature(); let signature = signature.to_ref(&mut time_buf); self.move_head( signature, gix::refs::Target::Symbolic(branch), &format!("checkout: moving to {name}"), )?; self.force_checkout(&tree)?; Ok(()) } /// Detaches HEAD onto a tag, replacing worktree contents. pub fn checkout_tag(&self, name: &str) -> Result<()> { let full = format!("refs/tags/{name}"); let mut reference = self.inner.find_reference(&full)?; let commit = reference.peel_to_id()?; let tree = reference.peel_to_tree()?.id; let (signature, mut time_buf) = Self::repository_signature(); let signature = signature.to_ref(&mut time_buf); self.move_head( signature, gix::refs::Target::Object(commit.detach()), &format!("checkout: moving to {name}"), )?; self.force_checkout(&tree)?; Ok(()) } /// Writes `tree` over the worktree and removes files absent from it. pub(crate) fn force_checkout(&self, tree: &gix::hash::oid) -> Result<()> { let workdir = self .inner .workdir() .context("repository has no worktree")? .to_path_buf(); let mut index = self.inner.index_from_tree(tree)?; if let Ok(previous) = self.inner.index_or_empty() { let keep: HashSet = index .entries() .iter() .map(|entry| { PathBuf::from(String::from_utf8_lossy(entry.path(&index)).into_owned()) }) .collect(); for entry in previous.entries() { let rel = entry.path(&previous); let rel = PathBuf::from(String::from_utf8_lossy(rel).into_owned()); if keep.contains(&rel) { continue; } let path = workdir.join(&rel); match std::fs::remove_file(&path) { Ok(()) => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => { return Err(error) .with_context(|| format!("failed to remove {}", path.display())); } } } } let mut options = self .inner .checkout_options(gix_worktree::stack::state::attributes::Source::IdMapping)?; options.overwrite_existing = true; let objects = self.inner.objects.clone().into_arc()?; let files = gix::progress::Discard; let bytes = gix::progress::Discard; gix_worktree_state::checkout( &mut index, workdir, objects, &files, &bytes, &gix::interrupt::IS_INTERRUPTED, options, ) .into_anyhow()?; index .write(gix::index::write::Options::default()) .into_anyhow()?; Ok(()) } /// Moves HEAD with a reflog entry. fn move_head( &self, signature: gix::actor::SignatureRef<'_>, target: gix::refs::Target, message: &str, ) -> Result<()> { use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; let head = gix::refs::FullName::try_from("HEAD") .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?; self.inner.edit_references_as( [RefEdit { change: Change::Update { log: LogChange { mode: RefLog::AndReference, force_create_reflog: false, message: message.into(), }, expected: PreviousValue::Any, new: target, }, name: head, deref: false, }], Some(signature), )?; Ok(()) } /// Recursively collects paths under `dir` relative to `root`, skipping `.git`. fn collect_entries(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, bool)>) -> Result<()> { for entry in std::fs::read_dir(dir)? { let entry = entry?; if entry.file_name() == ".git" { continue; } let is_dir = entry.file_type()?.is_dir(); let path = entry.path(); let rel = path.strip_prefix(root)?.to_path_buf(); out.push((rel, is_dir)); if is_dir { Self::collect_entries(root, &path, out)?; } } Ok(()) } /// Applies an mbox patch series with `git am`. pub fn apply_patch(&self, patch: &str) -> Result<()> { let workdir = self .inner .workdir() .context("repository has no worktree")? .to_path_buf(); let mut child = Self::git_command(&workdir) .args(["am"]) .stdin(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .context("failed to spawn `git am`")?; child .stdin .as_mut() .context("git am has no stdin pipe")? .write_all(patch.as_bytes())?; let output = child.wait_with_output()?; if !output.status.success() { bail!("git am failed: {}", String::from_utf8_lossy(&output.stderr)); } Ok(()) } /// Formats `base..tip` as an mbox, failing when the range is empty. pub fn format_patch_between(&self, base: &str, tip: &str) -> Result { let output = Self::run_git( self.workdir_or_dot(), &["format-patch", "--stdout", &format!("{base}..{tip}")], "git format-patch", )?; if !output.status.success() { bail!( "git format-patch failed: {}", String::from_utf8_lossy(&output.stderr).trim() ); } let patch = String::from_utf8_lossy(&output.stdout).into_owned(); if patch.trim().is_empty() { bail!("no commits between {base} and {tip}"); } Ok(patch) } /// Fetches `origin` including the `refs/nostr/*` PR refs. pub fn fetch(&self) -> Result<()> { let options = gix::remote::ref_map::Options { extra_refspecs: vec![ gix::refspec::parse( gix::bstr::BStr::new("+refs/nostr/*:refs/nostr/*"), gix::refspec::parse::Operation::Fetch, ) .into_anyhow()? .to_owned(), ], ..Default::default() }; self.inner .find_remote("origin")? .connect(gix::remote::Direction::Fetch)? .prepare_fetch(Discard, options)? .receive(Discard, &IS_INTERRUPTED)?; Ok(()) } /// Pushes `commit` to `reference` at `url`. pub fn push_ref(&self, url: &str, commit: &str, reference: &str) -> Result<()> { let output = Self::run_git( self.workdir_or_dot(), &["push", url, &format!("{commit}:{reference}")], "git push", )?; if !output.status.success() { bail!( "git push failed: {}", String::from_utf8_lossy(&output.stderr).trim() ); } Ok(()) } /// Pushes `main` to the grasp repository at `base_url`. pub fn push_main(&self, base_url: &str, owner: &str, repo_id: &str) -> Result<()> { self.push_refspecs( base_url, owner, repo_id, &["refs/heads/main:refs/heads/main"], ) } /// Mirrors all branches and tags to the grasp repository at `base_url`. pub fn push_all(&self, base_url: &str, owner: &str, repo_id: &str) -> Result<()> { self.push_refspecs( base_url, owner, repo_id, &["refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"], ) } /// Pushes explicit refspecs to the grasp repository at `base_url`. fn push_refspecs( &self, base_url: &str, owner: &str, repo_id: &str, refspecs: &[&str], ) -> Result<()> { let url = format!("{base_url}/{owner}/{repo_id}.git"); let mut args: Vec<&str> = Vec::with_capacity(refspecs.len() + 2); args.push("push"); args.push(&url); args.extend_from_slice(refspecs); let output = Self::run_git(self.workdir_or_dot(), &args, "git push")?; if !output.status.success() { bail!( "git push to {base_url} failed: {}", String::from_utf8_lossy(&output.stderr).trim() ); } Ok(()) } /// Checks that `url` advertises exactly the expected ref oids. pub fn remote_has_refs(&self, url: &str, expected: &[(String, String)]) -> Result { if expected.is_empty() { return Ok(true); } let url = Self::transport_url(url); let refspecs = expected .iter() .map(|(name, _)| { gix::refspec::parse( gix::bstr::BStr::new(format!("+{name}:{name}").as_bytes()), gix::refspec::parse::Operation::Fetch, ) .map(|spec| spec.to_owned()) }) .collect::, _>>() .into_anyhow() .context("invalid refspec")?; let options = gix::remote::ref_map::Options { extra_refspecs: refspecs, ..Default::default() }; let (refs, _) = self .inner .remote_at(url.as_str()) .with_context(|| format!("cannot use remote {url}"))? .connect(gix::remote::Direction::Fetch) .with_context(|| format!("cannot connect to {url}"))? .ref_map(Discard, options) .with_context(|| format!("listing refs of {url} failed"))?; let advertised: HashMap = refs .remote_refs .iter() .filter_map(|reference| { let (name, object, _peeled) = reference.unpack(); object.map(|oid| (String::from_utf8_lossy(name).into_owned(), oid.to_string())) }) .collect(); Ok(expected .iter() .all(|(name, oid)| advertised.get(name.as_str()) == Some(oid))) } /// Sets the `origin` URL when the repository has no remote yet. pub fn ensure_origin(&self, url: &str) -> Result<()> { if self.inner.find_remote("origin").is_ok() { return Ok(()); } self.edit_local_config(|config| { config .set_raw_value("remote.origin.url", url) .into_anyhow()?; config .set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*") .into_anyhow()?; Ok(()) }) } /// Repoints `origin` at `url`, keeping any existing fetch refspec. pub fn set_origin(&self, url: &str) -> Result<()> { let had_origin = self.inner.find_remote("origin").is_ok(); self.edit_local_config(|config| { config .set_raw_value("remote.origin.url", url) .into_anyhow()?; if !had_origin { config .set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*") .into_anyhow()?; } Ok(()) }) } /// Returns the `origin` fetch URL, or `None` without an origin. pub fn origin_url(&self) -> Result> { let Ok(remote) = self.inner.find_remote("origin") else { return Ok(None); }; Ok(remote .url(gix::remote::Direction::Fetch) .map(|url| url.to_string())) } /// Fetches `refspec` from the first working URL. pub fn fetch_refs>(&self, urls: &[U], refspec: &str) -> Result<()> { let refspec = gix::refspec::parse( gix::bstr::BStr::new(refspec), gix::refspec::parse::Operation::Fetch, ) .into_anyhow() .context("invalid fetch refspec")? .to_owned(); let mut last_error = None; for url in urls { let url = Self::transport_url(url.as_ref()); let outcome = (|| -> Result<()> { let options = gix::remote::ref_map::Options { extra_refspecs: vec![refspec.clone()], ..Default::default() }; self.inner .remote_at(url.as_str()) .with_context(|| format!("fetch from {url} failed"))? .connect(gix::remote::Direction::Fetch) .with_context(|| format!("fetch from {url} failed"))? .prepare_fetch(Discard, options) .with_context(|| format!("fetch from {url} failed"))? .receive(Discard, &IS_INTERRUPTED) .with_context(|| format!("fetch from {url} failed"))?; Ok(()) })(); match outcome { Ok(()) => return Ok(()), Err(error) => last_error = Some(error), } } match last_error { Some(error) => Err(error).context("failed to fetch from any mirror"), None => bail!("no fetch URLs provided"), } } /// Edits the repository config under a lock and saves it. pub(crate) fn edit_local_config( &self, edit: impl FnOnce(&mut gix::config::File) -> Result<()>, ) -> Result<()> { let config_path = self.inner.common_dir().join("config"); let mut lock = gix::lock::File::acquire_to_update_resource( &config_path, gix::lock::acquire::Fail::Immediately, None, ) .into_anyhow() .context("failed to lock repository config")?; let mut config = match gix::config::File::from_path_no_includes(config_path, gix::config::Source::Local) { Ok(config) => config, Err(error) if error.is_not_found() => gix::config::File::default(), Err(error) => { return Err(error) .into_anyhow() .context("failed to read repository config"); } }; edit(&mut config)?; config .write_to(&mut lock) .context("failed to write repository config")?; lock.commit().context("failed to save repository config")?; Ok(()) } /// Runs `git` in `dir` and returns its output. pub(crate) fn run_git(dir: &Path, args: &[&str], what: &str) -> Result { Self::git_command(dir) .args(args) .stderr(Stdio::piped()) .output() .with_context(|| format!("failed to spawn `{what}`")) } /// Builds a `git` command working in `dir` without terminal prompts. pub(crate) fn git_command(dir: &Path) -> std::process::Command { let mut command = std::process::Command::new("git"); command.arg("-C").arg(dir).env("GIT_TERMINAL_PROMPT", "0"); command } /// Detects the repository's NIP-34 binding, or `None` without Nostr markers. pub fn nip34_binding(&self) -> Option { let common_dir = self.inner.common_dir().to_path_buf(); let workdir = self.inner.workdir().map(std::path::Path::to_path_buf); let mut signals = GraspSignals::default(); let mut owner: Option = None; let mut identifier: Option = None; let mut grasp_urls: Vec = Vec::new(); if let Some(workdir) = &workdir { if let Ok(bytes) = std::fs::read(workdir.join("nip34.json")) && let Ok(config) = serde_json::from_slice::(&bytes) { signals.nip34_json = true; identifier = config.identifier.and_then(Self::non_empty); owner = config .owner .as_deref() .and_then(|value| PublicKey::parse(value).ok()); } if workdir.join("maintainers.yaml").is_file() { signals.maintainers_yaml = true; } } if let Ok(exclude) = std::fs::read_to_string(common_dir.join("info/exclude")) && exclude.contains("nip34.json") { signals.nip34_excluded = true; } if common_dir.join("nostr-cache.lmdb").is_file() { signals.nostr_cache = true; } if let Ok(config) = gix::config::File::from_path_no_includes( common_dir.join("config"), gix::config::Source::Local, ) { if let Some(value) = config.string("nostr.repo") && let Some((key, id)) = Self::coordinate_from_naddr(&value.to_str_lossy()) { signals.nostr_repo_config = true; owner = Some(key); identifier = Some(id); } for key in ["nostr.repo-relay-only", "nostr.nostate", "nostr.private"] { if config.string(key).is_some() { signals.nostr_aux_config = true; } } if let Some(sections) = config.sections_by_name("remote") { for section in sections { let Some(name) = section.header().subsection_name() else { continue; }; let nak_grasp_remote = name.to_str_lossy().starts_with("nip34/grasp/"); for url in section.values("url") { let url = url.to_str_lossy(); if url.starts_with("nostr://") { signals.nostr_remote = true; if owner.is_none() && identifier.is_none() && let Some((key, id)) = Self::parse_nostr_url(&url) { owner = Some(key); identifier = Some(id); } } if Self::is_grasp_url(&url) { signals.grasp_remote = true; signals.nip34_grasp_remote |= nak_grasp_remote; grasp_urls.push(url.to_string()); if owner.is_none() && identifier.is_none() && let Some((key, id)) = Self::grasp_parts(&url) { owner = Some(key); identifier = Some(id); } } } } } } if let Ok(platform) = self.inner.references() && let Ok(mut refs) = platform.prefixed(b"refs/heads/nip34/state/") && refs.next().is_some() { signals.nip34_state_refs = true; } if !signals.any() { return None; } let kind = if signals.nip34_json || signals.nostr_repo_config || signals.nip34_grasp_remote || signals.nip34_state_refs { Nip34Kind::Initialized } else if signals.nostr_remote { Nip34Kind::Cloned } else { Nip34Kind::ToolingOnly }; Some(Nip34Binding::new( kind, signals, owner, identifier, grasp_urls, )) } /// Stores a NIP-34 coordinate in the `nostr.repo` config. pub fn set_nostr_repo(&self, naddr: &str) -> Result<()> { self.edit_local_config(|config| { config.set_raw_value("nostr.repo", naddr).into_anyhow()?; Ok(()) }) } /// Matches grasp URLs by shape: two path segments with an npub owner. pub(crate) fn is_grasp_url(url: &str) -> bool { let Ok(parsed) = Url::parse(url) else { return false; }; if !matches!(parsed.scheme(), "http" | "https" | "grasp") { return false; } let path = parsed.path(); if path.matches('/').count() != 2 || path.len() < 65 { return false; } Self::grasp_parts(url).is_some() } /// Splits a grasp URL into its owner and identifier. fn grasp_parts(url: &str) -> Option<(PublicKey, String)> { let parsed = Url::parse(url).ok()?; let mut segments = parsed.path_segments()?.filter(|part| !part.is_empty()); let owner = PublicKey::parse(segments.next()?).ok()?; let identifier = Self::non_empty(segments.next()?.trim_end_matches(".git"))?; Some((owner, identifier)) } /// Decodes an `naddr` coordinate of a git repo announcement. fn coordinate_from_naddr(value: &str) -> Option<(PublicKey, String)> { let coordinate = Nip19Coordinate::from_bech32(value).ok()?; if coordinate.kind != Kind::GitRepoAnnouncement { return None; } let identifier = Self::non_empty(coordinate.identifier.clone())?; Some((coordinate.public_key, identifier)) } /// Extracts owner and identifier from a `nostr://` URL. fn parse_nostr_url(url: &str) -> Option<(PublicKey, String)> { let rest = url.strip_prefix("nostr://")?; if rest.starts_with("naddr1") { return Self::coordinate_from_naddr(rest); } let rest = rest.rsplit_once('@').map_or(rest, |(_, after)| after); let mut parts: Vec<&str> = rest.split('/').filter(|part| !part.is_empty()).collect(); if parts .first() .is_some_and(|first| matches!(*first, "ssh" | "https" | "http")) { parts.remove(0); } if parts.len() < 2 { return None; } let owner = PublicKey::parse(parts[0]).ok()?; let identifier = Self::non_empty(parts.last()?.trim_end_matches(".git"))?; Some((owner, identifier)) } /// Returns `value` unless it is empty. fn non_empty(value: impl Into) -> Option { let value = value.into(); (!value.is_empty()).then_some(value) } /// Returns the fixed author signature used for repository writes. pub(crate) fn repository_signature() -> (gix::actor::Signature, gix::date::parse::TimeBuf) { let seconds = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .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()) } /// Rewrites `grasp://` URLs to `https://`, leaving others untouched. pub(crate) fn transport_url(url: &str) -> String { url.strip_prefix("grasp://") .map(|rest| format!("https://{rest}")) .unwrap_or_else(|| url.to_owned()) } } #[cfg(test)] mod tests { use std::path::Path; use std::process::Command; use super::*; /// Creates a fresh repository in a temp directory. fn init_repo() -> (tempfile::TempDir, std::path::PathBuf) { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("repo"); std::fs::create_dir_all(&path).expect("mkdir"); git(&path, &["init", "-q"]); (dir, path) } /// Runs `git` in `dir`, failing the test on a non-zero exit. fn git(dir: &Path, args: &[&str]) { let status = Command::new("git") .current_dir(dir) .env("GIT_AUTHOR_NAME", "Test Author") .env("GIT_AUTHOR_EMAIL", "test@example.com") .env("GIT_COMMITTER_NAME", "Test Author") .env("GIT_COMMITTER_EMAIL", "test@example.com") .env("GIT_EDITOR", "true") .args(args) .status() .expect("spawn git"); assert!(status.success(), "git {args:?} failed"); } /// Generates a fresh public key. fn key() -> PublicKey { Keys::generate().public_key() } /// Encodes a NIP-34 coordinate as an `naddr` string. fn naddr(kind: Kind, owner: PublicKey, identifier: &str) -> String { let coordinate = Coordinate::new(kind, owner).identifier(identifier); Nip19Coordinate::new(coordinate, Vec::::new()) .to_bech32() .expect("naddr") } /// Opens the repository at `path` and returns its NIP-34 binding. fn binding_of(path: &Path) -> Option { Repo::open(path).ok()?.nip34_binding() } #[test] fn plain_repository_has_no_binding() { let (_dir, path) = init_repo(); assert!(binding_of(&path).is_none()); } #[test] fn nip34_json_marks_a_repository_initialized() { let (_dir, path) = init_repo(); let owner = key(); let npub = owner.to_bech32().expect("npub"); std::fs::write( path.join("nip34.json"), format!(r#"{{"identifier":"my-repo","owner":"{npub}"}}"#), ) .expect("write"); let binding = binding_of(&path).expect("binding"); assert_eq!(binding.kind, Nip34Kind::Initialized); assert!(binding.signals.nip34_json); assert_eq!(binding.owner, Some(owner)); assert_eq!(binding.identifier.as_deref(), Some("my-repo")); } #[test] fn malformed_nip34_json_is_ignored() { let (_dir, path) = init_repo(); std::fs::write(path.join("nip34.json"), b"not json").expect("write"); assert!(binding_of(&path).is_none()); } #[test] fn nak_exclude_and_state_refs_are_detected() { let (_dir, path) = init_repo(); std::fs::create_dir_all(path.join(".git/info")).expect("mkdir"); std::fs::write(path.join(".git/info/exclude"), "nip34.json\n").expect("write"); git(&path, &["commit", "-q", "--allow-empty", "-m", "initial"]); git( &path, &["update-ref", "refs/heads/nip34/state/HEAD", "HEAD"], ); let binding = binding_of(&path).expect("binding"); assert_eq!(binding.kind, Nip34Kind::Initialized); assert!(binding.signals.nip34_excluded); assert!(binding.signals.nip34_state_refs); } #[test] fn nostr_repo_config_marks_a_repository_initialized() { let (_dir, path) = init_repo(); let owner = key(); let naddr = naddr(Kind::GitRepoAnnouncement, owner, "my-repo"); git(&path, &["config", "nostr.repo", &naddr]); let binding = binding_of(&path).expect("binding"); assert_eq!(binding.kind, Nip34Kind::Initialized); assert!(binding.signals.nostr_repo_config); assert_eq!(binding.owner, Some(owner)); assert_eq!(binding.identifier.as_deref(), Some("my-repo")); } #[test] fn the_written_nostr_repo_marker_is_detected() { let (_dir, path) = init_repo(); let owner = key(); let naddr = naddr(Kind::GitRepoAnnouncement, owner, "my-repo"); Repo::open(&path) .expect("open") .set_nostr_repo(&naddr) .expect("write marker"); let binding = binding_of(&path).expect("binding"); assert_eq!(binding.kind, Nip34Kind::Initialized); assert!(binding.signals.nostr_repo_config); assert_eq!(binding.owner, Some(owner)); assert_eq!(binding.identifier.as_deref(), Some("my-repo")); } #[test] fn nostr_remote_is_a_nip34_clone() { let (_dir, path) = init_repo(); let owner = key(); let npub = owner.to_bech32().expect("npub"); let url = format!("nostr://{npub}/relay.ngit.dev/my-repo"); git(&path, &["remote", "add", "origin", &url]); let binding = binding_of(&path).expect("binding"); assert_eq!(binding.kind, Nip34Kind::Cloned); assert!(binding.signals.nostr_remote); assert_eq!(binding.owner, Some(owner)); assert_eq!(binding.identifier.as_deref(), Some("my-repo")); } #[test] fn nak_grasp_remote_marks_a_repository_initialized() { let (_dir, path) = init_repo(); let owner = key(); let npub = owner.to_bech32().expect("npub"); let url = format!("https://gitnostr.com/{npub}/my-repo.git"); git( &path, &["config", "remote.nip34/grasp/gitnostr.com.url", &url], ); let binding = binding_of(&path).expect("binding"); assert_eq!(binding.kind, Nip34Kind::Initialized); assert!(binding.signals.nip34_grasp_remote); assert!(binding.signals.grasp_remote); assert_eq!(binding.grasp_urls, vec![url]); assert_eq!(binding.owner, Some(owner)); assert_eq!(binding.identifier.as_deref(), Some("my-repo")); } #[test] fn nostr_cache_alone_is_tooling_only() { let (_dir, path) = init_repo(); std::fs::write(path.join(".git/nostr-cache.lmdb"), b"cache").expect("write"); let binding = binding_of(&path).expect("binding"); assert_eq!(binding.kind, Nip34Kind::ToolingOnly); assert!(binding.signals.nostr_cache); } #[test] fn grasp_urls_are_recognised_by_shape() { let owner = key(); let npub = owner.to_bech32().expect("npub"); assert!(Repo::is_grasp_url(&format!( "https://gitnostr.com/{npub}/my-repo.git" ))); assert!(Repo::is_grasp_url(&format!( "grasp://gitnostr.com/{npub}/my-repo.git" ))); assert!(!Repo::is_grasp_url("https://gitnostr.com/my-repo.git")); assert!(!Repo::is_grasp_url( "https://gitnostr.com/not-a-pubkey/my-repo.git" )); assert!(!Repo::is_grasp_url(&format!( "ssh://gitnostr.com/{npub}/my-repo.git" ))); } }