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