chore: refactor backend around domain types (#26)
Reviewed-on: #26
This commit was merged in pull request #26.
This commit is contained in:
@@ -3,9 +3,8 @@ 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)]
|
||||
pub struct GitCache {
|
||||
root: PathBuf,
|
||||
@@ -22,30 +21,25 @@ 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(addr.public_key().to_hex())
|
||||
.join(Self::sanitize_path_component(addr.identifier()))
|
||||
}
|
||||
|
||||
pub fn open(&self, addr: &RepoAddr) -> Result<Option<gix::Repository>> {
|
||||
pub fn open(&self, addr: &RepoAddr) -> Result<Option<Repo>> {
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the existing clone, fetching it first.
|
||||
pub fn ensure_clone<U: AsRef<str>>(
|
||||
&self,
|
||||
addr: &RepoAddr,
|
||||
clone_urls: &[U],
|
||||
) -> Result<gix::Repository> {
|
||||
pub fn ensure_clone<U: AsRef<str>>(&self, addr: &RepoAddr, clone_urls: &[U]) -> Result<Repo> {
|
||||
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 +48,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
|
||||
}
|
||||
// `id` is untrusted relay content: it must never escape the cache root as
|
||||
// a single path component.
|
||||
pub fn sanitize_path_component(id: &str) -> String {
|
||||
let sanitized: String = id
|
||||
.chars()
|
||||
.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
|
||||
}
|
||||
|
||||
pub fn fork_namespace(announcement: &Announcement) -> String {
|
||||
format!(
|
||||
"{}/{}",
|
||||
announcement.owner.to_hex(),
|
||||
Self::sanitize_path_component(&announcement.id)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+150
-166
@@ -1,11 +1,10 @@
|
||||
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.
|
||||
Context,
|
||||
Addition,
|
||||
Deletion,
|
||||
@@ -14,21 +13,15 @@ pub enum DiffLineKind {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiffLine {
|
||||
pub kind: DiffLineKind,
|
||||
/// 1-based line number in the old version, if the line exists there.
|
||||
pub old: Option<u32>,
|
||||
/// 1-based line number in the new version, if the line exists there.
|
||||
pub new: Option<u32>,
|
||||
/// Line content without the trailing newline.
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// A hunk of a file diff, like `@@ -a,b +c,d @@`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiffHunk {
|
||||
/// 1-based start line in the old version.
|
||||
pub old_start: u32,
|
||||
pub old_lines: u32,
|
||||
/// 1-based start line in the new version.
|
||||
pub new_start: u32,
|
||||
pub new_lines: u32,
|
||||
pub lines: Vec<DiffLine>,
|
||||
@@ -45,18 +38,13 @@ pub enum DiffStatus {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileDiff {
|
||||
/// Path of the file relative to the repo root.
|
||||
///
|
||||
/// For renames and copies, this is the destination path.
|
||||
// For renames and copies, the destination path.
|
||||
pub path: String,
|
||||
/// Previous path, for renames and copies.
|
||||
pub old_path: Option<String>,
|
||||
pub status: DiffStatus,
|
||||
/// Number of added lines, 0 for binary files.
|
||||
pub insertions: usize,
|
||||
/// Number of removed lines, 0 for binary files.
|
||||
pub deletions: usize,
|
||||
/// True if either version is binary, then `hunks` is empty.
|
||||
// Binary files have empty `hunks`.
|
||||
pub binary: bool,
|
||||
pub hunks: Vec<DiffHunk>,
|
||||
}
|
||||
@@ -66,162 +54,158 @@ pub struct CommitDiff {
|
||||
pub files: Vec<FileDiff>,
|
||||
}
|
||||
|
||||
/// The changes of the commit `id`, short or full, in the repository at `workdir`.
|
||||
///
|
||||
/// Compared against its first parent, the empty tree for the root commit.
|
||||
pub fn worktree_commit_diff(workdir: &Path, id: &str) -> Result<CommitDiff> {
|
||||
commit_diff(&gix::open(workdir)?, id)
|
||||
}
|
||||
|
||||
fn commit_diff(repo: &gix::Repository, id: &str) -> Result<CommitDiff> {
|
||||
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<CommitDiff> {
|
||||
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<CommitDiff> {
|
||||
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 {
|
||||
// Compared against the first parent; the empty tree for the root commit.
|
||||
pub fn commit_diff(&self, id: &str) -> Result<CommitDiff> {
|
||||
let commit_id = self.inner.rev_parse_single(id.as_bytes())?;
|
||||
let commit = commit_id.object()?.into_commit();
|
||||
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));
|
||||
// Directories and submodules are skipped, files are sorted by path.
|
||||
pub fn range_diff(&self, base: &str, tip: &str) -> Result<CommitDiff> {
|
||||
let base_tree = self
|
||||
.inner
|
||||
.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<CommitDiff> {
|
||||
use gix::diff::blob::platform::prepare_diff::Operation;
|
||||
use gix::object::tree::diff::Change;
|
||||
use gix::objs::tree::EntryKind;
|
||||
|
||||
let changes = repo
|
||||
.inner
|
||||
.diff_tree_to_tree(old_tree, Some(new_tree), None)?;
|
||||
|
||||
let mut cache = repo.inner.diff_resource_cache_for_tree_diff()?;
|
||||
let mut files = Vec::new();
|
||||
|
||||
for change in changes {
|
||||
let attached = Change::from_change_ref(change.to_ref(), &repo.inner, &repo.inner);
|
||||
|
||||
let (path, old_path, status) = match attached {
|
||||
Change::Addition {
|
||||
location,
|
||||
entry_mode,
|
||||
..
|
||||
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => {
|
||||
(location.to_owned(), None, DiffStatus::Added)
|
||||
}
|
||||
Change::Deletion {
|
||||
location,
|
||||
entry_mode,
|
||||
..
|
||||
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => {
|
||||
(location.to_owned(), None, DiffStatus::Deleted)
|
||||
}
|
||||
Change::Modification {
|
||||
location,
|
||||
previous_entry_mode,
|
||||
entry_mode,
|
||||
..
|
||||
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit)
|
||||
&& !matches!(
|
||||
previous_entry_mode.kind(),
|
||||
EntryKind::Tree | EntryKind::Commit
|
||||
) =>
|
||||
{
|
||||
(location.to_owned(), None, DiffStatus::Modified)
|
||||
}
|
||||
Change::Rewrite {
|
||||
location,
|
||||
source_location,
|
||||
source_entry_mode,
|
||||
entry_mode,
|
||||
copy,
|
||||
..
|
||||
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit)
|
||||
&& !matches!(
|
||||
source_entry_mode.kind(),
|
||||
EntryKind::Tree | EntryKind::Commit
|
||||
) =>
|
||||
{
|
||||
let status = if copy {
|
||||
DiffStatus::Copied
|
||||
} else {
|
||||
DiffStatus::Renamed
|
||||
};
|
||||
(
|
||||
location.to_owned(),
|
||||
Some(source_location.to_owned()),
|
||||
status,
|
||||
)
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// External diff drivers would shell out, out of scope for a read-only viewer.
|
||||
let platform = attached.diff(&mut cache)?;
|
||||
platform
|
||||
.resource_cache
|
||||
.options
|
||||
.skip_internal_diff_if_external_is_configured = true;
|
||||
let outcome = platform.resource_cache.prepare_diff()?;
|
||||
|
||||
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.
|
||||
struct HunkCollector<'a> {
|
||||
hunks: &'a mut Vec<DiffHunk>,
|
||||
insertions: &'a mut usize,
|
||||
|
||||
+161
-213
@@ -3,246 +3,194 @@ 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)]
|
||||
pub struct FileCommit {
|
||||
/// Shortened commit id, 7+ hex chars, disambiguated if needed.
|
||||
pub id: String,
|
||||
/// First line of the commit message.
|
||||
pub summary: String,
|
||||
/// Rest of the commit message after the title.
|
||||
///
|
||||
/// `None` for single-line commit messages.
|
||||
pub description: Option<String>,
|
||||
pub author: String,
|
||||
/// Author time, seconds since the Unix epoch.
|
||||
pub time: i64,
|
||||
}
|
||||
|
||||
/// 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<gix::Repository> {
|
||||
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<FileCommit> {
|
||||
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<FileCommit> {
|
||||
file_commit_with_description(commit, false)
|
||||
}
|
||||
|
||||
fn file_commit_with_description(
|
||||
commit: &gix::Commit<'_>,
|
||||
include_description: bool,
|
||||
) -> Result<FileCommit> {
|
||||
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 -- <rel>` 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<Vec<(PathBuf, FileCommit)>> {
|
||||
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<Vec<(PathBuf, FileCommit)>> {
|
||||
use gix::traverse::commit::simple::CommitTimeOrder;
|
||||
|
||||
let Some(head) = repo.head_id().ok() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
let mut pending: Vec<PathBuf> = 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 {
|
||||
fn from_commit(commit: &gix::Commit<'_>) -> Result<FileCommit> {
|
||||
Self::from_commit_with_description(commit, true)
|
||||
}
|
||||
|
||||
let walk = repo
|
||||
.rev_walk([head])
|
||||
.sorting(gix::revision::walk::Sorting::ByCommitTime(
|
||||
CommitTimeOrder::NewestFirst,
|
||||
));
|
||||
// History lists never display the body,
|
||||
// skipping it saves an allocation per listed commit.
|
||||
fn from_commit_summary(commit: &gix::Commit<'_>) -> Result<FileCommit> {
|
||||
Self::from_commit_with_description(commit, false)
|
||||
}
|
||||
|
||||
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<FileCommit> {
|
||||
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 {
|
||||
// Paths without any commit, like untracked files, are absent from the result.
|
||||
pub fn last_commits(&self, rels: &[PathBuf]) -> Result<Vec<(PathBuf, FileCommit)>> {
|
||||
use gix::traverse::commit::simple::CommitTimeOrder;
|
||||
|
||||
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<PathBuf> = 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)
|
||||
pub fn all_commits(&self) -> Result<CommitList> {
|
||||
use gix::traverse::commit::simple::CommitTimeOrder;
|
||||
|
||||
let Some(head) = self.inner.head_id().ok() else {
|
||||
return Ok(CommitList {
|
||||
total: 0,
|
||||
commits: Vec::new(),
|
||||
});
|
||||
};
|
||||
|
||||
let walk = self
|
||||
.inner
|
||||
.rev_walk([head])
|
||||
.sorting(gix::revision::walk::Sorting::ByCommitTime(
|
||||
CommitTimeOrder::NewestFirst,
|
||||
));
|
||||
|
||||
let mut commits = Vec::new();
|
||||
let mut total = 0;
|
||||
|
||||
for info in walk.all()? {
|
||||
let info = info?;
|
||||
total += 1;
|
||||
if commits.len() < MAX_LISTED_COMMITS {
|
||||
commits.push(FileCommit::from_commit_summary(&info.object()?)?);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(CommitList { total, commits })
|
||||
}
|
||||
|
||||
pub fn commit_range(&self, base: &str, tip: &str) -> Result<Vec<FileCommit>> {
|
||||
use gix::traverse::commit::simple::CommitTimeOrder;
|
||||
|
||||
let base_id = self.inner.rev_parse_single(base.as_bytes())?;
|
||||
let tip_id = self.inner.rev_parse_single(tip.as_bytes())?;
|
||||
let walk = self
|
||||
.inner
|
||||
.rev_walk([tip_id])
|
||||
.sorting(gix::revision::walk::Sorting::ByCommitTime(
|
||||
CommitTimeOrder::NewestFirst,
|
||||
))
|
||||
.with_hidden([base_id]);
|
||||
|
||||
let mut commits = Vec::new();
|
||||
|
||||
for info in walk.all()? {
|
||||
let info = info?;
|
||||
commits.push(FileCommit::from_commit_summary(&info.object()?)?);
|
||||
}
|
||||
|
||||
Ok(commits)
|
||||
}
|
||||
|
||||
// `Ok(None)` for an unborn HEAD.
|
||||
pub fn head_commit(&self) -> Result<Option<FileCommit>> {
|
||||
let Some(head) = self.inner.head_id().ok() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let commit = head.object()?.into_commit();
|
||||
Ok(Some(FileCommit::from_commit(&commit)?))
|
||||
}
|
||||
|
||||
// `Ok(None)` when the id cannot be resolved.
|
||||
pub fn commit(&self, id: &str) -> Result<Option<FileCommit>> {
|
||||
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,
|
||||
/// the tab badge shows the real count.
|
||||
///
|
||||
/// A huge history is never fully materialized in memory.
|
||||
// The virtual list renders a window at a time, the tab badge shows the real
|
||||
// count: a huge history is never fully materialized in memory.
|
||||
pub const MAX_LISTED_COMMITS: usize = 20_000;
|
||||
|
||||
/// Commits reachable from `HEAD`, newest first, possibly capped.
|
||||
pub struct CommitList {
|
||||
/// Number of commits reachable from HEAD.
|
||||
pub total: usize,
|
||||
/// Newest commits, capped at [`MAX_LISTED_COMMITS`].
|
||||
pub commits: Vec<FileCommit>,
|
||||
}
|
||||
|
||||
/// All commits reachable from `HEAD`, newest first, with author and summary.
|
||||
///
|
||||
/// Returns an empty list for a repository without any commits yet.
|
||||
pub fn all_commits(repo: &gix::Repository) -> Result<CommitList> {
|
||||
use gix::traverse::commit::simple::CommitTimeOrder;
|
||||
|
||||
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<CommitList> {
|
||||
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<Vec<FileCommit>> {
|
||||
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<Option<FileCommit>> {
|
||||
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<Option<FileCommit>> {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
let output = remote::git_output(dir, args, "git")?;
|
||||
let output = Repo::run_git(dir, args, "git")?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
|
||||
+205
-199
@@ -1,17 +1,17 @@
|
||||
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 {
|
||||
/// Bound to a NIP-34 coordinate, by `nak`'s `nip34.json` or `ngit`'s `nostr.repo`.
|
||||
// By `nak`'s `nip34.json` or `ngit`'s `nostr.repo`.
|
||||
Initialized,
|
||||
/// Cloned from a `nostr://` remote but never initialized locally.
|
||||
// Cloned from a `nostr://` remote but never initialized locally.
|
||||
Cloned,
|
||||
/// Nostr tooling touched the repository but no binding is recoverable.
|
||||
// Nostr tooling touched the repository but no binding is recoverable.
|
||||
ToolingOnly,
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ pub struct GraspSignals {
|
||||
pub nostr_repo_config: bool,
|
||||
pub nostr_remote: bool,
|
||||
pub grasp_remote: bool,
|
||||
/// `nip34_grasp_remote` is the `nak`-specific `nip34/grasp/<host>` remote name.
|
||||
// `nak`-specific `nip34/grasp/<host>` remote name.
|
||||
pub nip34_grasp_remote: bool,
|
||||
pub nip34_state_refs: bool,
|
||||
pub nostr_cache: bool,
|
||||
@@ -41,7 +41,7 @@ impl GraspSignals {
|
||||
pub struct Nip34Binding {
|
||||
pub kind: Nip34Kind,
|
||||
pub signals: GraspSignals,
|
||||
/// Coordinate owner and identifier, from `nip34.json` or `nostr.repo`.
|
||||
// From `nip34.json` or `nostr.repo`.
|
||||
pub owner: Option<PublicKey>,
|
||||
pub identifier: Option<String>,
|
||||
pub grasp_urls: Vec<String>,
|
||||
@@ -53,222 +53,221 @@ struct Nip34Json {
|
||||
owner: Option<String>,
|
||||
}
|
||||
|
||||
pub fn detect_nip34(repo_path: &Path) -> Option<Nip34Binding> {
|
||||
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 {
|
||||
// `None` when no Nostr tooling left a marker.
|
||||
pub fn nip34_binding(&self) -> Option<Nip34Binding> {
|
||||
let common_dir = self.inner.common_dir().to_path_buf();
|
||||
let workdir = self.inner.workdir().map(std::path::Path::to_path_buf);
|
||||
|
||||
let mut signals = GraspSignals::default();
|
||||
let mut owner: Option<PublicKey> = None;
|
||||
let mut identifier: Option<String> = None;
|
||||
let mut grasp_urls: Vec<String> = Vec::new();
|
||||
let mut signals = GraspSignals::default();
|
||||
let mut owner: Option<PublicKey> = None;
|
||||
let mut identifier: Option<String> = None;
|
||||
let mut grasp_urls: Vec<String> = 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::<Nip34Json>(&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::<Nip34Json>(&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;
|
||||
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;
|
||||
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<String>) -> Option<String> {
|
||||
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<String>) -> Option<String> {
|
||||
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 +305,14 @@ mod tests {
|
||||
.expect("naddr")
|
||||
}
|
||||
|
||||
fn binding_of(path: &Path) -> Option<Nip34Binding> {
|
||||
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 +326,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 +338,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 +354,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 +367,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 +380,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 +400,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 +418,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 +432,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 +442,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"
|
||||
)));
|
||||
}
|
||||
|
||||
+304
-278
@@ -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,342 @@ 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`")?;
|
||||
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 {
|
||||
// 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<String> {
|
||||
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);
|
||||
// Backed by `diffy::patch_set`, which implements git's extended diff format:
|
||||
// rename and copy detection, binary detection, quoted/escaped paths.
|
||||
pub fn patch_diffs(patch: &str) -> Result<CommitDiff> {
|
||||
if !patch.lines().any(|line| line.starts_with("diff --git ")) {
|
||||
return Ok(CommitDiff { files: Vec::new() });
|
||||
}
|
||||
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]
|
||||
// Entries appear in patch order, oldest first as `git format-patch`
|
||||
// produces them.
|
||||
pub fn patch_commits(patch: &str) -> Vec<FileCommit> {
|
||||
Self::envelopes(patch)
|
||||
.into_iter()
|
||||
.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()
|
||||
}
|
||||
|
||||
// The one parser `split_patch_series` and `patch_commits` share.
|
||||
// A malformed input yields one message covering the whole input.
|
||||
fn envelopes(patch: &str) -> Vec<Envelope<'_>> {
|
||||
let mut messages: Vec<Envelope<'_>> = Vec::new();
|
||||
let mut current: Option<(usize, &str, Vec<&str>)> = None;
|
||||
let mut headers_closed = false;
|
||||
|
||||
let mut offset = 0usize;
|
||||
for line in patch.lines() {
|
||||
let line_start = offset;
|
||||
offset += line.len() + 1;
|
||||
|
||||
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() {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
// Matches `[PATCH]`, `[PATCH 1/2]`, `[RFC PATCH]`, etc.
|
||||
fn strip_patch_prefix(subject: &str) -> String {
|
||||
let trimmed = subject.trim();
|
||||
let Some(rest) = trimmed.strip_prefix('[') else {
|
||||
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<FileDiff> {
|
||||
// The `---`/`+++` paths carry the `a/`/`b/` prefix, dropped the same way
|
||||
// `git apply -p1` does; rename and copy paths come from their own
|
||||
// headers, unprefixed.
|
||||
let stripped;
|
||||
let operation = match file.operation() {
|
||||
operation @ (FileOperation::Rename { .. } | FileOperation::Copy { .. }) => operation,
|
||||
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<CommitDiff> {
|
||||
if !patch.lines().any(|line| line.starts_with("diff --git ")) {
|
||||
return Ok(CommitDiff { files: Vec::new() });
|
||||
}
|
||||
|
||||
let mut files = Vec::new();
|
||||
// `diffy` reports only the hunk header ranges; the per-line numbers are
|
||||
// counted from them the way the header encodes them.
|
||||
fn hunk_diff(hunk: &Hunk<'_, str>) -> DiffHunk {
|
||||
let old_range = hunk.old_range();
|
||||
let new_range = hunk.new_range();
|
||||
|
||||
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<FileDiff> {
|
||||
// The `---`/`+++` paths carry the `a/`/`b/` prefix, so the first path
|
||||
// component is dropped, the same way `git apply -p1` does.
|
||||
// Rename and copy paths come from their own headers, unprefixed.
|
||||
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,
|
||||
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> <date>` envelope.
|
||||
struct Envelope<'a> {
|
||||
text: &'a str,
|
||||
id: &'a str,
|
||||
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<FileCommit> {
|
||||
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 {
|
||||
// The git CLI handles the mbox format natively.
|
||||
// TODO: replace with a pure-Rust implementation later without changing callers.
|
||||
pub fn apply_patch(&self, patch: &str) -> Result<()> {
|
||||
let workdir = self
|
||||
.inner
|
||||
.workdir()
|
||||
.context("repository has no worktree")?
|
||||
.to_path_buf();
|
||||
|
||||
while i < lines.len() {
|
||||
// A patch starts with its `From <id> <date>` 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;
|
||||
// Fails when the range has no commits. The mbox is returned untrimmed;
|
||||
// trailing newlines are part of the format.
|
||||
pub fn format_patch_between(&self, base: &str, tip: &str) -> Result<String> {
|
||||
let output = Repo::run_git(
|
||||
self.workdir_or_dot(),
|
||||
&["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)
|
||||
}
|
||||
}
|
||||
|
||||
+267
-337
@@ -6,346 +6,276 @@ 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<U: AsRef<str>>(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://<host>/<owner>/<repo>` 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<U: AsRef<str>, F>(urls: &[U], verb: &str, mut attempt: F) -> Result<()>
|
||||
where
|
||||
F: FnMut(&str) -> Result<()>,
|
||||
{
|
||||
let mut last_err: Option<anyhow::Error> = 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<gix::Repository> {
|
||||
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<bool> {
|
||||
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 <url> <name>` 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::<Result<Vec<_>, _>>()
|
||||
.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<String, String> = 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 <url>`.
|
||||
// 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<U: AsRef<str>>(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 {
|
||||
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<Option<String>> {
|
||||
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<std::process::Output> {
|
||||
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}`"))
|
||||
}
|
||||
|
||||
pub fn push_ref(&self, url: &str, commit: &str, reference: &str) -> Result<()> {
|
||||
let output = Self::run_git(
|
||||
self.workdir_or_dot(),
|
||||
&["push", url, &format!("{commit}:{reference}")],
|
||||
"git push",
|
||||
)?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"git push failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn push_main(&self, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
|
||||
self.push_refspecs(
|
||||
base_url,
|
||||
owner,
|
||||
repo_id,
|
||||
&["refs/heads/main:refs/heads/main"],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn push_all(&self, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
|
||||
self.push_refspecs(
|
||||
base_url,
|
||||
owner,
|
||||
repo_id,
|
||||
&["refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"],
|
||||
)
|
||||
}
|
||||
|
||||
fn push_refspecs(
|
||||
&self,
|
||||
base_url: &str,
|
||||
owner: &str,
|
||||
repo_id: &str,
|
||||
refspecs: &[&str],
|
||||
) -> Result<()> {
|
||||
let url = format!("{base_url}/{owner}/{repo_id}.git");
|
||||
|
||||
let mut args: Vec<&str> = Vec::with_capacity(refspecs.len() + 2);
|
||||
args.push("push");
|
||||
args.push(&url);
|
||||
args.extend_from_slice(refspecs);
|
||||
|
||||
let output = Self::run_git(self.workdir_or_dot(), &args, "git push")?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"git push to {base_url} failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// The convergence probe for a push that lost the compare-and-swap race to
|
||||
// the grasp server's own background ref alignment. Extra advertised refs
|
||||
// are ignored: the question is whether the pushed data is already there,
|
||||
// not whether the remote is an exact mirror.
|
||||
pub fn remote_has_refs(&self, url: &str, expected: &[(String, String)]) -> Result<bool> {
|
||||
if expected.is_empty() {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let url = Self::transport_url(url);
|
||||
|
||||
// A URL-created remote has no configured fetch refspecs, and `ref_map`
|
||||
// only keeps refs matching one; match each expected ref by exact name.
|
||||
let refspecs = expected
|
||||
.iter()
|
||||
.map(|(name, _)| {
|
||||
gix::refspec::parse(
|
||||
gix::bstr::BStr::new(format!("+{name}:{name}").as_bytes()),
|
||||
gix::refspec::parse::Operation::Fetch,
|
||||
)
|
||||
.map(|spec| spec.to_owned())
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.context("invalid refspec")?;
|
||||
|
||||
let options = gix::remote::ref_map::Options {
|
||||
extra_refspecs: refspecs,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (refs, _) = self
|
||||
.inner
|
||||
.remote_at(url.as_str())
|
||||
.with_context(|| format!("cannot use remote {url}"))?
|
||||
.connect(gix::remote::Direction::Fetch)
|
||||
.with_context(|| format!("cannot connect to {url}"))?
|
||||
.ref_map(Discard, options)
|
||||
.with_context(|| format!("listing refs of {url} failed"))?;
|
||||
|
||||
// Peeled tag entries carry the tag object in their direct oid, matching
|
||||
// `git ls-remote` while skipping the duplicated `^{}` lines.
|
||||
let advertised: HashMap<String, String> = refs
|
||||
.remote_refs
|
||||
.iter()
|
||||
.filter_map(|reference| {
|
||||
let (name, object, _peeled) = reference.unpack();
|
||||
object.map(|oid| (String::from_utf8_lossy(name).into_owned(), oid.to_string()))
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(expected
|
||||
.iter()
|
||||
.all(|(name, oid)| advertised.get(name.as_str()) == Some(oid)))
|
||||
}
|
||||
|
||||
pub fn ensure_origin(&self, url: &str) -> Result<()> {
|
||||
if self.inner.find_remote("origin").is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// `git remote add` also configures the default fetch refspec.
|
||||
self.edit_local_config(|config| {
|
||||
config.set_raw_value("remote.origin.url", url)?;
|
||||
config.set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
// A working copy cloned from a local mirror is re-targeted at the
|
||||
// grasp server; a pre-existing fetch refspec is left untouched.
|
||||
pub fn set_origin(&self, url: &str) -> Result<()> {
|
||||
let had_origin = self.inner.find_remote("origin").is_ok();
|
||||
|
||||
self.edit_local_config(|config| {
|
||||
config.set_raw_value("remote.origin.url", url)?;
|
||||
|
||||
if !had_origin {
|
||||
config
|
||||
.set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn origin_url(&self) -> Result<Option<String>> {
|
||||
let Ok(remote) = self.inner.find_remote("origin") else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(remote
|
||||
.url(gix::remote::Direction::Fetch)
|
||||
.map(|url| url.to_string()))
|
||||
}
|
||||
|
||||
// Never touches the checked-out refs or the worktree. The last error is
|
||||
// returned when no URL works.
|
||||
pub fn fetch_refs<U: AsRef<str>>(&self, urls: &[U], refspec: &str) -> Result<()> {
|
||||
let refspec = gix::refspec::parse(
|
||||
gix::bstr::BStr::new(refspec),
|
||||
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"),
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
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("."))
|
||||
}
|
||||
|
||||
pub(crate) fn run_git(dir: &Path, args: &[&str], what: &str) -> Result<std::process::Output> {
|
||||
Self::git_command(dir)
|
||||
.args(args)
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.with_context(|| format!("failed to spawn `{what}`"))
|
||||
}
|
||||
|
||||
pub(crate) fn git_command(dir: &Path) -> Command {
|
||||
let mut command = Command::new("git");
|
||||
command.arg("-C").arg(dir).env("GIT_TERMINAL_PROMPT", "0");
|
||||
command
|
||||
}
|
||||
}
|
||||
|
||||
+427
-426
@@ -2,342 +2,397 @@ use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::history::open_with_cache;
|
||||
use crate::worktree::{force_checkout, worktree_dirty};
|
||||
// History walks re-decode the same commit objects without one; sized
|
||||
// generously, a walk can cover a large portion of the history.
|
||||
const OBJECT_CACHE_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
/// The merge base of two revisions in the repository at `repo_path`,
|
||||
/// revisions may be branch names, remote-tracking refs or commit ids.
|
||||
///
|
||||
/// `Ok(None)` when the revisions share no common ancestor.
|
||||
///
|
||||
/// Unresolvable revisions are errors.
|
||||
pub fn merge_base(repo_path: &Path, a: &str, b: &str) -> Result<Option<String>> {
|
||||
let 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<Option<String>> {
|
||||
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<Vec<String>> {
|
||||
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<String> {
|
||||
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::<gix::ObjectId>::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<Option<String>> {
|
||||
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/<owner>/<id>`.
|
||||
///
|
||||
/// Returns an empty list when nothing matches.
|
||||
pub fn refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<Vec<String>> {
|
||||
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/<owner>/<id>`.
|
||||
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<RefEdit> = 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 {
|
||||
pub fn open(workdir: &Path) -> Result<Self> {
|
||||
Ok(Self {
|
||||
inner: gix::open(workdir)?,
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
}
|
||||
|
||||
repo.edit_references(edits)?;
|
||||
pub fn try_open(workdir: &Path) -> Option<Self> {
|
||||
Self::open(workdir).ok()
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
// Only history walks benefit from the object cache, they re-decode the
|
||||
// same commit objects repeatedly; single-object reads open plain.
|
||||
pub fn open_cached(workdir: &Path) -> Result<Self> {
|
||||
let mut repo = gix::open(workdir)?;
|
||||
repo.object_cache_size_if_unset(OBJECT_CACHE_BYTES);
|
||||
Ok(Self { inner: repo })
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
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())
|
||||
}
|
||||
pub fn init(path: &Path, name: &str, description: &str) -> Result<String> {
|
||||
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<bool> {
|
||||
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`:
|
||||
// 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::<gix::ObjectId>::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())
|
||||
}
|
||||
|
||||
// Not kept in any cache, unlike `GitCache::ensure_clone`.
|
||||
pub fn clone<U: AsRef<str>>(clone_urls: &[U], path: &Path) -> Result<Self> {
|
||||
if path.exists() {
|
||||
anyhow::bail!("destination {} already exists", path.display());
|
||||
}
|
||||
|
||||
let mut last_error = None;
|
||||
|
||||
for url in clone_urls {
|
||||
match Self::clone_from(url.as_ref(), path) {
|
||||
Ok(repo) => {
|
||||
// The default-refspec clone misses the `refs/nostr/*` PR refs.
|
||||
repo.fetch().ok();
|
||||
return Ok(repo);
|
||||
}
|
||||
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<Self> {
|
||||
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
|
||||
}
|
||||
|
||||
pub fn workdir(&self) -> Option<&Path> {
|
||||
self.inner.workdir()
|
||||
}
|
||||
|
||||
// `None` for an unborn HEAD.
|
||||
pub fn head(&self) -> Option<String> {
|
||||
self.inner.head_id().ok().map(|id| id.to_string())
|
||||
}
|
||||
|
||||
// `Ok(None)` when the revisions share no common ancestor — a valid
|
||||
// outcome for a proposal; unresolvable revisions are errors.
|
||||
pub fn merge_base(&self, a: &str, b: &str) -> Result<Option<String>> {
|
||||
let a = self.inner.rev_parse_single(a.as_bytes())?;
|
||||
let b = self.inner.rev_parse_single(b.as_bytes())?;
|
||||
match self.inner.merge_base(a, b) {
|
||||
Ok(id) => Ok(Some(id.to_string())),
|
||||
Err(gix::repository::merge_base::Error::NotFound { .. }) => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
// Oldest first: the order `git am` creates them.
|
||||
pub fn commits_since(&self, base: Option<&str>) -> Result<Vec<String>> {
|
||||
let head = match self.inner.head_id() {
|
||||
Ok(head) => head,
|
||||
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)
|
||||
}
|
||||
|
||||
// The NIP-34 announcement's `euc` marker; `None` without commits.
|
||||
pub fn root_commit(&self) -> Result<Option<String>> {
|
||||
let Ok(head) = self.inner.head_id() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
pub fn refs_with_prefix(&self, prefix: &str) -> Result<Vec<String>> {
|
||||
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.
|
||||
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)
|
||||
}
|
||||
|
||||
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<RefEdit> = 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::<Result<Vec<_>>>()?;
|
||||
|
||||
self.inner.edit_references(edits)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn current_branch(&self) -> Option<String> {
|
||||
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()
|
||||
}
|
||||
|
||||
pub fn branches(&self) -> Result<Vec<String>> {
|
||||
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)
|
||||
}
|
||||
|
||||
pub fn tags(&self) -> Result<Vec<String>> {
|
||||
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)
|
||||
}
|
||||
|
||||
pub fn ref_state(&self) -> Result<RepoRefState> {
|
||||
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 only: local-only commits or diverged history must never
|
||||
// be rewritten by a refresh. Returns whether any branch moved.
|
||||
pub fn fast_forward_branches(&self) -> Result<bool> {
|
||||
use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Only fast-forward: the base must be the local tip.
|
||||
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,119 +406,65 @@ pub fn fast_forward_branches(workdir: &Path) -> Result<bool> {
|
||||
},
|
||||
name: full.clone(),
|
||||
deref: false,
|
||||
};
|
||||
|
||||
if current.as_deref() == Some(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)
|
||||
}
|
||||
|
||||
// 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<Vec<String>> {
|
||||
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());
|
||||
// GRASP servers announce `grasp://` clone URLs but the transport is git
|
||||
// smart HTTP, so the scheme is rewritten for gix.
|
||||
pub(crate) fn transport_url(url: &str) -> String {
|
||||
url.strip_prefix("grasp://")
|
||||
.map(|rest| format!("https://{rest}"))
|
||||
.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<Vec<String>> {
|
||||
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<Vec<String>> {
|
||||
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<Option<String>> {
|
||||
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.
|
||||
pub refs: Vec<(String, String)>,
|
||||
/// Short branch name HEAD points to, or `None` when detached.
|
||||
pub head: Option<String>,
|
||||
}
|
||||
|
||||
pub fn repo_ref_state(repo: &gix::Repository) -> Result<RepoRefState> {
|
||||
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<RepoRefState> {
|
||||
repo_ref_state(&gix::open(workdir)?)
|
||||
}
|
||||
|
||||
@@ -2,20 +2,18 @@ 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.
|
||||
// Caps nesting so pathological trees can't stall the scan.
|
||||
const SCAN_MAX_DEPTH: usize = 12;
|
||||
|
||||
/// A git repository discovered under a scan root.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocalRepo {
|
||||
pub path: PathBuf,
|
||||
/// `None` for a plain repository.
|
||||
pub nip34: Option<Nip34Binding>,
|
||||
}
|
||||
|
||||
/// Walk `root` recursively and collect the git repositories below it.
|
||||
pub fn find_git_repos(root: &Path) -> Vec<LocalRepo> {
|
||||
if !root.is_dir() {
|
||||
return Vec::new();
|
||||
@@ -49,7 +47,7 @@ pub fn find_git_repos(root: &Path) -> Vec<LocalRepo> {
|
||||
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()
|
||||
|
||||
+143
-657
File diff suppressed because it is too large
Load Diff
+287
-307
@@ -4,331 +4,311 @@ 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::<gix::bstr::BString>::new())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
for change in changes.by_ref() {
|
||||
match change {
|
||||
Ok(gix::status::index_worktree::Item::DirectoryContents { .. }) => return true,
|
||||
Ok(_) => {}
|
||||
impl Repo {
|
||||
// Best-effort: any read failure is reported as clean.
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
// Tracked files, staged or not; untracked files are handled below.
|
||||
match self.inner.is_dirty() {
|
||||
Ok(true) => return true,
|
||||
Ok(false) => {}
|
||||
Err(_) => return false,
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
/// 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(mut changes) = platform.into_index_worktree_iter(Vec::<gix::bstr::BString>::new())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
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<gix::Id<'a>> {
|
||||
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<Vec<PathBuf>> {
|
||||
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<Option<Vec<u8>>> {
|
||||
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())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<Option<PathBuf>> {
|
||||
let Some(workdir) = repo.workdir() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let mut candidates: Vec<PathBuf> = 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 struct WorktreeSnapshot {
|
||||
/// Relative paths of all worktree entries, directories first.
|
||||
pub entries: Vec<PathBuf>,
|
||||
/// README path relative to the worktree, if any.
|
||||
pub readme_path: Option<PathBuf>,
|
||||
/// Contents of the README, if any.
|
||||
pub readme: Option<Vec<u8>>,
|
||||
/// Branch HEAD points to, `None` when detached, for example on a tag.
|
||||
pub current_branch: Option<String>,
|
||||
/// Commit HEAD points to, if any, see [`head_commit`].
|
||||
pub head_commit: Option<FileCommit>,
|
||||
}
|
||||
|
||||
/// Collects entries, the README, the branch HEAD points to and its commit.
|
||||
pub fn worktree_snapshot(workdir: &Path) -> Result<WorktreeSnapshot> {
|
||||
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<PathBuf> = 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;
|
||||
for change in changes.by_ref() {
|
||||
match change {
|
||||
Ok(gix::status::index_worktree::Item::DirectoryContents { .. }) => return true,
|
||||
Ok(_) => {}
|
||||
Err(_) => return false,
|
||||
}
|
||||
}
|
||||
|
||||
let path = workdir.join(&rel);
|
||||
false
|
||||
}
|
||||
|
||||
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()));
|
||||
// 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;
|
||||
};
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Accepts full refs or the bare branch names callers pass; `gix`'s
|
||||
// revision parser already applies git's ref DWIM.
|
||||
fn resolve_commit<'a>(&'a self, rev: &str) -> Option<gix::Id<'a>> {
|
||||
self.inner.rev_parse_single(rev.as_bytes()).ok()
|
||||
}
|
||||
|
||||
// The `.git` directory is skipped.
|
||||
pub fn entries(&self) -> Result<Vec<PathBuf>> {
|
||||
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())
|
||||
}
|
||||
|
||||
// `Ok(None)` when the path is missing or not a regular file.
|
||||
pub fn read(&self, rel: &Path) -> Result<Option<Vec<u8>>> {
|
||||
let workdir = self.inner.workdir().context("repository has no worktree")?;
|
||||
let path = workdir.join(rel);
|
||||
|
||||
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())),
|
||||
}
|
||||
}
|
||||
|
||||
// Falls back to any other file whose name starts with `readme`.
|
||||
pub fn find_readme(&self) -> Result<Option<PathBuf>> {
|
||||
let Some(workdir) = self.inner.workdir() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let mut candidates: Vec<PathBuf> = Vec::new();
|
||||
for entry in std::fs::read_dir(workdir)? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name();
|
||||
let Some(name) = name.to_str() else { continue };
|
||||
if name.to_ascii_lowercase().starts_with("readme") {
|
||||
candidates.push(entry.path());
|
||||
}
|
||||
}
|
||||
|
||||
candidates.sort_by_key(|path| {
|
||||
let ext = path
|
||||
.extension()
|
||||
.map(|e| e.to_string_lossy().to_ascii_lowercase());
|
||||
match ext.as_deref() {
|
||||
Some("md") => 0,
|
||||
Some("markdown") => 1,
|
||||
Some("mdown") => 2,
|
||||
Some("mkdn") => 3,
|
||||
Some(_) => 5,
|
||||
None => 4,
|
||||
}
|
||||
});
|
||||
|
||||
Ok(candidates
|
||||
.into_iter()
|
||||
.next()
|
||||
.and_then(|path| path.strip_prefix(workdir).ok().map(Path::to_path_buf)))
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> Result<WorktreeSnapshot> {
|
||||
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(),
|
||||
})
|
||||
}
|
||||
|
||||
// HEAD stays attached to the branch.
|
||||
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(())
|
||||
}
|
||||
|
||||
// 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<PathBuf> = 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(())
|
||||
}
|
||||
|
||||
let mut options =
|
||||
repo.checkout_options(gix_worktree::stack::state::attributes::Source::IdMapping)?;
|
||||
options.overwrite_existing = true;
|
||||
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 objects = repo.objects.clone().into_arc()?;
|
||||
let files = gix::progress::Discard;
|
||||
let bytes = gix::progress::Discard;
|
||||
let head = gix::refs::FullName::try_from("HEAD")
|
||||
.map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?;
|
||||
|
||||
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(),
|
||||
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,
|
||||
},
|
||||
expected: PreviousValue::Any,
|
||||
new: target,
|
||||
},
|
||||
name: head,
|
||||
deref: false,
|
||||
}],
|
||||
Some(signature),
|
||||
)?;
|
||||
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(())
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct WorktreeSnapshot {
|
||||
pub entries: Vec<PathBuf>,
|
||||
pub readme_path: Option<PathBuf>,
|
||||
pub readme: Option<Vec<u8>>,
|
||||
// `None` when detached, for example on a tag.
|
||||
pub current_branch: Option<String>,
|
||||
pub head_commit: Option<FileCommit>,
|
||||
pub branches: Vec<String>,
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user