chore: refactor git module #27
Generated
+1
@@ -8143,6 +8143,7 @@ dependencies = [
|
|||||||
"gix-worktree",
|
"gix-worktree",
|
||||||
"gix-worktree-state",
|
"gix-worktree-state",
|
||||||
"ignore",
|
"ignore",
|
||||||
|
"log",
|
||||||
"nostr",
|
"nostr",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ signed_core = { path = "../signed_core" }
|
|||||||
nostr.workspace = true
|
nostr.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
log.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
gix = { workspace = true, features = ["revision", "blob-diff"] }
|
gix = { workspace = true, features = ["revision", "blob-diff"] }
|
||||||
gix-worktree = "0.57"
|
gix-worktree = "0.57"
|
||||||
gix-worktree-state = "0.35"
|
gix-worktree-state = "0.35"
|
||||||
anyhow.workspace = true
|
|
||||||
diffy = "0.5"
|
diffy = "0.5"
|
||||||
ignore = "0.4"
|
ignore = "0.4"
|
||||||
|
|
||||||
|
|||||||
@@ -11,35 +11,41 @@ pub struct GitCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl GitCache {
|
impl GitCache {
|
||||||
|
/// Creates a cache rooted at `root`.
|
||||||
pub fn new(root: PathBuf) -> Self {
|
pub fn new(root: PathBuf) -> Self {
|
||||||
Self { root }
|
Self { root }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the cache root directory.
|
||||||
pub fn root(&self) -> &Path {
|
pub fn root(&self) -> &Path {
|
||||||
&self.root
|
&self.root
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the cache path for a repository address.
|
||||||
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(Self::sanitize_path_component(addr.identifier()))
|
.join(Self::sanitize_path_component(addr.identifier()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Opens the cached repository, or `None` when it is not cloned yet.
|
||||||
pub fn open(&self, addr: &RepoAddr) -> Result<Option<Repo>> {
|
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 { inner: repo })),
|
Ok(repo) => Ok(Some(Repo::new(repo))),
|
||||||
// A missing path and a directory that is no repository are both classified `NotFound`.
|
|
||||||
Err(e) if e.is_not_found() => Ok(None),
|
Err(e) if e.is_not_found() => Ok(None),
|
||||||
Err(e) => Err(e.into()),
|
Err(e) => Err(e.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the cached repository, cloning from one of `clone_urls` on first use.
|
||||||
pub fn ensure_clone<U: AsRef<str>>(&self, addr: &RepoAddr, clone_urls: &[U]) -> Result<Repo> {
|
pub fn ensure_clone<U: AsRef<str>>(&self, addr: &RepoAddr, clone_urls: &[U]) -> Result<Repo> {
|
||||||
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)? {
|
||||||
repo.fetch().ok();
|
if let Err(error) = repo.fetch() {
|
||||||
|
log::warn!("failed to refresh the cached repository: {error:#}");
|
||||||
|
}
|
||||||
return Ok(repo);
|
return Ok(repo);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,8 +57,7 @@ impl GitCache {
|
|||||||
Repo::clone(clone_urls, &path)
|
Repo::clone(clone_urls, &path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// `id` is untrusted relay content: it must never escape the cache root as
|
/// Maps untrusted relay content onto a safe single path component.
|
||||||
// a single path component.
|
|
||||||
pub fn sanitize_path_component(id: &str) -> String {
|
pub fn sanitize_path_component(id: &str) -> String {
|
||||||
let sanitized: String = id
|
let sanitized: String = id
|
||||||
.chars()
|
.chars()
|
||||||
@@ -72,6 +77,7 @@ impl GitCache {
|
|||||||
sanitized
|
sanitized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the `owner/identifier` namespace used for fork checkouts.
|
||||||
pub fn fork_namespace(announcement: &Announcement) -> String {
|
pub fn fork_namespace(announcement: &Announcement) -> String {
|
||||||
format!(
|
format!(
|
||||||
"{}/{}",
|
"{}/{}",
|
||||||
|
|||||||
+159
-202
@@ -1,9 +1,5 @@
|
|||||||
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::GixResultExt as _;
|
|
||||||
use crate::repo::Repo;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum DiffLineKind {
|
pub enum DiffLineKind {
|
||||||
Context,
|
Context,
|
||||||
@@ -19,6 +15,18 @@ pub struct DiffLine {
|
|||||||
pub text: String,
|
pub text: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl DiffLine {
|
||||||
|
/// Creates a diff line with its kind, optional line numbers, and text.
|
||||||
|
pub fn new(kind: DiffLineKind, old: Option<u32>, new: Option<u32>, text: String) -> Self {
|
||||||
|
Self {
|
||||||
|
kind,
|
||||||
|
old,
|
||||||
|
new,
|
||||||
|
text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct DiffHunk {
|
pub struct DiffHunk {
|
||||||
pub old_start: u32,
|
pub old_start: u32,
|
||||||
@@ -28,6 +36,25 @@ pub struct DiffHunk {
|
|||||||
pub lines: Vec<DiffLine>,
|
pub lines: Vec<DiffLine>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl DiffHunk {
|
||||||
|
/// Creates a hunk spanning the given old and new ranges.
|
||||||
|
pub fn new(
|
||||||
|
old_start: u32,
|
||||||
|
old_lines: u32,
|
||||||
|
new_start: u32,
|
||||||
|
new_lines: u32,
|
||||||
|
lines: Vec<DiffLine>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
old_start,
|
||||||
|
old_lines,
|
||||||
|
new_start,
|
||||||
|
new_lines,
|
||||||
|
lines,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum DiffStatus {
|
pub enum DiffStatus {
|
||||||
Added,
|
Added,
|
||||||
@@ -39,242 +66,172 @@ pub enum DiffStatus {
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct FileDiff {
|
pub struct FileDiff {
|
||||||
// For renames and copies, the destination path.
|
|
||||||
pub path: String,
|
pub path: String,
|
||||||
pub old_path: Option<String>,
|
pub old_path: Option<String>,
|
||||||
pub status: DiffStatus,
|
pub status: DiffStatus,
|
||||||
pub insertions: usize,
|
pub insertions: usize,
|
||||||
pub deletions: usize,
|
pub deletions: usize,
|
||||||
// Binary files have empty `hunks`.
|
|
||||||
pub binary: bool,
|
pub binary: bool,
|
||||||
pub hunks: Vec<DiffHunk>,
|
pub hunks: Vec<DiffHunk>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl FileDiff {
|
||||||
|
/// Creates a file diff; `old_path` is the rename or copy source and `binary` files have no hunks.
|
||||||
|
pub fn new(
|
||||||
|
path: String,
|
||||||
|
old_path: Option<String>,
|
||||||
|
status: DiffStatus,
|
||||||
|
insertions: usize,
|
||||||
|
deletions: usize,
|
||||||
|
binary: bool,
|
||||||
|
hunks: Vec<DiffHunk>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
path,
|
||||||
|
old_path,
|
||||||
|
status,
|
||||||
|
insertions,
|
||||||
|
deletions,
|
||||||
|
binary,
|
||||||
|
hunks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct CommitDiff {
|
pub struct CommitDiff {
|
||||||
pub files: Vec<FileDiff>,
|
pub files: Vec<FileDiff>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Repo {
|
impl CommitDiff {
|
||||||
// Compared against the first parent; the empty tree for the root commit.
|
/// Creates a commit diff from its files.
|
||||||
pub fn commit_diff(&self, id: &str) -> Result<CommitDiff> {
|
pub fn new(files: Vec<FileDiff>) -> Self {
|
||||||
let commit_id = self.inner.rev_parse_single(id.as_bytes())?;
|
Self { files }
|
||||||
let commit = commit_id.object()?.into_commit();
|
|
||||||
let new_tree = commit.tree()?;
|
|
||||||
let old_tree = match commit.parent_ids().next() {
|
|
||||||
Some(parent) => Some(parent.object()?.into_commit().tree()?),
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
Self::tree_diff(self, old_tree.as_ref(), &new_tree)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Directories and submodules are skipped, files are sorted by path.
|
|
||||||
pub fn range_diff(&self, base: &str, tip: &str) -> Result<CommitDiff> {
|
|
||||||
let base_tree = self
|
|
||||||
.inner
|
|
||||||
.rev_parse_single(base.as_bytes())?
|
|
||||||
.object()?
|
|
||||||
.into_commit()
|
|
||||||
.tree()?;
|
|
||||||
let tip_tree = self
|
|
||||||
.inner
|
|
||||||
.rev_parse_single(tip.as_bytes())?
|
|
||||||
.object()?
|
|
||||||
.into_commit()
|
|
||||||
.tree()?;
|
|
||||||
Self::tree_diff(self, Some(&base_tree), &tip_tree)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tree_diff(
|
|
||||||
repo: &Repo,
|
|
||||||
old_tree: Option<&gix::Tree<'_>>,
|
|
||||||
new_tree: &gix::Tree<'_>,
|
|
||||||
) -> Result<CommitDiff> {
|
|
||||||
use gix::diff::blob::platform::prepare_diff::Operation;
|
|
||||||
use gix::object::tree::diff::Change;
|
|
||||||
use gix::objs::tree::EntryKind;
|
|
||||||
|
|
||||||
let changes = repo
|
|
||||||
.inner
|
|
||||||
.diff_tree_to_tree(old_tree, Some(new_tree), None)?;
|
|
||||||
|
|
||||||
let mut cache = repo.inner.diff_resource_cache_for_tree_diff()?;
|
|
||||||
let mut files = Vec::new();
|
|
||||||
|
|
||||||
for change in changes {
|
|
||||||
let attached = Change::from_change_ref(change.to_ref(), &repo.inner, &repo.inner);
|
|
||||||
|
|
||||||
let (path, old_path, status) = match attached {
|
|
||||||
Change::Addition {
|
|
||||||
location,
|
|
||||||
entry_mode,
|
|
||||||
..
|
|
||||||
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => {
|
|
||||||
(location.to_owned(), None, DiffStatus::Added)
|
|
||||||
}
|
|
||||||
Change::Deletion {
|
|
||||||
location,
|
|
||||||
entry_mode,
|
|
||||||
..
|
|
||||||
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => {
|
|
||||||
(location.to_owned(), None, DiffStatus::Deleted)
|
|
||||||
}
|
|
||||||
Change::Modification {
|
|
||||||
location,
|
|
||||||
previous_entry_mode,
|
|
||||||
entry_mode,
|
|
||||||
..
|
|
||||||
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit)
|
|
||||||
&& !matches!(
|
|
||||||
previous_entry_mode.kind(),
|
|
||||||
EntryKind::Tree | EntryKind::Commit
|
|
||||||
) =>
|
|
||||||
{
|
|
||||||
(location.to_owned(), None, DiffStatus::Modified)
|
|
||||||
}
|
|
||||||
Change::Rewrite {
|
|
||||||
location,
|
|
||||||
source_location,
|
|
||||||
source_entry_mode,
|
|
||||||
entry_mode,
|
|
||||||
copy,
|
|
||||||
..
|
|
||||||
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit)
|
|
||||||
&& !matches!(
|
|
||||||
source_entry_mode.kind(),
|
|
||||||
EntryKind::Tree | EntryKind::Commit
|
|
||||||
) =>
|
|
||||||
{
|
|
||||||
let status = if copy {
|
|
||||||
DiffStatus::Copied
|
|
||||||
} else {
|
|
||||||
DiffStatus::Renamed
|
|
||||||
};
|
|
||||||
(
|
|
||||||
location.to_owned(),
|
|
||||||
Some(source_location.to_owned()),
|
|
||||||
status,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
_ => continue,
|
|
||||||
};
|
|
||||||
|
|
||||||
// External diff drivers would shell out, out of scope for a read-only viewer.
|
|
||||||
let platform = attached.diff(&mut cache)?;
|
|
||||||
platform
|
|
||||||
.resource_cache
|
|
||||||
.options
|
|
||||||
.skip_internal_diff_if_external_is_configured = true;
|
|
||||||
let outcome = platform.resource_cache.prepare_diff().into_anyhow()?;
|
|
||||||
|
|
||||||
let (binary, hunks, insertions, deletions) = match outcome.operation {
|
|
||||||
Operation::InternalDiff { algorithm } => {
|
|
||||||
let input = outcome.interned_input();
|
|
||||||
let diff = gix::diff::blob::diff_with_slider_heuristics(algorithm, &input);
|
|
||||||
|
|
||||||
let mut hunks = Vec::new();
|
|
||||||
let mut insertions = 0usize;
|
|
||||||
let mut deletions = 0usize;
|
|
||||||
let collector = HunkCollector {
|
|
||||||
hunks: &mut hunks,
|
|
||||||
insertions: &mut insertions,
|
|
||||||
deletions: &mut deletions,
|
|
||||||
};
|
|
||||||
gix::diff::blob::UnifiedDiff::new(&diff, &input, collector, Default::default())
|
|
||||||
.consume()?;
|
|
||||||
(false, hunks, insertions, deletions)
|
|
||||||
}
|
|
||||||
Operation::SourceOrDestinationIsBinary => (true, Vec::new(), 0, 0),
|
|
||||||
Operation::ExternalCommand { .. } => {
|
|
||||||
unreachable!("external diff drivers are disabled")
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
files.push(FileDiff {
|
|
||||||
path: String::from_utf8_lossy(&path).into_owned(),
|
|
||||||
old_path: old_path.map(|p| String::from_utf8_lossy(&p).into_owned()),
|
|
||||||
status,
|
|
||||||
insertions,
|
|
||||||
deletions,
|
|
||||||
binary,
|
|
||||||
hunks,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
files.sort_by(|a, b| a.path.cmp(&b.path));
|
|
||||||
|
|
||||||
Ok(CommitDiff { files })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct HunkCollector<'a> {
|
pub(crate) struct HunkBuilder {
|
||||||
|
old_start: u32,
|
||||||
|
old_lines: u32,
|
||||||
|
new_start: u32,
|
||||||
|
new_lines: u32,
|
||||||
|
old: u32,
|
||||||
|
new: u32,
|
||||||
|
lines: Vec<DiffLine>,
|
||||||
|
insertions: usize,
|
||||||
|
deletions: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HunkBuilder {
|
||||||
|
/// Starts a hunk spanning the given old and new ranges.
|
||||||
|
pub(crate) fn new(old_start: u32, old_lines: u32, new_start: u32, new_lines: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
old_start,
|
||||||
|
old_lines,
|
||||||
|
new_start,
|
||||||
|
new_lines,
|
||||||
|
old: old_start,
|
||||||
|
new: new_start,
|
||||||
|
lines: Vec::new(),
|
||||||
|
insertions: 0,
|
||||||
|
deletions: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appends a line, numbering it and counting insertions and deletions.
|
||||||
|
pub(crate) fn push(&mut self, kind: DiffLineKind, text: impl Into<String>) {
|
||||||
|
let (old_no, new_no) = match kind {
|
||||||
|
DiffLineKind::Context => {
|
||||||
|
let numbers = (Some(self.old), Some(self.new));
|
||||||
|
self.old += 1;
|
||||||
|
self.new += 1;
|
||||||
|
numbers
|
||||||
|
}
|
||||||
|
DiffLineKind::Addition => {
|
||||||
|
self.insertions += 1;
|
||||||
|
let number = Some(self.new);
|
||||||
|
self.new += 1;
|
||||||
|
(None, number)
|
||||||
|
}
|
||||||
|
DiffLineKind::Deletion => {
|
||||||
|
self.deletions += 1;
|
||||||
|
let number = Some(self.old);
|
||||||
|
self.old += 1;
|
||||||
|
(number, None)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
self.lines
|
||||||
|
.push(DiffLine::new(kind, old_no, new_no, text.into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finishes the hunk and returns it with its insertion and deletion counts.
|
||||||
|
pub(crate) fn finish(self) -> (DiffHunk, usize, usize) {
|
||||||
|
let hunk = DiffHunk::new(
|
||||||
|
self.old_start,
|
||||||
|
self.old_lines,
|
||||||
|
self.new_start,
|
||||||
|
self.new_lines,
|
||||||
|
self.lines,
|
||||||
|
);
|
||||||
|
(hunk, self.insertions, self.deletions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct HunkCollector<'a> {
|
||||||
hunks: &'a mut Vec<DiffHunk>,
|
hunks: &'a mut Vec<DiffHunk>,
|
||||||
insertions: &'a mut usize,
|
insertions: &'a mut usize,
|
||||||
deletions: &'a mut usize,
|
deletions: &'a mut usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<'a> HunkCollector<'a> {
|
||||||
|
/// Collects hunks into `hunks` while counting `insertions` and `deletions`.
|
||||||
|
pub(crate) fn new(
|
||||||
|
hunks: &'a mut Vec<DiffHunk>,
|
||||||
|
insertions: &'a mut usize,
|
||||||
|
deletions: &'a mut usize,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
hunks,
|
||||||
|
insertions,
|
||||||
|
deletions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl ConsumeHunk for HunkCollector<'_> {
|
impl ConsumeHunk for HunkCollector<'_> {
|
||||||
type Out = ();
|
type Out = ();
|
||||||
|
|
||||||
|
/// Consumes one hunk, tracking line numbers and insertion/deletion counts.
|
||||||
fn consume_hunk(
|
fn consume_hunk(
|
||||||
&mut self,
|
&mut self,
|
||||||
header: HunkHeader,
|
header: HunkHeader,
|
||||||
lines: &[(GixLineKind, &[u8])],
|
lines: &[(GixLineKind, &[u8])],
|
||||||
) -> std::io::Result<()> {
|
) -> std::io::Result<()> {
|
||||||
let mut old_ln = header.before_hunk_start;
|
let mut builder = HunkBuilder::new(
|
||||||
let mut new_ln = header.after_hunk_start;
|
header.before_hunk_start,
|
||||||
let mut out = Vec::with_capacity(lines.len());
|
header.before_hunk_len,
|
||||||
|
header.after_hunk_start,
|
||||||
|
header.after_hunk_len,
|
||||||
|
);
|
||||||
|
|
||||||
for (kind, content) in lines {
|
for (kind, content) in lines {
|
||||||
let text = String::from_utf8_lossy(content).into_owned();
|
let kind = match kind {
|
||||||
let line = match kind {
|
GixLineKind::Context => DiffLineKind::Context,
|
||||||
GixLineKind::Context => {
|
GixLineKind::Remove => DiffLineKind::Deletion,
|
||||||
let line = DiffLine {
|
GixLineKind::Add => DiffLineKind::Addition,
|
||||||
kind: DiffLineKind::Context,
|
|
||||||
old: Some(old_ln),
|
|
||||||
new: Some(new_ln),
|
|
||||||
text,
|
|
||||||
};
|
|
||||||
old_ln += 1;
|
|
||||||
new_ln += 1;
|
|
||||||
line
|
|
||||||
}
|
|
||||||
GixLineKind::Remove => {
|
|
||||||
*self.deletions += 1;
|
|
||||||
let line = DiffLine {
|
|
||||||
kind: DiffLineKind::Deletion,
|
|
||||||
old: Some(old_ln),
|
|
||||||
new: None,
|
|
||||||
text,
|
|
||||||
};
|
|
||||||
old_ln += 1;
|
|
||||||
line
|
|
||||||
}
|
|
||||||
GixLineKind::Add => {
|
|
||||||
*self.insertions += 1;
|
|
||||||
let line = DiffLine {
|
|
||||||
kind: DiffLineKind::Addition,
|
|
||||||
old: None,
|
|
||||||
new: Some(new_ln),
|
|
||||||
text,
|
|
||||||
};
|
|
||||||
new_ln += 1;
|
|
||||||
line
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
out.push(line);
|
builder.push(kind, String::from_utf8_lossy(content).into_owned());
|
||||||
}
|
}
|
||||||
|
|
||||||
self.hunks.push(DiffHunk {
|
let (hunk, insertions, deletions) = builder.finish();
|
||||||
old_start: header.before_hunk_start,
|
*self.insertions += insertions;
|
||||||
old_lines: header.before_hunk_len,
|
*self.deletions += deletions;
|
||||||
new_start: header.after_hunk_start,
|
self.hunks.push(hunk);
|
||||||
new_lines: header.after_hunk_len,
|
|
||||||
lines: out,
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Marks the end of the unified diff stream.
|
||||||
fn finish(self) {}
|
fn finish(self) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
use std::collections::HashSet;
|
pub const MAX_LISTED_COMMITS: usize = 20_000;
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use anyhow::Result;
|
pub struct CommitList {
|
||||||
|
pub total: usize,
|
||||||
|
pub commits: Vec<FileCommit>,
|
||||||
|
}
|
||||||
|
|
||||||
use crate::GixResultExt as _;
|
impl CommitList {
|
||||||
use crate::repo::Repo;
|
/// Creates a list holding `total` commits of which `commits` are materialized.
|
||||||
|
pub fn new(total: usize, commits: Vec<FileCommit>) -> Self {
|
||||||
|
Self { total, commits }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct FileCommit {
|
pub struct FileCommit {
|
||||||
@@ -16,27 +22,47 @@ pub struct FileCommit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl FileCommit {
|
impl FileCommit {
|
||||||
fn from_commit(commit: &gix::Commit<'_>) -> Result<FileCommit> {
|
/// Creates a commit entry from its id, message parts, author, and timestamp.
|
||||||
|
pub fn new(
|
||||||
|
id: String,
|
||||||
|
summary: String,
|
||||||
|
description: Option<String>,
|
||||||
|
author: String,
|
||||||
|
time: i64,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
summary,
|
||||||
|
description,
|
||||||
|
author,
|
||||||
|
time,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts a gix commit including its message body.
|
||||||
|
pub(crate) fn from_commit(commit: &gix::Commit<'_>) -> anyhow::Result<Self> {
|
||||||
Self::from_commit_with_description(commit, true)
|
Self::from_commit_with_description(commit, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// History lists never display the body,
|
/// Converts a gix commit, skipping the message body since lists never show it.
|
||||||
// skipping it saves an allocation per listed commit.
|
pub(crate) fn from_commit_summary(commit: &gix::Commit<'_>) -> anyhow::Result<Self> {
|
||||||
fn from_commit_summary(commit: &gix::Commit<'_>) -> Result<FileCommit> {
|
|
||||||
Self::from_commit_with_description(commit, false)
|
Self::from_commit_with_description(commit, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Converts a gix commit, optionally skipping the message body.
|
||||||
fn from_commit_with_description(
|
fn from_commit_with_description(
|
||||||
commit: &gix::Commit<'_>,
|
commit: &gix::Commit<'_>,
|
||||||
include_description: bool,
|
include_description: bool,
|
||||||
) -> Result<FileCommit> {
|
) -> anyhow::Result<Self> {
|
||||||
|
use crate::GixResultExt as _;
|
||||||
|
|
||||||
let author = commit.author().into_anyhow()?;
|
let author = commit.author().into_anyhow()?;
|
||||||
let message = commit.message().into_anyhow()?;
|
let message = commit.message().into_anyhow()?;
|
||||||
|
|
||||||
Ok(FileCommit {
|
Ok(FileCommit::new(
|
||||||
id: commit.id().shorten_or_id().to_string(),
|
commit.id().shorten_or_id().to_string(),
|
||||||
summary: String::from_utf8_lossy(message.title).trim().to_string(),
|
String::from_utf8_lossy(message.title).trim().to_string(),
|
||||||
description: if include_description {
|
if include_description {
|
||||||
message
|
message
|
||||||
.body
|
.body
|
||||||
.map(|body| String::from_utf8_lossy(body).trim().to_string())
|
.map(|body| String::from_utf8_lossy(body).trim().to_string())
|
||||||
@@ -44,154 +70,8 @@ impl FileCommit {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
},
|
},
|
||||||
author: String::from_utf8_lossy(author.name).trim().to_string(),
|
String::from_utf8_lossy(author.name).trim().to_string(),
|
||||||
time: author.time().into_anyhow()?.seconds,
|
author.time().into_anyhow()?.seconds,
|
||||||
})
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Repo {
|
|
||||||
// Paths without any commit, like untracked files, are absent from the result.
|
|
||||||
pub fn last_commits(&self, rels: &[PathBuf]) -> Result<Vec<(PathBuf, FileCommit)>> {
|
|
||||||
use gix::traverse::commit::simple::CommitTimeOrder;
|
|
||||||
|
|
||||||
let Some(head) = self.inner.head_id().ok() else {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut pending: Vec<PathBuf> = Vec::with_capacity(rels.len());
|
|
||||||
let mut seen: HashSet<&Path> = HashSet::with_capacity(rels.len());
|
|
||||||
|
|
||||||
for rel in rels {
|
|
||||||
if seen.insert(rel.as_path()) {
|
|
||||||
pending.push(rel.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let walk = self
|
|
||||||
.inner
|
|
||||||
.rev_walk([head])
|
|
||||||
.sorting(gix::revision::walk::Sorting::ByCommitTime(
|
|
||||||
CommitTimeOrder::NewestFirst,
|
|
||||||
));
|
|
||||||
|
|
||||||
let mut found = Vec::new();
|
|
||||||
for info in walk.all()? {
|
|
||||||
if pending.is_empty() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let info = info?;
|
|
||||||
let commit = info.object()?;
|
|
||||||
let tree = commit.tree()?;
|
|
||||||
let parent_tree = match info.parent_ids().next() {
|
|
||||||
Some(parent) => Some(parent.object()?.into_commit().tree()?),
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Compare each unresolved path against this commit and its first parent.
|
|
||||||
let mut ix = 0;
|
|
||||||
while ix < pending.len() {
|
|
||||||
let rel = &pending[ix];
|
|
||||||
let blob = tree.lookup_entry_by_path(rel)?;
|
|
||||||
let parent_blob = match &parent_tree {
|
|
||||||
Some(tree) => tree.lookup_entry_by_path(rel)?,
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
if blob.map(|entry| entry.id().detach())
|
|
||||||
!= parent_blob.map(|entry| entry.id().detach())
|
|
||||||
{
|
|
||||||
found.push((rel.clone(), FileCommit::from_commit(&commit)?));
|
|
||||||
pending.swap_remove(ix);
|
|
||||||
} else {
|
|
||||||
ix += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(found)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn all_commits(&self) -> Result<CommitList> {
|
|
||||||
use gix::traverse::commit::simple::CommitTimeOrder;
|
|
||||||
|
|
||||||
let Some(head) = self.inner.head_id().ok() else {
|
|
||||||
return Ok(CommitList {
|
|
||||||
total: 0,
|
|
||||||
commits: Vec::new(),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
let walk = self
|
|
||||||
.inner
|
|
||||||
.rev_walk([head])
|
|
||||||
.sorting(gix::revision::walk::Sorting::ByCommitTime(
|
|
||||||
CommitTimeOrder::NewestFirst,
|
|
||||||
));
|
|
||||||
|
|
||||||
let mut commits = Vec::new();
|
|
||||||
let mut total = 0;
|
|
||||||
|
|
||||||
for info in walk.all()? {
|
|
||||||
let info = info?;
|
|
||||||
total += 1;
|
|
||||||
if commits.len() < MAX_LISTED_COMMITS {
|
|
||||||
commits.push(FileCommit::from_commit_summary(&info.object()?)?);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(CommitList { total, commits })
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn commit_range(&self, base: &str, tip: &str) -> Result<Vec<FileCommit>> {
|
|
||||||
use gix::traverse::commit::simple::CommitTimeOrder;
|
|
||||||
|
|
||||||
let base_id = self.inner.rev_parse_single(base.as_bytes())?;
|
|
||||||
let tip_id = self.inner.rev_parse_single(tip.as_bytes())?;
|
|
||||||
let walk = self
|
|
||||||
.inner
|
|
||||||
.rev_walk([tip_id])
|
|
||||||
.sorting(gix::revision::walk::Sorting::ByCommitTime(
|
|
||||||
CommitTimeOrder::NewestFirst,
|
|
||||||
))
|
|
||||||
.with_hidden([base_id]);
|
|
||||||
|
|
||||||
let mut commits = Vec::new();
|
|
||||||
|
|
||||||
for info in walk.all()? {
|
|
||||||
let info = info?;
|
|
||||||
commits.push(FileCommit::from_commit_summary(&info.object()?)?);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(commits)
|
|
||||||
}
|
|
||||||
|
|
||||||
// `Ok(None)` for an unborn HEAD.
|
|
||||||
pub fn head_commit(&self) -> Result<Option<FileCommit>> {
|
|
||||||
let Some(head) = self.inner.head_id().ok() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let commit = head.object()?.into_commit();
|
|
||||||
Ok(Some(FileCommit::from_commit(&commit)?))
|
|
||||||
}
|
|
||||||
|
|
||||||
// `Ok(None)` when the id cannot be resolved.
|
|
||||||
pub fn commit(&self, id: &str) -> Result<Option<FileCommit>> {
|
|
||||||
match self.inner.rev_parse_single(id.as_bytes()) {
|
|
||||||
Ok(commit_id) => {
|
|
||||||
let commit = commit_id.object()?.into_commit();
|
|
||||||
Ok(Some(FileCommit::from_commit(&commit)?))
|
|
||||||
}
|
|
||||||
Err(_) => Ok(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The virtual list renders a window at a time, the tab badge shows the real
|
|
||||||
// count: a huge history is never fully materialized in memory.
|
|
||||||
pub const MAX_LISTED_COMMITS: usize = 20_000;
|
|
||||||
|
|
||||||
pub struct CommitList {
|
|
||||||
pub total: usize,
|
|
||||||
pub commits: Vec<FileCommit>,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ mod diff;
|
|||||||
mod history;
|
mod history;
|
||||||
mod nip34;
|
mod nip34;
|
||||||
mod patch;
|
mod patch;
|
||||||
mod remote;
|
|
||||||
mod repo;
|
mod repo;
|
||||||
mod scan;
|
mod scan;
|
||||||
mod worktree;
|
mod worktree;
|
||||||
@@ -21,6 +20,7 @@ pub use scan::{LocalRepo, find_git_repos};
|
|||||||
pub use worktree::WorktreeSnapshot;
|
pub use worktree::WorktreeSnapshot;
|
||||||
|
|
||||||
pub(crate) trait GixResultExt<T> {
|
pub(crate) trait GixResultExt<T> {
|
||||||
|
/// Converts a gix exception result into an `anyhow` result.
|
||||||
fn into_anyhow(self) -> anyhow::Result<T>;
|
fn into_anyhow(self) -> anyhow::Result<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-414
@@ -1,18 +1,15 @@
|
|||||||
use anyhow::Result;
|
|
||||||
use gix::bstr::ByteSlice;
|
|
||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
|
|
||||||
use crate::GixResultExt as _;
|
#[derive(serde::Deserialize)]
|
||||||
use crate::repo::Repo;
|
pub(crate) struct Nip34Json {
|
||||||
|
pub(crate) identifier: Option<String>,
|
||||||
|
pub(crate) owner: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// 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 {
|
||||||
// By `nak`'s `nip34.json` or `ngit`'s `nostr.repo`.
|
|
||||||
Initialized,
|
Initialized,
|
||||||
// Cloned from a `nostr://` remote but never initialized locally.
|
|
||||||
Cloned,
|
Cloned,
|
||||||
// Nostr tooling touched the repository but no binding is recoverable.
|
|
||||||
ToolingOnly,
|
ToolingOnly,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,7 +20,6 @@ pub struct GraspSignals {
|
|||||||
pub nostr_repo_config: bool,
|
pub nostr_repo_config: bool,
|
||||||
pub nostr_remote: bool,
|
pub nostr_remote: bool,
|
||||||
pub grasp_remote: bool,
|
pub grasp_remote: bool,
|
||||||
// `nak`-specific `nip34/grasp/<host>` remote name.
|
|
||||||
pub nip34_grasp_remote: bool,
|
pub nip34_grasp_remote: bool,
|
||||||
pub nip34_state_refs: bool,
|
pub nip34_state_refs: bool,
|
||||||
pub nostr_cache: bool,
|
pub nostr_cache: bool,
|
||||||
@@ -32,430 +28,36 @@ pub struct GraspSignals {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl GraspSignals {
|
impl GraspSignals {
|
||||||
|
/// Returns whether any Nostr tooling marker was found.
|
||||||
pub fn any(&self) -> bool {
|
pub fn any(&self) -> bool {
|
||||||
*self != Self::default()
|
*self != Self::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a local repository's on-disk state says about its NIP-34 binding.
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct Nip34Binding {
|
pub struct Nip34Binding {
|
||||||
pub kind: Nip34Kind,
|
pub kind: Nip34Kind,
|
||||||
pub signals: GraspSignals,
|
pub signals: GraspSignals,
|
||||||
// From `nip34.json` or `nostr.repo`.
|
|
||||||
pub owner: Option<PublicKey>,
|
pub owner: Option<PublicKey>,
|
||||||
pub identifier: Option<String>,
|
pub identifier: Option<String>,
|
||||||
pub grasp_urls: Vec<String>,
|
pub grasp_urls: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
impl Nip34Binding {
|
||||||
struct Nip34Json {
|
/// Creates a binding from its kind, detected signals, and recovered coordinates.
|
||||||
identifier: Option<String>,
|
pub fn new(
|
||||||
owner: Option<String>,
|
kind: Nip34Kind,
|
||||||
}
|
signals: GraspSignals,
|
||||||
|
owner: Option<PublicKey>,
|
||||||
impl Repo {
|
identifier: Option<String>,
|
||||||
// `None` when no Nostr tooling left a marker.
|
grasp_urls: Vec<String>,
|
||||||
pub fn nip34_binding(&self) -> Option<Nip34Binding> {
|
) -> Self {
|
||||||
let common_dir = self.inner.common_dir().to_path_buf();
|
Self {
|
||||||
let workdir = self.inner.workdir().map(std::path::Path::to_path_buf);
|
|
||||||
|
|
||||||
let mut signals = GraspSignals::default();
|
|
||||||
let mut owner: Option<PublicKey> = None;
|
|
||||||
let mut identifier: Option<String> = None;
|
|
||||||
let mut grasp_urls: Vec<String> = Vec::new();
|
|
||||||
|
|
||||||
if let Some(workdir) = &workdir {
|
|
||||||
if let Ok(bytes) = std::fs::read(workdir.join("nip34.json"))
|
|
||||||
&& let Ok(config) = serde_json::from_slice::<Nip34Json>(&bytes)
|
|
||||||
{
|
|
||||||
signals.nip34_json = true;
|
|
||||||
identifier = config.identifier.and_then(Self::non_empty);
|
|
||||||
owner = config
|
|
||||||
.owner
|
|
||||||
.as_deref()
|
|
||||||
.and_then(|value| PublicKey::parse(value).ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
if workdir.join("maintainers.yaml").is_file() {
|
|
||||||
signals.maintainers_yaml = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(exclude) = std::fs::read_to_string(common_dir.join("info/exclude"))
|
|
||||||
&& exclude.contains("nip34.json")
|
|
||||||
{
|
|
||||||
signals.nip34_excluded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// `ngit` keeps its repository event cache in the Git common directory.
|
|
||||||
if common_dir.join("nostr-cache.lmdb").is_file() {
|
|
||||||
signals.nostr_cache = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// `ngit` reads and writes `nostr.repo` at repository-local scope only.
|
|
||||||
if let Ok(config) = gix::config::File::from_path_no_includes(
|
|
||||||
common_dir.join("config"),
|
|
||||||
gix::config::Source::Local,
|
|
||||||
) {
|
|
||||||
if let Some(value) = config.string("nostr.repo")
|
|
||||||
&& let Some((key, id)) = Self::coordinate_from_naddr(&value.to_str_lossy())
|
|
||||||
{
|
|
||||||
signals.nostr_repo_config = true;
|
|
||||||
owner = Some(key);
|
|
||||||
identifier = Some(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
for key in ["nostr.repo-relay-only", "nostr.nostate", "nostr.private"] {
|
|
||||||
if config.string(key).is_some() {
|
|
||||||
signals.nostr_aux_config = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(sections) = config.sections_by_name("remote") {
|
|
||||||
for section in sections {
|
|
||||||
let Some(name) = section.header().subsection_name() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let nak_grasp_remote = name.to_str_lossy().starts_with("nip34/grasp/");
|
|
||||||
|
|
||||||
for url in section.values("url") {
|
|
||||||
let url = url.to_str_lossy();
|
|
||||||
|
|
||||||
if url.starts_with("nostr://") {
|
|
||||||
signals.nostr_remote = true;
|
|
||||||
// Strong markers win; only fill an empty binding.
|
|
||||||
if owner.is_none()
|
|
||||||
&& identifier.is_none()
|
|
||||||
&& let Some((key, id)) = Self::parse_nostr_url(&url)
|
|
||||||
{
|
|
||||||
owner = Some(key);
|
|
||||||
identifier = Some(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if Self::is_grasp_url(&url) {
|
|
||||||
signals.grasp_remote = true;
|
|
||||||
signals.nip34_grasp_remote |= nak_grasp_remote;
|
|
||||||
grasp_urls.push(url.to_string());
|
|
||||||
|
|
||||||
if owner.is_none()
|
|
||||||
&& identifier.is_none()
|
|
||||||
&& let Some((key, id)) = Self::grasp_parts(&url)
|
|
||||||
{
|
|
||||||
owner = Some(key);
|
|
||||||
identifier = Some(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// `nak` materializes a kind-30618 state as `refs/heads/nip34/state/*`.
|
|
||||||
if let Ok(platform) = self.inner.references()
|
|
||||||
&& let Ok(mut refs) = platform.prefixed(b"refs/heads/nip34/state/")
|
|
||||||
&& refs.next().is_some()
|
|
||||||
{
|
|
||||||
signals.nip34_state_refs = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if !signals.any() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let kind = if signals.nip34_json
|
|
||||||
|| signals.nostr_repo_config
|
|
||||||
|| signals.nip34_grasp_remote
|
|
||||||
|| signals.nip34_state_refs
|
|
||||||
{
|
|
||||||
Nip34Kind::Initialized
|
|
||||||
} else if signals.nostr_remote {
|
|
||||||
Nip34Kind::Cloned
|
|
||||||
} else {
|
|
||||||
Nip34Kind::ToolingOnly
|
|
||||||
};
|
|
||||||
|
|
||||||
Some(Nip34Binding {
|
|
||||||
kind,
|
kind,
|
||||||
signals,
|
signals,
|
||||||
owner,
|
owner,
|
||||||
identifier,
|
identifier,
|
||||||
grasp_urls,
|
grasp_urls,
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_nostr_repo(&self, naddr: &str) -> Result<()> {
|
|
||||||
self.edit_local_config(|config| {
|
|
||||||
config.set_raw_value("nostr.repo", naddr).into_anyhow()?;
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mirrors `nak`'s `IsGraspURL`: two path segments,
|
|
||||||
// a path of at least 65 bytes, and a first segment that decodes as an `npub`.
|
|
||||||
fn is_grasp_url(url: &str) -> bool {
|
|
||||||
let Ok(parsed) = Url::parse(url) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
if !matches!(parsed.scheme(), "http" | "https" | "grasp") {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let path = parsed.path();
|
|
||||||
if path.matches('/').count() != 2 || path.len() < 65 {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
Self::grasp_parts(url).is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn grasp_parts(url: &str) -> Option<(PublicKey, String)> {
|
|
||||||
let parsed = Url::parse(url).ok()?;
|
|
||||||
let mut segments = parsed.path_segments()?.filter(|part| !part.is_empty());
|
|
||||||
|
|
||||||
let owner = PublicKey::parse(segments.next()?).ok()?;
|
|
||||||
let identifier = Self::non_empty(segments.next()?.trim_end_matches(".git"))?;
|
|
||||||
|
|
||||||
Some((owner, identifier))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn coordinate_from_naddr(value: &str) -> Option<(PublicKey, String)> {
|
|
||||||
let coordinate = Nip19Coordinate::from_bech32(value).ok()?;
|
|
||||||
if coordinate.kind != Kind::GitRepoAnnouncement {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let identifier = Self::non_empty(coordinate.identifier.clone())?;
|
|
||||||
Some((coordinate.public_key, identifier))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handles a bare `naddr`, an `npub`, and the optional `[ssh-key-file@]`,
|
|
||||||
// `[protocol/]` and `[relay/]` components. An `nip05` owner yields no binding.
|
|
||||||
fn parse_nostr_url(url: &str) -> Option<(PublicKey, String)> {
|
|
||||||
let rest = url.strip_prefix("nostr://")?;
|
|
||||||
|
|
||||||
if rest.starts_with("naddr1") {
|
|
||||||
return Self::coordinate_from_naddr(rest);
|
|
||||||
}
|
|
||||||
|
|
||||||
let rest = rest.rsplit_once('@').map_or(rest, |(_, after)| after);
|
|
||||||
let mut parts: Vec<&str> = rest.split('/').filter(|part| !part.is_empty()).collect();
|
|
||||||
|
|
||||||
if parts
|
|
||||||
.first()
|
|
||||||
.is_some_and(|first| matches!(*first, "ssh" | "https" | "http"))
|
|
||||||
{
|
|
||||||
parts.remove(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if parts.len() < 2 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let owner = PublicKey::parse(parts[0]).ok()?;
|
|
||||||
let identifier = Self::non_empty(parts.last()?.trim_end_matches(".git"))?;
|
|
||||||
|
|
||||||
Some((owner, identifier))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn non_empty(value: impl Into<String>) -> Option<String> {
|
|
||||||
let value = value.into();
|
|
||||||
(!value.is_empty()).then_some(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use std::path::Path;
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn init_repo() -> (tempfile::TempDir, std::path::PathBuf) {
|
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
|
||||||
let path = dir.path().join("repo");
|
|
||||||
std::fs::create_dir_all(&path).expect("mkdir");
|
|
||||||
git(&path, &["init", "-q"]);
|
|
||||||
(dir, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn git(dir: &Path, args: &[&str]) {
|
|
||||||
let status = Command::new("git")
|
|
||||||
.current_dir(dir)
|
|
||||||
.env("GIT_AUTHOR_NAME", "Test Author")
|
|
||||||
.env("GIT_AUTHOR_EMAIL", "test@example.com")
|
|
||||||
.env("GIT_COMMITTER_NAME", "Test Author")
|
|
||||||
.env("GIT_COMMITTER_EMAIL", "test@example.com")
|
|
||||||
.env("GIT_EDITOR", "true")
|
|
||||||
.args(args)
|
|
||||||
.status()
|
|
||||||
.expect("spawn git");
|
|
||||||
assert!(status.success(), "git {args:?} failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn key() -> PublicKey {
|
|
||||||
Keys::generate().public_key()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn naddr(kind: Kind, owner: PublicKey, identifier: &str) -> String {
|
|
||||||
let coordinate = Coordinate::new(kind, owner).identifier(identifier);
|
|
||||||
Nip19Coordinate::new(coordinate, Vec::<RelayUrl>::new())
|
|
||||||
.to_bech32()
|
|
||||||
.expect("naddr")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn binding_of(path: &Path) -> Option<Nip34Binding> {
|
|
||||||
Repo::open(path).ok()?.nip34_binding()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn plain_repository_has_no_binding() {
|
|
||||||
let (_dir, path) = init_repo();
|
|
||||||
assert!(binding_of(&path).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn nip34_json_marks_a_repository_initialized() {
|
|
||||||
let (_dir, path) = init_repo();
|
|
||||||
let owner = key();
|
|
||||||
let npub = owner.to_bech32().expect("npub");
|
|
||||||
std::fs::write(
|
|
||||||
path.join("nip34.json"),
|
|
||||||
format!(r#"{{"identifier":"my-repo","owner":"{npub}"}}"#),
|
|
||||||
)
|
|
||||||
.expect("write");
|
|
||||||
|
|
||||||
let binding = binding_of(&path).expect("binding");
|
|
||||||
assert_eq!(binding.kind, Nip34Kind::Initialized);
|
|
||||||
assert!(binding.signals.nip34_json);
|
|
||||||
assert_eq!(binding.owner, Some(owner));
|
|
||||||
assert_eq!(binding.identifier.as_deref(), Some("my-repo"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn malformed_nip34_json_is_ignored() {
|
|
||||||
let (_dir, path) = init_repo();
|
|
||||||
std::fs::write(path.join("nip34.json"), b"not json").expect("write");
|
|
||||||
|
|
||||||
assert!(binding_of(&path).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn nak_exclude_and_state_refs_are_detected() {
|
|
||||||
let (_dir, path) = init_repo();
|
|
||||||
|
|
||||||
std::fs::create_dir_all(path.join(".git/info")).expect("mkdir");
|
|
||||||
std::fs::write(path.join(".git/info/exclude"), "nip34.json\n").expect("write");
|
|
||||||
|
|
||||||
git(&path, &["commit", "-q", "--allow-empty", "-m", "initial"]);
|
|
||||||
git(
|
|
||||||
&path,
|
|
||||||
&["update-ref", "refs/heads/nip34/state/HEAD", "HEAD"],
|
|
||||||
);
|
|
||||||
|
|
||||||
let binding = binding_of(&path).expect("binding");
|
|
||||||
assert_eq!(binding.kind, Nip34Kind::Initialized);
|
|
||||||
assert!(binding.signals.nip34_excluded);
|
|
||||||
assert!(binding.signals.nip34_state_refs);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn nostr_repo_config_marks_a_repository_initialized() {
|
|
||||||
let (_dir, path) = init_repo();
|
|
||||||
let owner = key();
|
|
||||||
let naddr = naddr(Kind::GitRepoAnnouncement, owner, "my-repo");
|
|
||||||
git(&path, &["config", "nostr.repo", &naddr]);
|
|
||||||
|
|
||||||
let binding = binding_of(&path).expect("binding");
|
|
||||||
assert_eq!(binding.kind, Nip34Kind::Initialized);
|
|
||||||
assert!(binding.signals.nostr_repo_config);
|
|
||||||
assert_eq!(binding.owner, Some(owner));
|
|
||||||
assert_eq!(binding.identifier.as_deref(), Some("my-repo"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_written_nostr_repo_marker_is_detected() {
|
|
||||||
let (_dir, path) = init_repo();
|
|
||||||
let owner = key();
|
|
||||||
let naddr = naddr(Kind::GitRepoAnnouncement, owner, "my-repo");
|
|
||||||
|
|
||||||
Repo::open(&path)
|
|
||||||
.expect("open")
|
|
||||||
.set_nostr_repo(&naddr)
|
|
||||||
.expect("write marker");
|
|
||||||
|
|
||||||
let binding = binding_of(&path).expect("binding");
|
|
||||||
assert_eq!(binding.kind, Nip34Kind::Initialized);
|
|
||||||
assert!(binding.signals.nostr_repo_config);
|
|
||||||
assert_eq!(binding.owner, Some(owner));
|
|
||||||
assert_eq!(binding.identifier.as_deref(), Some("my-repo"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn nostr_remote_is_a_nip34_clone() {
|
|
||||||
let (_dir, path) = init_repo();
|
|
||||||
let owner = key();
|
|
||||||
let npub = owner.to_bech32().expect("npub");
|
|
||||||
let url = format!("nostr://{npub}/relay.ngit.dev/my-repo");
|
|
||||||
git(&path, &["remote", "add", "origin", &url]);
|
|
||||||
|
|
||||||
let binding = binding_of(&path).expect("binding");
|
|
||||||
assert_eq!(binding.kind, Nip34Kind::Cloned);
|
|
||||||
assert!(binding.signals.nostr_remote);
|
|
||||||
assert_eq!(binding.owner, Some(owner));
|
|
||||||
assert_eq!(binding.identifier.as_deref(), Some("my-repo"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn nak_grasp_remote_marks_a_repository_initialized() {
|
|
||||||
let (_dir, path) = init_repo();
|
|
||||||
let owner = key();
|
|
||||||
let npub = owner.to_bech32().expect("npub");
|
|
||||||
let url = format!("https://gitnostr.com/{npub}/my-repo.git");
|
|
||||||
git(
|
|
||||||
&path,
|
|
||||||
&["config", "remote.nip34/grasp/gitnostr.com.url", &url],
|
|
||||||
);
|
|
||||||
|
|
||||||
let binding = binding_of(&path).expect("binding");
|
|
||||||
assert_eq!(binding.kind, Nip34Kind::Initialized);
|
|
||||||
assert!(binding.signals.nip34_grasp_remote);
|
|
||||||
assert!(binding.signals.grasp_remote);
|
|
||||||
assert_eq!(binding.grasp_urls, vec![url]);
|
|
||||||
assert_eq!(binding.owner, Some(owner));
|
|
||||||
assert_eq!(binding.identifier.as_deref(), Some("my-repo"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn nostr_cache_alone_is_tooling_only() {
|
|
||||||
let (_dir, path) = init_repo();
|
|
||||||
std::fs::write(path.join(".git/nostr-cache.lmdb"), b"cache").expect("write");
|
|
||||||
|
|
||||||
let binding = binding_of(&path).expect("binding");
|
|
||||||
assert_eq!(binding.kind, Nip34Kind::ToolingOnly);
|
|
||||||
assert!(binding.signals.nostr_cache);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn grasp_urls_are_recognised_by_shape() {
|
|
||||||
let owner = key();
|
|
||||||
let npub = owner.to_bech32().expect("npub");
|
|
||||||
|
|
||||||
assert!(Repo::is_grasp_url(&format!(
|
|
||||||
"https://gitnostr.com/{npub}/my-repo.git"
|
|
||||||
)));
|
|
||||||
assert!(Repo::is_grasp_url(&format!(
|
|
||||||
"grasp://gitnostr.com/{npub}/my-repo.git"
|
|
||||||
)));
|
|
||||||
|
|
||||||
assert!(!Repo::is_grasp_url("https://gitnostr.com/my-repo.git"));
|
|
||||||
assert!(!Repo::is_grasp_url(
|
|
||||||
"https://gitnostr.com/not-a-pubkey/my-repo.git"
|
|
||||||
));
|
|
||||||
assert!(!Repo::is_grasp_url(&format!(
|
|
||||||
"ssh://gitnostr.com/{npub}/my-repo.git"
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-157
@@ -1,17 +1,14 @@
|
|||||||
use std::io::Write;
|
use anyhow::Result;
|
||||||
use std::process::Stdio;
|
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
|
||||||
use diffy::patch_set::{FileOperation, FilePatch, ParseOptions, PatchSet};
|
use diffy::patch_set::{FileOperation, FilePatch, ParseOptions, PatchSet};
|
||||||
use diffy::{Hunk, Line};
|
use diffy::{Hunk, Line};
|
||||||
|
|
||||||
use crate::diff::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileDiff};
|
use crate::diff::{CommitDiff, DiffHunk, DiffLineKind, DiffStatus, FileDiff, HunkBuilder};
|
||||||
use crate::history::FileCommit;
|
use crate::history::FileCommit;
|
||||||
use crate::repo::Repo;
|
|
||||||
|
|
||||||
pub struct PatchParser;
|
pub struct PatchParser;
|
||||||
|
|
||||||
impl PatchParser {
|
impl PatchParser {
|
||||||
|
/// Splits an mbox series into the raw text of each message.
|
||||||
pub fn split_patch_series(patch: &str) -> Vec<&str> {
|
pub fn split_patch_series(patch: &str) -> Vec<&str> {
|
||||||
Self::envelopes(patch)
|
Self::envelopes(patch)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -19,44 +16,49 @@ impl PatchParser {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parses a git diff into file diffs, returning an empty diff without one.
|
||||||
pub fn patch_diffs(patch: &str) -> Result<CommitDiff> {
|
pub fn patch_diffs(patch: &str) -> Result<CommitDiff> {
|
||||||
if !patch.lines().any(|line| line.starts_with("diff --git ")) {
|
if !patch.lines().any(|line| line.starts_with("diff --git ")) {
|
||||||
return Ok(CommitDiff { files: Vec::new() });
|
return Ok(CommitDiff::new(Vec::new()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut files = Vec::new();
|
let mut files = Vec::new();
|
||||||
|
|
||||||
for file in PatchSet::parse(patch, ParseOptions::gitdiff()) {
|
for file in PatchSet::parse(patch, ParseOptions::gitdiff()) {
|
||||||
files.push(Self::file_diff(file?)?);
|
files.push(Self::file_diff(file?));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(CommitDiff { files })
|
Ok(CommitDiff::new(files))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lists the commits represented by the mbox messages that carry an id.
|
||||||
pub fn patch_commits(patch: &str) -> Vec<FileCommit> {
|
pub fn patch_commits(patch: &str) -> Vec<FileCommit> {
|
||||||
Self::envelopes(patch)
|
Self::envelopes(patch)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|message| !message.id.is_empty())
|
.filter(|message| !message.id.is_empty())
|
||||||
.map(|message| FileCommit {
|
.map(|message| {
|
||||||
id: message.id.to_string(),
|
FileCommit::new(
|
||||||
summary: message
|
message.id.to_string(),
|
||||||
.header("Subject")
|
message
|
||||||
.map(Self::strip_patch_prefix)
|
.header("Subject")
|
||||||
.unwrap_or_default(),
|
.map(Self::strip_patch_prefix)
|
||||||
description: None,
|
.unwrap_or_default(),
|
||||||
author: message
|
None,
|
||||||
.header("From")
|
message
|
||||||
.map(Self::name_from_address)
|
.header("From")
|
||||||
.unwrap_or_default(),
|
.map(Self::name_from_address)
|
||||||
time: message
|
.unwrap_or_default(),
|
||||||
.header("Date")
|
message
|
||||||
.and_then(|value| gix::date::parse(value.trim(), None).ok())
|
.header("Date")
|
||||||
.map(|time| time.seconds)
|
.and_then(|value| gix::date::parse(value.trim(), None).ok())
|
||||||
.unwrap_or(0),
|
.map(|time| time.seconds)
|
||||||
|
.unwrap_or(0),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Splits `patch` on `From <40-hex> <date>` envelopes, falling back to one message.
|
||||||
fn envelopes(patch: &str) -> Vec<Envelope<'_>> {
|
fn envelopes(patch: &str) -> Vec<Envelope<'_>> {
|
||||||
let mut messages: Vec<Envelope<'_>> = Vec::new();
|
let mut messages: Vec<Envelope<'_>> = Vec::new();
|
||||||
let mut current: Option<(usize, &str, Vec<&str>)> = None;
|
let mut current: Option<(usize, &str, Vec<&str>)> = None;
|
||||||
@@ -74,11 +76,7 @@ impl PatchParser {
|
|||||||
|
|
||||||
if is_envelope {
|
if is_envelope {
|
||||||
if let Some((start, id, headers)) = current.take() {
|
if let Some((start, id, headers)) = current.take() {
|
||||||
messages.push(Envelope {
|
messages.push(Envelope::new(&patch[start..], id, headers));
|
||||||
text: &patch[start..],
|
|
||||||
id,
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let id = line
|
let id = line
|
||||||
@@ -104,24 +102,17 @@ impl PatchParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some((start, id, headers)) = current.take() {
|
if let Some((start, id, headers)) = current.take() {
|
||||||
messages.push(Envelope {
|
messages.push(Envelope::new(&patch[start..], id, headers));
|
||||||
text: &patch[start..],
|
|
||||||
id,
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if messages.is_empty() {
|
if messages.is_empty() {
|
||||||
messages.push(Envelope {
|
messages.push(Envelope::new(patch, "", Vec::new()));
|
||||||
text: patch,
|
|
||||||
id: "",
|
|
||||||
headers: Vec::new(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
messages
|
messages
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extracts the display name from a `From` header value.
|
||||||
fn name_from_address(from: &str) -> String {
|
fn name_from_address(from: &str) -> String {
|
||||||
match from.trim().find('<') {
|
match from.trim().find('<') {
|
||||||
Some(ix) => from[..ix].trim().to_string(),
|
Some(ix) => from[..ix].trim().to_string(),
|
||||||
@@ -129,7 +120,7 @@ impl PatchParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Matches `[PATCH]`, `[PATCH 1/2]`, `[RFC PATCH]`, etc.
|
/// Strips mailing list prefixes like `[PATCH]` or `[RFC PATCH 1/2]`.
|
||||||
fn strip_patch_prefix(subject: &str) -> String {
|
fn strip_patch_prefix(subject: &str) -> String {
|
||||||
let trimmed = subject.trim();
|
let trimmed = subject.trim();
|
||||||
let Some(rest) = trimmed.strip_prefix('[') else {
|
let Some(rest) = trimmed.strip_prefix('[') else {
|
||||||
@@ -145,7 +136,8 @@ impl PatchParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn file_diff(file: FilePatch<'_, str>) -> Result<FileDiff> {
|
/// Converts one parsed file patch, keeping rename and copy paths unstripped.
|
||||||
|
fn file_diff(file: FilePatch<'_, str>) -> FileDiff {
|
||||||
let stripped;
|
let stripped;
|
||||||
let operation = match file.operation() {
|
let operation = match file.operation() {
|
||||||
operation @ (FileOperation::Rename { .. } | FileOperation::Copy { .. }) => operation,
|
operation @ (FileOperation::Rename { .. } | FileOperation::Copy { .. }) => operation,
|
||||||
@@ -177,39 +169,35 @@ impl PatchParser {
|
|||||||
|
|
||||||
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 = Self::hunk_diff(hunk);
|
let (hunk, hunk_insertions, hunk_deletions) = Self::hunk_diff(hunk);
|
||||||
insertions += hunk
|
insertions += hunk_insertions;
|
||||||
.lines
|
deletions += hunk_deletions;
|
||||||
.iter()
|
|
||||||
.filter(|line| line.kind == DiffLineKind::Addition)
|
|
||||||
.count();
|
|
||||||
deletions += hunk
|
|
||||||
.lines
|
|
||||||
.iter()
|
|
||||||
.filter(|line| line.kind == DiffLineKind::Deletion)
|
|
||||||
.count();
|
|
||||||
hunks.push(hunk);
|
hunks.push(hunk);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(FileDiff {
|
FileDiff::new(
|
||||||
path: path.to_owned(),
|
path.to_owned(),
|
||||||
old_path: old_path.map(str::to_owned),
|
old_path.map(str::to_owned),
|
||||||
status,
|
status,
|
||||||
insertions,
|
insertions,
|
||||||
deletions,
|
deletions,
|
||||||
binary: patch.is_binary(),
|
patch.is_binary(),
|
||||||
hunks,
|
hunks,
|
||||||
})
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hunk_diff(hunk: &Hunk<'_, str>) -> DiffHunk {
|
/// Converts a diffy hunk into a `DiffHunk` with its insertion and deletion counts.
|
||||||
|
fn hunk_diff(hunk: &Hunk<'_, str>) -> (DiffHunk, usize, usize) {
|
||||||
let old_range = hunk.old_range();
|
let old_range = hunk.old_range();
|
||||||
let new_range = hunk.new_range();
|
let new_range = hunk.new_range();
|
||||||
|
|
||||||
let mut old = old_range.start() as u32;
|
let mut builder = HunkBuilder::new(
|
||||||
let mut new = new_range.start() as u32;
|
old_range.start() as u32,
|
||||||
let mut lines = Vec::with_capacity(hunk.lines().len());
|
old_range.len() as u32,
|
||||||
|
new_range.start() as u32,
|
||||||
|
new_range.len() as u32,
|
||||||
|
);
|
||||||
|
|
||||||
for line in hunk.lines() {
|
for line in hunk.lines() {
|
||||||
let (kind, text) = match line {
|
let (kind, text) = match line {
|
||||||
@@ -218,56 +206,28 @@ impl PatchParser {
|
|||||||
Line::Insert(text) => (DiffLineKind::Addition, *text),
|
Line::Insert(text) => (DiffLineKind::Addition, *text),
|
||||||
};
|
};
|
||||||
|
|
||||||
let (old_no, new_no) = match kind {
|
let text = text.strip_suffix('\n').unwrap_or(text);
|
||||||
DiffLineKind::Context => {
|
let text = text.strip_suffix('\r').unwrap_or(text);
|
||||||
let numbers = (Some(old), Some(new));
|
builder.push(kind, text.to_owned());
|
||||||
old += 1;
|
|
||||||
new += 1;
|
|
||||||
numbers
|
|
||||||
}
|
|
||||||
DiffLineKind::Addition => {
|
|
||||||
let number = Some(new);
|
|
||||||
new += 1;
|
|
||||||
(None, number)
|
|
||||||
}
|
|
||||||
DiffLineKind::Deletion => {
|
|
||||||
let number = Some(old);
|
|
||||||
old += 1;
|
|
||||||
(number, None)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
lines.push(DiffLine {
|
|
||||||
kind,
|
|
||||||
old: old_no,
|
|
||||||
new: new_no,
|
|
||||||
text: text
|
|
||||||
.strip_suffix('\n')
|
|
||||||
.unwrap_or(text)
|
|
||||||
.strip_suffix('\r')
|
|
||||||
.unwrap_or(text)
|
|
||||||
.to_owned(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
DiffHunk {
|
builder.finish()
|
||||||
old_start: old_range.start() as u32,
|
|
||||||
old_lines: old_range.len() as u32,
|
|
||||||
new_start: new_range.start() as u32,
|
|
||||||
new_lines: new_range.len() as u32,
|
|
||||||
lines,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A `git format-patch` mbox message, split on its `From <40-hex> <date>` envelope.
|
|
||||||
struct Envelope<'a> {
|
struct Envelope<'a> {
|
||||||
text: &'a str,
|
text: &'a str,
|
||||||
id: &'a str,
|
id: &'a str,
|
||||||
headers: Vec<&'a str>,
|
headers: Vec<&'a str>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Envelope<'_> {
|
impl<'a> Envelope<'a> {
|
||||||
|
/// Creates an envelope from its text, commit id, and header lines.
|
||||||
|
fn new(text: &'a str, id: &'a str, headers: Vec<&'a str>) -> Self {
|
||||||
|
Self { text, id, headers }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the first header line matching `name: `.
|
||||||
fn header(&self, name: &str) -> Option<&str> {
|
fn header(&self, name: &str) -> Option<&str> {
|
||||||
let prefix = format!("{name}: ");
|
let prefix = format!("{name}: ");
|
||||||
self.headers
|
self.headers
|
||||||
@@ -276,59 +236,3 @@ impl Envelope<'_> {
|
|||||||
.map(|line| &line[prefix.len()..])
|
.map(|line| &line[prefix.len()..])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Repo {
|
|
||||||
pub fn apply_patch(&self, patch: &str) -> Result<()> {
|
|
||||||
let workdir = self
|
|
||||||
.inner
|
|
||||||
.workdir()
|
|
||||||
.context("repository has no worktree")?
|
|
||||||
.to_path_buf();
|
|
||||||
|
|
||||||
let mut child = Repo::git_command(&workdir)
|
|
||||||
.args(["am"])
|
|
||||||
.stdin(Stdio::piped())
|
|
||||||
.stderr(Stdio::piped())
|
|
||||||
.spawn()
|
|
||||||
.context("failed to spawn `git am`")?;
|
|
||||||
|
|
||||||
child
|
|
||||||
.stdin
|
|
||||||
.as_mut()
|
|
||||||
.context("git am has no stdin pipe")?
|
|
||||||
.write_all(patch.as_bytes())?;
|
|
||||||
|
|
||||||
let output = child.wait_with_output()?;
|
|
||||||
|
|
||||||
if !output.status.success() {
|
|
||||||
bail!("git am failed: {}", String::from_utf8_lossy(&output.stderr));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fails when the range has no commits. The mbox is returned untrimmed;
|
|
||||||
// trailing newlines are part of the format.
|
|
||||||
pub fn format_patch_between(&self, base: &str, tip: &str) -> Result<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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,285 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
use std::path::Path;
|
|
||||||
use std::process::{Command, Stdio};
|
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
|
||||||
use gix::interrupt::IS_INTERRUPTED;
|
|
||||||
use gix::progress::Discard;
|
|
||||||
|
|
||||||
use crate::GixResultExt as _;
|
|
||||||
use crate::repo::Repo;
|
|
||||||
|
|
||||||
impl Repo {
|
|
||||||
pub fn fetch(&self) -> Result<()> {
|
|
||||||
let options = gix::remote::ref_map::Options {
|
|
||||||
extra_refspecs: vec![
|
|
||||||
gix::refspec::parse(
|
|
||||||
gix::bstr::BStr::new("+refs/nostr/*:refs/nostr/*"),
|
|
||||||
gix::refspec::parse::Operation::Fetch,
|
|
||||||
)
|
|
||||||
.into_anyhow()?
|
|
||||||
.to_owned(),
|
|
||||||
],
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
self.inner
|
|
||||||
.find_remote("origin")?
|
|
||||||
.connect(gix::remote::Direction::Fetch)?
|
|
||||||
.prepare_fetch(Discard, options)?
|
|
||||||
.receive(Discard, &IS_INTERRUPTED)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn push_ref(&self, url: &str, commit: &str, reference: &str) -> Result<()> {
|
|
||||||
let output = Self::run_git(
|
|
||||||
self.workdir_or_dot(),
|
|
||||||
&["push", url, &format!("{commit}:{reference}")],
|
|
||||||
"git push",
|
|
||||||
)?;
|
|
||||||
|
|
||||||
if !output.status.success() {
|
|
||||||
bail!(
|
|
||||||
"git push failed: {}",
|
|
||||||
String::from_utf8_lossy(&output.stderr).trim()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn push_main(&self, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
|
|
||||||
self.push_refspecs(
|
|
||||||
base_url,
|
|
||||||
owner,
|
|
||||||
repo_id,
|
|
||||||
&["refs/heads/main:refs/heads/main"],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn push_all(&self, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
|
|
||||||
self.push_refspecs(
|
|
||||||
base_url,
|
|
||||||
owner,
|
|
||||||
repo_id,
|
|
||||||
&["refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn push_refspecs(
|
|
||||||
&self,
|
|
||||||
base_url: &str,
|
|
||||||
owner: &str,
|
|
||||||
repo_id: &str,
|
|
||||||
refspecs: &[&str],
|
|
||||||
) -> Result<()> {
|
|
||||||
let url = format!("{base_url}/{owner}/{repo_id}.git");
|
|
||||||
|
|
||||||
let mut args: Vec<&str> = Vec::with_capacity(refspecs.len() + 2);
|
|
||||||
args.push("push");
|
|
||||||
args.push(&url);
|
|
||||||
args.extend_from_slice(refspecs);
|
|
||||||
|
|
||||||
let output = Self::run_git(self.workdir_or_dot(), &args, "git push")?;
|
|
||||||
|
|
||||||
if !output.status.success() {
|
|
||||||
bail!(
|
|
||||||
"git push to {base_url} failed: {}",
|
|
||||||
String::from_utf8_lossy(&output.stderr).trim()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remote_has_refs(&self, url: &str, expected: &[(String, String)]) -> Result<bool> {
|
|
||||||
if expected.is_empty() {
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
let url = Self::transport_url(url);
|
|
||||||
|
|
||||||
let refspecs = expected
|
|
||||||
.iter()
|
|
||||||
.map(|(name, _)| {
|
|
||||||
gix::refspec::parse(
|
|
||||||
gix::bstr::BStr::new(format!("+{name}:{name}").as_bytes()),
|
|
||||||
gix::refspec::parse::Operation::Fetch,
|
|
||||||
)
|
|
||||||
.map(|spec| spec.to_owned())
|
|
||||||
})
|
|
||||||
.collect::<Result<Vec<_>, _>>()
|
|
||||||
.into_anyhow()
|
|
||||||
.context("invalid refspec")?;
|
|
||||||
|
|
||||||
let options = gix::remote::ref_map::Options {
|
|
||||||
extra_refspecs: refspecs,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let (refs, _) = self
|
|
||||||
.inner
|
|
||||||
.remote_at(url.as_str())
|
|
||||||
.with_context(|| format!("cannot use remote {url}"))?
|
|
||||||
.connect(gix::remote::Direction::Fetch)
|
|
||||||
.with_context(|| format!("cannot connect to {url}"))?
|
|
||||||
.ref_map(Discard, options)
|
|
||||||
.with_context(|| format!("listing refs of {url} failed"))?;
|
|
||||||
|
|
||||||
// Peeled tag entries carry the tag object in their direct oid,
|
|
||||||
// matching `git ls-remote` while skipping the duplicated `^{}` lines.
|
|
||||||
let advertised: HashMap<String, String> = refs
|
|
||||||
.remote_refs
|
|
||||||
.iter()
|
|
||||||
.filter_map(|reference| {
|
|
||||||
let (name, object, _peeled) = reference.unpack();
|
|
||||||
object.map(|oid| (String::from_utf8_lossy(name).into_owned(), oid.to_string()))
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(expected
|
|
||||||
.iter()
|
|
||||||
.all(|(name, oid)| advertised.get(name.as_str()) == Some(oid)))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ensure_origin(&self, url: &str) -> Result<()> {
|
|
||||||
if self.inner.find_remote("origin").is_ok() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// `git remote add` also configures the default fetch refspec.
|
|
||||||
self.edit_local_config(|config| {
|
|
||||||
config
|
|
||||||
.set_raw_value("remote.origin.url", url)
|
|
||||||
.into_anyhow()?;
|
|
||||||
config
|
|
||||||
.set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
|
|
||||||
.into_anyhow()?;
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// A working copy cloned from a local mirror is re-targeted at the grasp server,
|
|
||||||
// a pre-existing fetch refspec is left untouched.
|
|
||||||
pub fn set_origin(&self, url: &str) -> Result<()> {
|
|
||||||
let had_origin = self.inner.find_remote("origin").is_ok();
|
|
||||||
|
|
||||||
self.edit_local_config(|config| {
|
|
||||||
config
|
|
||||||
.set_raw_value("remote.origin.url", url)
|
|
||||||
.into_anyhow()?;
|
|
||||||
|
|
||||||
if !had_origin {
|
|
||||||
config
|
|
||||||
.set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
|
|
||||||
.into_anyhow()?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn origin_url(&self) -> Result<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()))
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
.into_anyhow()
|
|
||||||
.context("invalid fetch refspec")?
|
|
||||||
.to_owned();
|
|
||||||
|
|
||||||
let mut last_error = None;
|
|
||||||
|
|
||||||
for url in urls {
|
|
||||||
let url = Self::transport_url(url.as_ref());
|
|
||||||
let outcome = (|| -> Result<()> {
|
|
||||||
let options = gix::remote::ref_map::Options {
|
|
||||||
extra_refspecs: vec![refspec.clone()],
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
self.inner
|
|
||||||
.remote_at(url.as_str())
|
|
||||||
.with_context(|| format!("fetch from {url} failed"))?
|
|
||||||
.connect(gix::remote::Direction::Fetch)
|
|
||||||
.with_context(|| format!("fetch from {url} failed"))?
|
|
||||||
.prepare_fetch(Discard, options)
|
|
||||||
.with_context(|| format!("fetch from {url} failed"))?
|
|
||||||
.receive(Discard, &IS_INTERRUPTED)
|
|
||||||
.with_context(|| format!("fetch from {url} failed"))?;
|
|
||||||
Ok(())
|
|
||||||
})();
|
|
||||||
|
|
||||||
match outcome {
|
|
||||||
Ok(()) => return Ok(()),
|
|
||||||
Err(error) => last_error = Some(error),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match last_error {
|
|
||||||
Some(error) => Err(error).context("failed to fetch from any mirror"),
|
|
||||||
None => bail!("no fetch URLs provided"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn edit_local_config(
|
|
||||||
&self,
|
|
||||||
edit: impl FnOnce(&mut gix::config::File) -> Result<()>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let config_path = self.inner.common_dir().join("config");
|
|
||||||
|
|
||||||
let mut lock = gix::lock::File::acquire_to_update_resource(
|
|
||||||
&config_path,
|
|
||||||
gix::lock::acquire::Fail::Immediately,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.into_anyhow()
|
|
||||||
.context("failed to lock repository config")?;
|
|
||||||
|
|
||||||
let mut config =
|
|
||||||
match gix::config::File::from_path_no_includes(config_path, gix::config::Source::Local)
|
|
||||||
{
|
|
||||||
Ok(config) => config,
|
|
||||||
Err(error) if error.is_not_found() => gix::config::File::default(),
|
|
||||||
Err(error) => {
|
|
||||||
return Err(error)
|
|
||||||
.into_anyhow()
|
|
||||||
.context("failed to read repository config");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
edit(&mut config)?;
|
|
||||||
|
|
||||||
config
|
|
||||||
.write_to(&mut lock)
|
|
||||||
.context("failed to write repository config")?;
|
|
||||||
|
|
||||||
lock.commit().context("failed to save repository config")?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn workdir_or_dot(&self) -> &Path {
|
|
||||||
self.inner.workdir().unwrap_or_else(|| Path::new("."))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn run_git(dir: &Path, args: &[&str], what: &str) -> Result<std::process::Output> {
|
|
||||||
Self::git_command(dir)
|
|
||||||
.args(args)
|
|
||||||
.stderr(Stdio::piped())
|
|
||||||
.output()
|
|
||||||
.with_context(|| format!("failed to spawn `{what}`"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn git_command(dir: &Path) -> Command {
|
|
||||||
let mut command = Command::new("git");
|
|
||||||
command.arg("-C").arg(dir).env("GIT_TERMINAL_PROMPT", "0");
|
|
||||||
command
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1362
-24
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,11 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use ignore::WalkBuilder;
|
use ignore::WalkBuilder;
|
||||||
|
|
||||||
use crate::nip34::Nip34Binding;
|
use crate::nip34::Nip34Binding;
|
||||||
use crate::repo::Repo;
|
use crate::repo::Repo;
|
||||||
|
|
||||||
/// Caps nesting so pathological trees can't stall the scan.
|
|
||||||
const SCAN_MAX_DEPTH: usize = 12;
|
const SCAN_MAX_DEPTH: usize = 12;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -14,41 +14,69 @@ pub struct LocalRepo {
|
|||||||
pub nip34: Option<Nip34Binding>,
|
pub nip34: Option<Nip34Binding>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn find_git_repos(root: &Path) -> Vec<LocalRepo> {
|
impl LocalRepo {
|
||||||
if !root.is_dir() {
|
/// Creates a local repository entry from its path and optional NIP-34 binding.
|
||||||
return Vec::new();
|
pub fn new(path: PathBuf, nip34: Option<Nip34Binding>) -> Self {
|
||||||
|
Self { path, nip34 }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let walker = WalkBuilder::new(root)
|
/// Finds git repositories under `root` up to `SCAN_MAX_DEPTH` deep, without descending into them.
|
||||||
|
pub fn find_git_repos(root: &Path) -> Vec<LocalRepo> {
|
||||||
|
let Ok(root) = root.canonicalize() else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
let found = Arc::new(Mutex::new(Vec::<PathBuf>::new()));
|
||||||
|
|
||||||
|
let walker = WalkBuilder::new(&root)
|
||||||
.max_depth(Some(SCAN_MAX_DEPTH))
|
.max_depth(Some(SCAN_MAX_DEPTH))
|
||||||
// Honour `.gitignore` even when the scan root is not itself a repository.
|
|
||||||
.require_git(false)
|
.require_git(false)
|
||||||
|
.filter_entry({
|
||||||
|
let found = found.clone();
|
||||||
|
move |entry| {
|
||||||
|
let Some(kind) = entry.file_type() else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
if !kind.is_dir() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dir = entry.path();
|
||||||
|
let Ok(repo) = gix::discover(dir) else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(workdir) = repo.workdir() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if workdir != dir {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
match found.lock() {
|
||||||
|
Ok(mut repos) => repos.push(dir.to_path_buf()),
|
||||||
|
Err(poisoned) => poisoned.into_inner().push(dir.to_path_buf()),
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
})
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let mut repos: Vec<PathBuf> = walker
|
for _ in walker {}
|
||||||
.flatten()
|
|
||||||
.filter(|entry| entry.file_type().is_some_and(|kind| kind.is_dir()))
|
|
||||||
.map(ignore::DirEntry::into_path)
|
|
||||||
.filter(|dir| dir.join(".git").exists())
|
|
||||||
.filter_map(|dir| dir.canonicalize().ok())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
|
let mut repos = match found.lock() {
|
||||||
|
Ok(mut repos) => std::mem::take(&mut *repos),
|
||||||
|
Err(poisoned) => std::mem::take(&mut *poisoned.into_inner()),
|
||||||
|
};
|
||||||
repos.sort();
|
repos.sort();
|
||||||
repos.dedup();
|
|
||||||
|
|
||||||
// A repository nested inside another, like a submodule worktree, is not reported.
|
repos
|
||||||
let mut roots: Vec<PathBuf> = Vec::with_capacity(repos.len());
|
|
||||||
for repo in repos {
|
|
||||||
if !roots.iter().any(|kept| repo.starts_with(kept)) {
|
|
||||||
roots.push(repo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
roots
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|path| {
|
.map(|path| {
|
||||||
let nip34 = Repo::open(&path).ok().and_then(|repo| repo.nip34_binding());
|
let nip34 = Repo::open(&path).ok().and_then(|repo| repo.nip34_binding());
|
||||||
LocalRepo { path, nip34 }
|
LocalRepo::new(path, nip34)
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ fn repo_ref_state_lists_branches_tags_and_head() {
|
|||||||
assert_eq!(state.refs.len(), 3);
|
assert_eq!(state.refs.len(), 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates a repository fixture with the given files.
|
||||||
fn fixture(files: &[(&str, &[u8])]) -> (tempfile::TempDir, Repo) {
|
fn fixture(files: &[(&str, &[u8])]) -> (tempfile::TempDir, Repo) {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
gix::init(&dir).expect("init");
|
gix::init(&dir).expect("init");
|
||||||
@@ -144,6 +145,7 @@ fn fixture(files: &[(&str, &[u8])]) -> (tempfile::TempDir, Repo) {
|
|||||||
(dir, repo)
|
(dir, repo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stages and commits every change in the repository.
|
||||||
fn commit_all(repo: &Repo, 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]);
|
||||||
@@ -415,6 +417,7 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Runs `git` in `dir`, failing the test on a non-zero exit.
|
||||||
fn git_run(dir: &Path, args: &[&str]) -> std::process::Output {
|
fn git_run(dir: &Path, args: &[&str]) -> std::process::Output {
|
||||||
let output = Command::new("git")
|
let output = Command::new("git")
|
||||||
.current_dir(dir)
|
.current_dir(dir)
|
||||||
@@ -430,6 +433,52 @@ fn git_run(dir: &Path, args: &[&str]) -> std::process::Output {
|
|||||||
output
|
output
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_git_repos_reports_the_outermost_repository() {
|
||||||
|
let root = tempfile::tempdir().expect("tempdir");
|
||||||
|
let repo_dir = root.path().join("project").join("sub");
|
||||||
|
std::fs::create_dir_all(&repo_dir).expect("mkdir");
|
||||||
|
git_run(&repo_dir, &["init", "-q"]);
|
||||||
|
|
||||||
|
let plain_dir = root.path().join("plain").join("deep");
|
||||||
|
std::fs::create_dir_all(&plain_dir).expect("mkdir");
|
||||||
|
|
||||||
|
let repos = find_git_repos(root.path());
|
||||||
|
|
||||||
|
assert_eq!(repos.len(), 1);
|
||||||
|
assert_eq!(repos[0].path, repo_dir.canonicalize().expect("canonical"));
|
||||||
|
assert!(repos[0].nip34.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_git_repos_skips_repositories_nested_in_repositories() {
|
||||||
|
let root = tempfile::tempdir().expect("tempdir");
|
||||||
|
let outer = root.path().join("outer");
|
||||||
|
std::fs::create_dir_all(&outer).expect("mkdir");
|
||||||
|
git_run(&outer, &["init", "-q"]);
|
||||||
|
|
||||||
|
let nested = outer.join("nested");
|
||||||
|
std::fs::create_dir_all(&nested).expect("mkdir");
|
||||||
|
git_run(&nested, &["init", "-q"]);
|
||||||
|
|
||||||
|
let repos = find_git_repos(root.path());
|
||||||
|
|
||||||
|
assert_eq!(repos.len(), 1);
|
||||||
|
assert_eq!(repos[0].path, outer.canonicalize().expect("canonical"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_git_repos_ignores_a_repository_above_the_scan_root() {
|
||||||
|
let outer = tempfile::tempdir().expect("tempdir");
|
||||||
|
git_run(outer.path(), &["init", "-q"]);
|
||||||
|
|
||||||
|
let root = outer.path().join("scan");
|
||||||
|
std::fs::create_dir_all(&root).expect("mkdir");
|
||||||
|
|
||||||
|
assert!(find_git_repos(&root).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates an empty bare server repository for push tests.
|
||||||
fn bare_server(base: &Path, owner: &str, name: &str) -> PathBuf {
|
fn bare_server(base: &Path, owner: &str, name: &str) -> PathBuf {
|
||||||
let repo = base.join(owner).join(format!("{name}.git"));
|
let repo = base.join(owner).join(format!("{name}.git"));
|
||||||
std::fs::create_dir_all(repo.parent().expect("parent")).expect("mkdir");
|
std::fs::create_dir_all(repo.parent().expect("parent")).expect("mkdir");
|
||||||
|
|||||||
@@ -1,18 +1,11 @@
|
|||||||
use std::collections::HashSet;
|
use std::path::PathBuf;
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use gix::progress::Discard;
|
|
||||||
|
|
||||||
use crate::GixResultExt as _;
|
|
||||||
use crate::history::FileCommit;
|
use crate::history::FileCommit;
|
||||||
use crate::repo::Repo;
|
|
||||||
|
|
||||||
pub struct WorktreeSnapshot {
|
pub struct WorktreeSnapshot {
|
||||||
pub entries: Vec<PathBuf>,
|
pub entries: Vec<PathBuf>,
|
||||||
pub readme_path: Option<PathBuf>,
|
pub readme_path: Option<PathBuf>,
|
||||||
pub readme: Option<Vec<u8>>,
|
pub readme: Option<Vec<u8>>,
|
||||||
// `None` when detached, for example on a tag.
|
|
||||||
pub current_branch: Option<String>,
|
pub current_branch: Option<String>,
|
||||||
pub head_commit: Option<FileCommit>,
|
pub head_commit: Option<FileCommit>,
|
||||||
pub branches: Vec<String>,
|
pub branches: Vec<String>,
|
||||||
@@ -20,7 +13,8 @@ pub struct WorktreeSnapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl WorktreeSnapshot {
|
impl WorktreeSnapshot {
|
||||||
fn new(
|
/// Creates a snapshot of the worktree contents and ref state.
|
||||||
|
pub(crate) fn new(
|
||||||
entries: Vec<PathBuf>,
|
entries: Vec<PathBuf>,
|
||||||
readme_path: Option<PathBuf>,
|
readme_path: Option<PathBuf>,
|
||||||
readme: Option<Vec<u8>>,
|
readme: Option<Vec<u8>>,
|
||||||
@@ -40,287 +34,3 @@ impl WorktreeSnapshot {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Repo {
|
|
||||||
pub fn is_dirty(&self) -> bool {
|
|
||||||
match self.inner.is_dirty() {
|
|
||||||
Ok(true) => return true,
|
|
||||||
Ok(false) => {}
|
|
||||||
Err(_) => return false,
|
|
||||||
}
|
|
||||||
|
|
||||||
let Ok(platform) = self.inner.status(Discard) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
let Ok(mut changes) = platform.into_index_worktree_iter(Vec::<gix::bstr::BString>::new())
|
|
||||||
else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
for change in changes.by_ref() {
|
|
||||||
match change {
|
|
||||||
Ok(gix::status::index_worktree::Item::DirectoryContents { .. }) => return true,
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(_) => return false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn commits_ahead(&self, base: &str, branch: &str) -> u32 {
|
|
||||||
let (Some(base), Some(branch)) = (self.resolve_commit(base), self.resolve_commit(branch))
|
|
||||||
else {
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
let Ok(walk) = self.inner.rev_walk([branch]).with_hidden([base]).all() else {
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
walk.filter_map(Result::ok).count().min(u32::MAX as usize) as u32
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_commit<'a>(&'a self, rev: &str) -> Option<gix::Id<'a>> {
|
|
||||||
self.inner.rev_parse_single(rev.as_bytes()).ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn entries(&self) -> Result<Vec<PathBuf>> {
|
|
||||||
let workdir = self.inner.workdir().context("repository has no worktree")?;
|
|
||||||
|
|
||||||
let mut entries: Vec<(PathBuf, bool)> = Vec::new();
|
|
||||||
Self::collect_entries(workdir, workdir, &mut entries)?;
|
|
||||||
|
|
||||||
entries.sort_by(|(a, a_is_dir), (b, b_is_dir)| {
|
|
||||||
b_is_dir
|
|
||||||
.cmp(a_is_dir)
|
|
||||||
.then_with(|| a.as_os_str().cmp(b.as_os_str()))
|
|
||||||
});
|
|
||||||
Ok(entries.into_iter().map(|(path, _)| path).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn read(&self, rel: &Path) -> Result<Option<Vec<u8>>> {
|
|
||||||
let workdir = self.inner.workdir().context("repository has no worktree")?;
|
|
||||||
let path = workdir.join(rel);
|
|
||||||
|
|
||||||
match std::fs::read(&path) {
|
|
||||||
Ok(bytes) => Ok(Some(bytes)),
|
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::IsADirectory => Ok(None),
|
|
||||||
Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn find_readme(&self) -> Result<Option<PathBuf>> {
|
|
||||||
let Some(workdir) = self.inner.workdir() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut candidates: Vec<PathBuf> = Vec::new();
|
|
||||||
for entry in std::fs::read_dir(workdir)? {
|
|
||||||
let entry = entry?;
|
|
||||||
let name = entry.file_name();
|
|
||||||
let Some(name) = name.to_str() else { continue };
|
|
||||||
if name.to_ascii_lowercase().starts_with("readme") {
|
|
||||||
candidates.push(entry.path());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
candidates.sort_by_key(|path| {
|
|
||||||
let ext = path
|
|
||||||
.extension()
|
|
||||||
.map(|e| e.to_string_lossy().to_ascii_lowercase());
|
|
||||||
match ext.as_deref() {
|
|
||||||
Some("md") => 0,
|
|
||||||
Some("markdown") => 1,
|
|
||||||
Some("mdown") => 2,
|
|
||||||
Some("mkdn") => 3,
|
|
||||||
Some(_) => 5,
|
|
||||||
None => 4,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(candidates
|
|
||||||
.into_iter()
|
|
||||||
.next()
|
|
||||||
.and_then(|path| path.strip_prefix(workdir).ok().map(Path::to_path_buf)))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn snapshot(&self) -> Result<WorktreeSnapshot> {
|
|
||||||
let readme_path = self.find_readme()?;
|
|
||||||
let readme = match &readme_path {
|
|
||||||
Some(path) => self.read(path)?,
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
Ok(WorktreeSnapshot::new(
|
|
||||||
self.entries()?,
|
|
||||||
readme_path,
|
|
||||||
readme,
|
|
||||||
self.current_branch(),
|
|
||||||
self.head_commit().unwrap_or(None),
|
|
||||||
self.branches().unwrap_or_default(),
|
|
||||||
self.tags().unwrap_or_default(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn checkout_branch(&self, name: &str) -> Result<()> {
|
|
||||||
let full = format!("refs/heads/{name}");
|
|
||||||
|
|
||||||
let branch = gix::refs::FullName::try_from(full.as_str())
|
|
||||||
.map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?;
|
|
||||||
|
|
||||||
let mut reference = self.inner.find_reference(&full)?;
|
|
||||||
let tree = reference.peel_to_tree()?.id;
|
|
||||||
|
|
||||||
let (signature, mut time_buf) = Self::repository_signature();
|
|
||||||
let signature = signature.to_ref(&mut time_buf);
|
|
||||||
|
|
||||||
self.move_head(
|
|
||||||
signature,
|
|
||||||
gix::refs::Target::Symbolic(branch),
|
|
||||||
&format!("checkout: moving to {name}"),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
self.force_checkout(&tree)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn checkout_tag(&self, name: &str) -> Result<()> {
|
|
||||||
let full = format!("refs/tags/{name}");
|
|
||||||
|
|
||||||
let mut reference = self.inner.find_reference(&full)?;
|
|
||||||
|
|
||||||
let commit = reference.peel_to_id()?;
|
|
||||||
let tree = reference.peel_to_tree()?.id;
|
|
||||||
|
|
||||||
let (signature, mut time_buf) = Self::repository_signature();
|
|
||||||
let signature = signature.to_ref(&mut time_buf);
|
|
||||||
|
|
||||||
self.move_head(
|
|
||||||
signature,
|
|
||||||
gix::refs::Target::Object(commit.detach()),
|
|
||||||
&format!("checkout: moving to {name}"),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
self.force_checkout(&tree)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn force_checkout(&self, tree: &gix::hash::oid) -> Result<()> {
|
|
||||||
let workdir = self
|
|
||||||
.inner
|
|
||||||
.workdir()
|
|
||||||
.context("repository has no worktree")?
|
|
||||||
.to_path_buf();
|
|
||||||
|
|
||||||
let mut index = self.inner.index_from_tree(tree)?;
|
|
||||||
|
|
||||||
if let Ok(previous) = self.inner.index_or_empty() {
|
|
||||||
let keep: HashSet<PathBuf> = index
|
|
||||||
.entries()
|
|
||||||
.iter()
|
|
||||||
.map(|entry| {
|
|
||||||
PathBuf::from(String::from_utf8_lossy(entry.path(&index)).into_owned())
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
for entry in previous.entries() {
|
|
||||||
let rel = entry.path(&previous);
|
|
||||||
let rel = PathBuf::from(String::from_utf8_lossy(rel).into_owned());
|
|
||||||
|
|
||||||
if keep.contains(&rel) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let path = workdir.join(&rel);
|
|
||||||
|
|
||||||
match std::fs::remove_file(&path) {
|
|
||||||
Ok(()) => {}
|
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
|
||||||
Err(error) => {
|
|
||||||
return Err(error)
|
|
||||||
.with_context(|| format!("failed to remove {}", path.display()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut options = self
|
|
||||||
.inner
|
|
||||||
.checkout_options(gix_worktree::stack::state::attributes::Source::IdMapping)?;
|
|
||||||
options.overwrite_existing = true;
|
|
||||||
|
|
||||||
let objects = self.inner.objects.clone().into_arc()?;
|
|
||||||
let files = gix::progress::Discard;
|
|
||||||
let bytes = gix::progress::Discard;
|
|
||||||
|
|
||||||
gix_worktree_state::checkout(
|
|
||||||
&mut index,
|
|
||||||
workdir,
|
|
||||||
objects,
|
|
||||||
&files,
|
|
||||||
&bytes,
|
|
||||||
&gix::interrupt::IS_INTERRUPTED,
|
|
||||||
options,
|
|
||||||
)
|
|
||||||
.into_anyhow()?;
|
|
||||||
|
|
||||||
index
|
|
||||||
.write(gix::index::write::Options::default())
|
|
||||||
.into_anyhow()?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn move_head(
|
|
||||||
&self,
|
|
||||||
signature: gix::actor::SignatureRef<'_>,
|
|
||||||
target: gix::refs::Target,
|
|
||||||
message: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
|
|
||||||
|
|
||||||
let head = gix::refs::FullName::try_from("HEAD")
|
|
||||||
.map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?;
|
|
||||||
|
|
||||||
self.inner.edit_references_as(
|
|
||||||
[RefEdit {
|
|
||||||
change: Change::Update {
|
|
||||||
log: LogChange {
|
|
||||||
mode: RefLog::AndReference,
|
|
||||||
force_create_reflog: false,
|
|
||||||
message: message.into(),
|
|
||||||
},
|
|
||||||
expected: PreviousValue::Any,
|
|
||||||
new: target,
|
|
||||||
},
|
|
||||||
name: head,
|
|
||||||
deref: false,
|
|
||||||
}],
|
|
||||||
Some(signature),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn collect_entries(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, bool)>) -> Result<()> {
|
|
||||||
for entry in std::fs::read_dir(dir)? {
|
|
||||||
let entry = entry?;
|
|
||||||
if entry.file_name() == ".git" {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let is_dir = entry.file_type()?.is_dir();
|
|
||||||
let path = entry.path();
|
|
||||||
let rel = path.strip_prefix(root)?.to_path_buf();
|
|
||||||
out.push((rel, is_dir));
|
|
||||||
|
|
||||||
if is_dir {
|
|
||||||
Self::collect_entries(root, &path, out)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user