add nip34 detector
This commit is contained in:
Generated
+2
@@ -7981,6 +7981,8 @@ dependencies = [
|
||||
"gix-worktree-state",
|
||||
"ignore",
|
||||
"nostr",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"signed_core",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
@@ -8,6 +8,8 @@ publish.workspace = true
|
||||
signed_core = { path = "../signed_core" }
|
||||
|
||||
nostr.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
gix = { workspace = true, features = ["revision", "blob-diff"] }
|
||||
gix-worktree = "0.56"
|
||||
gix-worktree-state = "0.34"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
mod cache;
|
||||
mod diff;
|
||||
mod history;
|
||||
mod nip34;
|
||||
mod patch;
|
||||
mod remote;
|
||||
mod repo;
|
||||
@@ -19,6 +20,7 @@ pub use history::{
|
||||
CommitList, FileCommit, MAX_LISTED_COMMITS, all_commits, head_commit, worktree_all_commits,
|
||||
worktree_commit, worktree_commit_range_commits, worktree_last_commits,
|
||||
};
|
||||
pub use nip34::{GraspSignals, Nip34Binding, Nip34Kind, detect_nip34, is_grasp_url};
|
||||
pub use patch::{
|
||||
apply_patch, format_patch_between, patch_commits, patch_diffs, split_patch_series,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
use std::path::Path;
|
||||
|
||||
use gix::bstr::ByteSlice;
|
||||
use nostr::prelude::*;
|
||||
|
||||
/// The kind of NIP-34 relationship a local repository has on disk.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Nip34Kind {
|
||||
/// Bound to a NIP-34 coordinate, by `nak`'s `nip34.json` or `ngit`'s `nostr.repo`.
|
||||
Initialized,
|
||||
/// Cloned from a `nostr://` remote but never initialized locally.
|
||||
Cloned,
|
||||
/// Nostr tooling touched the repository but no binding is recoverable.
|
||||
ToolingOnly,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct GraspSignals {
|
||||
pub nip34_json: bool,
|
||||
pub nip34_excluded: bool,
|
||||
pub nostr_repo_config: bool,
|
||||
pub nostr_remote: bool,
|
||||
pub grasp_remote: bool,
|
||||
/// `nip34_grasp_remote` is the `nak`-specific `nip34/grasp/<host>` remote name.
|
||||
pub nip34_grasp_remote: bool,
|
||||
pub nip34_state_refs: bool,
|
||||
pub nostr_cache: bool,
|
||||
pub nostr_aux_config: bool,
|
||||
pub maintainers_yaml: bool,
|
||||
}
|
||||
|
||||
impl GraspSignals {
|
||||
pub fn any(&self) -> bool {
|
||||
*self != Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// What a local repository's on-disk state says about its NIP-34 binding.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Nip34Binding {
|
||||
pub kind: Nip34Kind,
|
||||
pub signals: GraspSignals,
|
||||
/// Coordinate owner and identifier, from `nip34.json` or `nostr.repo`.
|
||||
pub owner: Option<PublicKey>,
|
||||
pub identifier: Option<String>,
|
||||
pub grasp_urls: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Nip34Json {
|
||||
identifier: Option<String>,
|
||||
owner: Option<String>,
|
||||
}
|
||||
|
||||
pub fn detect_nip34(repo_path: &Path) -> Option<Nip34Binding> {
|
||||
let repo = gix::open(repo_path).ok()?;
|
||||
let common_dir = repo.common_dir().to_path_buf();
|
||||
let workdir = repo.workdir().map(Path::to_path_buf);
|
||||
|
||||
let mut signals = GraspSignals::default();
|
||||
let mut owner: Option<PublicKey> = None;
|
||||
let mut identifier: Option<String> = None;
|
||||
let mut grasp_urls: Vec<String> = Vec::new();
|
||||
|
||||
if let Some(workdir) = &workdir {
|
||||
if let Ok(bytes) = std::fs::read(workdir.join("nip34.json"))
|
||||
&& let Ok(config) = serde_json::from_slice::<Nip34Json>(&bytes)
|
||||
{
|
||||
signals.nip34_json = true;
|
||||
identifier = config.identifier.and_then(non_empty);
|
||||
owner = config
|
||||
.owner
|
||||
.as_deref()
|
||||
.and_then(|value| PublicKey::parse(value).ok());
|
||||
}
|
||||
|
||||
if workdir.join("maintainers.yaml").is_file() {
|
||||
signals.maintainers_yaml = true;
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(exclude) = std::fs::read_to_string(common_dir.join("info/exclude"))
|
||||
&& exclude.contains("nip34.json")
|
||||
{
|
||||
signals.nip34_excluded = true;
|
||||
}
|
||||
|
||||
// `ngit` keeps its repository event cache in the Git common directory.
|
||||
if common_dir.join("nostr-cache.lmdb").is_file() {
|
||||
signals.nostr_cache = true;
|
||||
}
|
||||
|
||||
// `ngit` reads and writes `nostr.repo` at repository-local scope only.
|
||||
if let Ok(config) = gix::config::File::from_path_no_includes(
|
||||
common_dir.join("config"),
|
||||
gix::config::Source::Local,
|
||||
) {
|
||||
if let Some(value) = config.string("nostr.repo")
|
||||
&& let Some((key, id)) = coordinate_from_naddr(&value.to_str_lossy())
|
||||
{
|
||||
signals.nostr_repo_config = true;
|
||||
owner = Some(key);
|
||||
identifier = Some(id);
|
||||
}
|
||||
|
||||
for key in ["nostr.repo-relay-only", "nostr.nostate", "nostr.private"] {
|
||||
if config.string(key).is_some() {
|
||||
signals.nostr_aux_config = true;
|
||||
}
|
||||
}
|
||||
|
||||
if 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)) = parse_nostr_url(&url)
|
||||
{
|
||||
owner = Some(key);
|
||||
identifier = Some(id);
|
||||
}
|
||||
}
|
||||
|
||||
if is_grasp_url(&url) {
|
||||
signals.grasp_remote = true;
|
||||
signals.nip34_grasp_remote |= nak_grasp_remote;
|
||||
grasp_urls.push(url.to_string());
|
||||
|
||||
if owner.is_none()
|
||||
&& identifier.is_none()
|
||||
&& let Some((key, id)) = grasp_parts(&url)
|
||||
{
|
||||
owner = Some(key);
|
||||
identifier = Some(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `nak` materializes a kind-30618 state as `refs/heads/nip34/state/*`.
|
||||
if let Ok(platform) = repo.references()
|
||||
&& let Ok(mut refs) = platform.prefixed(b"refs/heads/nip34/state/")
|
||||
&& refs.next().is_some()
|
||||
{
|
||||
signals.nip34_state_refs = true;
|
||||
}
|
||||
|
||||
if !signals.any() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let kind = if signals.nip34_json
|
||||
|| signals.nostr_repo_config
|
||||
|| signals.nip34_grasp_remote
|
||||
|| signals.nip34_state_refs
|
||||
{
|
||||
Nip34Kind::Initialized
|
||||
} else if signals.nostr_remote {
|
||||
Nip34Kind::Cloned
|
||||
} else {
|
||||
Nip34Kind::ToolingOnly
|
||||
};
|
||||
|
||||
Some(Nip34Binding {
|
||||
kind,
|
||||
signals,
|
||||
owner,
|
||||
identifier,
|
||||
grasp_urls,
|
||||
})
|
||||
}
|
||||
|
||||
/// Mirrors `nak`'s `IsGraspURL`: two path segments, a path of at least 65 bytes,
|
||||
/// and a first segment that decodes as an `npub`.
|
||||
pub fn is_grasp_url(url: &str) -> bool {
|
||||
let Ok(parsed) = Url::parse(url) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if !matches!(parsed.scheme(), "http" | "https" | "grasp") {
|
||||
return false;
|
||||
}
|
||||
|
||||
let path = parsed.path();
|
||||
if path.matches('/').count() != 2 || path.len() < 65 {
|
||||
return false;
|
||||
}
|
||||
|
||||
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 = 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 = non_empty(coordinate.identifier.clone())?;
|
||||
Some((coordinate.public_key, identifier))
|
||||
}
|
||||
|
||||
/// Handles a bare `naddr`, an `npub`, and the optional `[ssh-key-file@]`,
|
||||
/// `[protocol/]` and `[relay/]` components. An `nip05` owner yields no binding.
|
||||
fn parse_nostr_url(url: &str) -> Option<(PublicKey, String)> {
|
||||
let rest = url.strip_prefix("nostr://")?;
|
||||
|
||||
if rest.starts_with("naddr1") {
|
||||
return coordinate_from_naddr(rest);
|
||||
}
|
||||
|
||||
let rest = rest.rsplit_once('@').map_or(rest, |(_, after)| after);
|
||||
let mut parts: Vec<&str> = rest.split('/').filter(|part| !part.is_empty()).collect();
|
||||
|
||||
if parts
|
||||
.first()
|
||||
.is_some_and(|first| matches!(*first, "ssh" | "https" | "http"))
|
||||
{
|
||||
parts.remove(0);
|
||||
}
|
||||
|
||||
// `[owner, (relay), identifier]`.
|
||||
if parts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let owner = PublicKey::parse(parts[0]).ok()?;
|
||||
let identifier = non_empty(parts.last()?.trim_end_matches(".git"))?;
|
||||
|
||||
Some((owner, identifier))
|
||||
}
|
||||
|
||||
fn non_empty(value: impl Into<String>) -> Option<String> {
|
||||
let value = value.into();
|
||||
(!value.is_empty()).then_some(value)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::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")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_repository_has_no_binding() {
|
||||
let (_dir, path) = init_repo();
|
||||
assert!(detect_nip34(&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 = detect_nip34(&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!(detect_nip34(&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 = detect_nip34(&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 = detect_nip34(&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 = detect_nip34(&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 = detect_nip34(&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 = detect_nip34(&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!(is_grasp_url(&format!(
|
||||
"https://gitnostr.com/{npub}/my-repo.git"
|
||||
)));
|
||||
assert!(is_grasp_url(&format!(
|
||||
"grasp://gitnostr.com/{npub}/my-repo.git"
|
||||
)));
|
||||
|
||||
assert!(!is_grasp_url("https://gitnostr.com/my-repo.git"));
|
||||
assert!(!is_grasp_url(
|
||||
"https://gitnostr.com/not-a-pubkey/my-repo.git"
|
||||
));
|
||||
assert!(!is_grasp_url(&format!(
|
||||
"ssh://gitnostr.com/{npub}/my-repo.git"
|
||||
)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
# Local repository NIP-34 detection
|
||||
|
||||
Status: plan, not yet implemented.
|
||||
|
||||
## Motivation
|
||||
|
||||
`LocalReposStore::rescan` (`crates/signed_state/src/local_repos.rs`) walks the configured scan
|
||||
roots with `find_git_repos` (`crates/signed_git/src/scan.rs`) and returns every directory that
|
||||
contains a `.git` entry. It has no idea whether the repository was already bound to NIP-34 by
|
||||
another tool (nak, ngit) or by Signed itself.
|
||||
|
||||
Today the sidebar works around this by matching the repository folder name against the signed-in
|
||||
user's own announcements (`crates/workspace/src/views/sidebar/mod.rs`, in `refresh`). That is
|
||||
fragile: it only recognises repositories the user already announced, it depends on the folder name
|
||||
happening to sanitize to the identifier, and it never notices repositories initialized by other
|
||||
tooling.
|
||||
|
||||
The goal is for every scanned repository to carry a **NIP-34 binding** (or none), so the UI can
|
||||
label it, link it to its announcement, and stop offering to publish something that is already
|
||||
published.
|
||||
|
||||
## Decisions taken
|
||||
|
||||
1. **Open as the announced repository.** When a local repository's detected binding resolves to a
|
||||
coordinate that matches a known announcement, clicking it opens the announced repository (with
|
||||
the local worktree attached), not a local-only detail view.
|
||||
2. **Cloned is its own visible state.** A repository cloned from a `nostr://` remote but never
|
||||
initialized locally is a distinct state from both a plain repository and an initialized one.
|
||||
|
||||
## Detection signals
|
||||
|
||||
nak and ngit use completely different on-disk conventions. There is no shared marker, so both must
|
||||
be recognised. Everything below is verified against ngit v3.0.1 and nak `master`.
|
||||
|
||||
| # | Signal | Exact location | Meaning | Tool | Strength |
|
||||
|---|--------|----------------|---------|------|----------|
|
||||
| 1 | `nip34.json` | `<worktree>/nip34.json` | Repo initialized. JSON fields: `identifier`, `name`, `description`, `owner`, `grasp-servers[]`, `earliest-unique-commit` | nak | Strong; yields owner + identifier |
|
||||
| 2 | `nip34.json` line | `<git-common-dir>/info/exclude` | Corroborates #1 (nak hides the file this way) | nak | Corroborating |
|
||||
| 3 | `refs/heads/nip34/state/HEAD` and `refs/heads/nip34/state/<branch>` | refs | nak materialized a kind-30618 state | nak | Strong |
|
||||
| 4 | Remote `nip34/grasp/<host>` | `.git/config`: `remote.nip34/grasp/<host>.url` = `https://<host>/<npub>/<id>.git` | nak `gitSetupRemotes` | nak | Strong; owner + id from the URL |
|
||||
| 5 | `nostr.repo` = `naddr1…` (kind 30617) | **local** git config | ngit bound the repo to a coordinate | ngit | Strong; yields the coordinate |
|
||||
| 6 | Remote URL `nostr://…` | `.git/config` | ngit init, or a plain `git clone nostr://…` | ngit | Medium; init **or** clone |
|
||||
| 7 | `nostr-cache.lmdb` | `<git-common-dir>/nostr-cache.lmdb` | ngit has run here (also on clone) | ngit | Weak; touched only |
|
||||
| 8 | `nostr.repo-relay-only`, `nostr.nostate`, `nostr.private` | local git config | ngit auxiliary flags | ngit | Weak |
|
||||
| 9 | Grasp-shaped remote `https://<host>/<npub>/<id>.git` (or `grasp://…`) | `.git/config` | Some client registered a grasp remote | unknown | Medium |
|
||||
| 10 | `maintainers.yaml` | `<worktree>/maintainers.yaml` | ngit multi-maintainer config | ngit | Weak |
|
||||
|
||||
Notes:
|
||||
|
||||
- ngit has **no** `nip34.json`, and nak has **no** `nostr.repo` config key. The two conventions are
|
||||
disjoint, so seeing both is essentially impossible; if it happens, prefer #1/#5 (a real binding)
|
||||
for the coordinate and record both evidence flags.
|
||||
- `nostr.repo` is written and read at **local** scope by ngit, so detection must read the local
|
||||
config only, never the merged/global view.
|
||||
- The grasp URL shape mirrors nak's `IsGraspURL`: exactly two `/` in the path (two segments), total
|
||||
path length ≥ 65, and the first 63 characters after the leading `/` decode as an `npub`. The
|
||||
identifier segment conventionally ends in `.git`.
|
||||
- Signed's own clone URLs use the `grasp://` scheme (see `transport_url` in
|
||||
`crates/signed_git/src/remote.rs`), so the shape check must accept `grasp://` too.
|
||||
- A disk-only check cannot prove a repository was *announced*; ngit explicitly has a
|
||||
"coordinate set, no announcement on relays" state. Detection answers "is this repository bound
|
||||
to NIP-34", and the relay lookup stays a separate layer.
|
||||
|
||||
## Classification
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Scanned repo] --> B{nip34.json parses?}
|
||||
B -- yes --> INIT[NIP-34 initialized<br/>nak, owner+id known]
|
||||
B -- no --> C{nostr.repo decodes<br/>as kind-30617 naddr?}
|
||||
C -- yes --> INIT2[NIP-34 initialized<br/>ngit, coordinate known]
|
||||
C -- no --> D{nip34/grasp remote<br/>or nip34/state refs?}
|
||||
D -- yes --> INIT3[NIP-34 initialized<br/>nak, id from remote URL]
|
||||
D -- no --> E{nostr:// remote only?}
|
||||
E -- yes --> CLONE[NIP-34 clone<br/>derive owner+id from URL]
|
||||
E -- no --> F{lmdb / aux config /<br/>grasp-shaped remote only?}
|
||||
F -- yes --> TOOL[Nostr tooling seen<br/>binding unclear]
|
||||
F -- no --> PLAIN[Plain local repository]
|
||||
```
|
||||
|
||||
Precedence, in order:
|
||||
|
||||
1. #1 `nip34.json` parses → `Initialized`, owner + identifier known (nak).
|
||||
2. #5 `nostr.repo` decodes as a kind-30617 `naddr` → `Initialized`, coordinate known (ngit).
|
||||
3. #3 or #4, without #1/#2 → `Initialized` (nak); derive owner + identifier from the
|
||||
`nip34/grasp/<host>` remote URL when present.
|
||||
4. Only #6 `nostr://` remote → `Cloned`; derive owner + identifier from the URL when parseable.
|
||||
5. Only #7, #8, #9 or #10 → `ToolingOnly`.
|
||||
6. Nothing → `None`.
|
||||
|
||||
## Data model
|
||||
|
||||
Detection lives in `signed_git` next to the other git plumbing (`scan.rs`, `remote.rs`). It reports
|
||||
raw on-disk facts plus the nostr identity it can recover; mapping to `RepoAddr` and matching against
|
||||
announcements happens in `signed_state`, where `RepoAddr` and the announcement list live.
|
||||
|
||||
New file `crates/signed_git/src/nip34.rs`:
|
||||
|
||||
```rust
|
||||
use std::path::Path;
|
||||
|
||||
use nostr::PublicKey;
|
||||
|
||||
/// The kind of NIP-34 relationship a local repository has on disk.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Nip34Kind {
|
||||
/// Bound to a NIP-34 coordinate (nak `nip34.json` or ngit `nostr.repo`).
|
||||
Initialized,
|
||||
/// Cloned from a `nostr://` remote but never initialized locally.
|
||||
Cloned,
|
||||
/// Nostr tooling touched the repository but no binding is recoverable.
|
||||
ToolingOnly,
|
||||
}
|
||||
|
||||
/// `nip34_grasp_remote` is the nak-specific `nip34/grasp/<host>` remote name (#4),
|
||||
/// while `grasp_remote` covers any grasp-shaped remote URL (#9).
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct GraspSignals {
|
||||
pub nip34_json: bool,
|
||||
pub nip34_excluded: bool,
|
||||
pub nostr_repo_config: bool,
|
||||
pub nostr_remote: bool,
|
||||
pub grasp_remote: bool,
|
||||
pub nip34_grasp_remote: bool,
|
||||
pub nip34_state_refs: bool,
|
||||
pub nostr_cache: bool,
|
||||
pub nostr_aux_config: bool,
|
||||
pub maintainers_yaml: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Nip34Binding {
|
||||
pub kind: Nip34Kind,
|
||||
pub signals: GraspSignals,
|
||||
/// Coordinate owner and identifier, from #1 or #5.
|
||||
pub owner: Option<PublicKey>,
|
||||
pub identifier: Option<String>,
|
||||
pub grasp_urls: Vec<String>,
|
||||
}
|
||||
|
||||
/// Inspect a repository's on-disk state.
|
||||
///
|
||||
/// `None` for a plain repository. Best-effort: unreadable or malformed input is
|
||||
/// treated as a missing signal, never an error, so a bad repository cannot fail a scan.
|
||||
pub fn detect_nip34(repo_path: &Path) -> Option<Nip34Binding>;
|
||||
|
||||
/// Whether `url` has the grasp URL shape, `[https|http|grasp]://<host>/<npub>/<id>.git`.
|
||||
pub fn is_grasp_url(url: &str) -> bool;
|
||||
```
|
||||
|
||||
Implementation notes:
|
||||
|
||||
- Use **gix** (`gix::open`, `config::File::from_path_no_includes`, remote config sections,
|
||||
`references().prefixed`), not `git` subprocesses: the scan walks many repositories on a
|
||||
background thread.
|
||||
- Worktree root via `repo.workdir()`; `.git`-relative paths (`info/exclude`,
|
||||
`nostr-cache.lmdb`) via `repo.common_dir()`.
|
||||
- Read `nostr.repo` from the **local** config file only (`repo.common_dir().join("config")`),
|
||||
mirroring ngit's own scope.
|
||||
- Parse `nip34.json` with `serde_json`. The `owner` field is an npub in nak's writer but its
|
||||
validator accepts hex too, so use `PublicKey::parse` (bech32 or hex).
|
||||
- Decode ngit's `naddr` with the nostr crate's NIP-19 coordinate decoder (`Nip19Coordinate`), and
|
||||
require kind 30617.
|
||||
- Add `serde` and `serde_json` to `crates/signed_git/Cargo.toml` (both are already
|
||||
workspace dependencies).
|
||||
|
||||
`crates/signed_git/src/scan.rs` returns richer entries:
|
||||
|
||||
```rust
|
||||
pub struct LocalRepo {
|
||||
pub path: PathBuf,
|
||||
/// `None` for a plain repository.
|
||||
pub nip34: Option<Nip34Binding>,
|
||||
}
|
||||
|
||||
pub fn find_git_repos(root: &Path) -> Vec<LocalRepo>;
|
||||
```
|
||||
|
||||
The existing dedup rules (nested repositories, canonicalize, sort) are unchanged; `detect_nip34`
|
||||
runs per kept path. Export `LocalRepo`, `Nip34Binding`, `Nip34Kind`, `GraspSignals` from
|
||||
`signed_git/src/lib.rs`.
|
||||
|
||||
## Action plan
|
||||
|
||||
### Phase 1 — `signed_git` detection (no app behavior change)
|
||||
|
||||
Implemented.
|
||||
|
||||
- [x] `crates/signed_git/Cargo.toml`: add `serde.workspace = true`, `serde_json.workspace = true`.
|
||||
- [x] `crates/signed_git/src/nip34.rs`: implement `detect_nip34`, `is_grasp_url`, the enums and
|
||||
structs above. Keep every file/config read fallible-but-ignored: a missing or malformed
|
||||
input clears only its own flag.
|
||||
- [x] `crates/signed_git/src/lib.rs`: add `mod nip34;` and re-export the public items.
|
||||
- [x] Tests in `crates/signed_git/src/nip34.rs` (see Testing).
|
||||
|
||||
### Phase 2 — thread `LocalRepo` through the state layer
|
||||
|
||||
- [ ] `crates/signed_git/src/scan.rs`: change `find_git_repos` to `Vec<LocalRepo>`; in the walk,
|
||||
`detect_nip34` each kept path.
|
||||
- [ ] `crates/signed_git/src/tests.rs`: update `find_git_repos_*` expectations to the new type
|
||||
(compare `.path`).
|
||||
- [ ] `crates/signed_state/src/local_repos.rs`: `repos: Arc<Vec<LocalRepo>>`; `remove(&path)`
|
||||
filters on `.path`; the background scan already returns the richer vector unchanged.
|
||||
- [ ] `crates/signed_state/src/checkouts.rs` (~`run_refresh`): iterate `.path` instead of bare
|
||||
paths when collecting `scanned`.
|
||||
- [ ] `crates/signed_state/src/lib.rs`: re-export `LocalRepo`, `Nip34Binding`, `Nip34Kind` (and
|
||||
`GraspSignals` if the UI needs the flags).
|
||||
- [ ] `crates/signed_state/src/local_repos.rs`: add a helper that resolves a binding to a
|
||||
`RepoAddr` (`signed_core::repo_addr(owner, identifier)`) when both are known.
|
||||
|
||||
### Phase 3 — sidebar: derive the local list and link to announcements
|
||||
|
||||
File: `crates/workspace/src/views/sidebar/mod.rs`.
|
||||
|
||||
- [ ] Replace the folder-name dedupe in `refresh` with a resolution pass over
|
||||
`LocalReposStore::global(cx).read(cx).repos`:
|
||||
- For each entry, compute the detected `RepoAddr` when the binding is `Initialized` with
|
||||
owner + identifier.
|
||||
- Look the address up in `RepoListStore` announcements.
|
||||
- If it matches an announcement **already shown** in the sidebar's list (the signed-in
|
||||
user's own), drop it from the local list to avoid showing it twice.
|
||||
- If it matches an announcement that is not in the shown list, keep it as a local entry
|
||||
rendered with the "initialized" badge; clicking opens the announced repository (decision 1).
|
||||
- Otherwise keep it with a badge derived from the binding kind.
|
||||
- [ ] Extend the sidebar's local entry model from `Vec<PathBuf>` to a small struct carrying the
|
||||
path, the binding kind, the tool label and the resolved address, so `render_repo_at` can
|
||||
render the badge and route clicks.
|
||||
- [ ] Add the badge rendering to the local row in `render_repo_at` (three visible states):
|
||||
- `Initialized` → "NIP-34 · nak" / "NIP-34 · ngit"
|
||||
- `Cloned` → "NIP-34 clone" (decision 2, its own visible state)
|
||||
- `ToolingOnly` → "Nostr tooling" (muted)
|
||||
- plain → unchanged.
|
||||
|
||||
### Phase 4 — detail view: open as announced, gate the publish CTA
|
||||
|
||||
- [ ] `crates/signed_state/src/repo.rs`: add a constructor that keeps the worktree path while in
|
||||
NIP-34 mode, e.g. `RepoStore::from_worktree(addr, announcement, path, cx)`, or a small
|
||||
`set_path`. `RepoStore::announce` already keeps an existing path, so this can be built from
|
||||
`new` plus assigning the path. Opening a repository must **not** remove it from
|
||||
`LocalReposStore` (that removal belongs to `apply_announcement`, which only runs on a real
|
||||
publish).
|
||||
- [ ] `crates/workspace/src/views/repo/mod.rs`: add `RepoDetailView::new_local_announced(...)`
|
||||
(or an `Option<Announcement>` parameter on `new_local`) that builds the announced store with
|
||||
the local worktree attached, then `new_common`.
|
||||
- [ ] `crates/workspace/src/views/sidebar/mod.rs`: route local-entry clicks through the new
|
||||
constructor when an announcement matched, and through `open_local_repo` otherwise.
|
||||
- [ ] `crates/workspace/src/views/repo/mod.rs`: for a `new_local` view whose store carries an
|
||||
`Initialized` binding, suppress the "publish to NIP-34" call to action and instead show the
|
||||
owner and identifier. `Cloned` keeps the ability to be adopted/published.
|
||||
|
||||
### Phase 5 — optional: mark Signed's own publications
|
||||
|
||||
Signed's `publish_local_repo` (`crates/signed_state/src/backend.rs`) currently pushes by URL and
|
||||
writes no on-disk marker, so a repository Signed itself published is not detected on the next scan.
|
||||
Decide separately whether to write a marker on publish:
|
||||
|
||||
- ngit-compatible and least intrusive: `git config --local nostr.repo <naddr>`.
|
||||
- nak-compatible: write `nip34.json` and add it to `.git/info/exclude`.
|
||||
|
||||
Do not write both. This is a behavior change to a third-party convention and should be a
|
||||
deliberate, separate decision.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- Bare repositories are never scanned (no `.git` entry to match), so their absence as a worktree is
|
||||
not a problem. Linked worktrees have a `.git` file and are scanned; `gix::open` resolves them and
|
||||
`info/exclude`, `config` and `nostr-cache.lmdb` live in the shared common dir.
|
||||
- Submodules are already filtered out by the nested-path rule in `scan.rs`.
|
||||
- Detection must be cheap and side-effect-free: no network, no writes, no `git` subprocesses.
|
||||
- Multiple grasp servers produce multiple `nip34/grasp/*` remotes; use the first parseable one for
|
||||
the coordinate and keep all of them in `grasp_urls`.
|
||||
- Offline: matching against announcements may find nothing, but the disk badge still shows. Disk
|
||||
state is authoritative for "bound"; relay state only adds "and announced".
|
||||
- The scan runs on wasm with empty roots (`init` passes `Vec::new()`), so detection code must not
|
||||
assume a non-empty root list; no `cfg` gymnastics are needed since it is never reached.
|
||||
|
||||
## Testing
|
||||
|
||||
Unit tests in `crates/signed_git/src/nip34.rs` (use `tempfile` and a small local `git` helper):
|
||||
|
||||
- `nip34.json` with an npub owner → `Initialized`, owner + identifier parsed.
|
||||
- malformed `nip34.json` → falls back to no #1 signal, does not panic.
|
||||
- `nostr.repo` set to a kind-30617 `naddr` → `Initialized` with the matching coordinate.
|
||||
- `origin` = `nostr://npub…/relay/identifier` and no `nostr.repo` → `Cloned`.
|
||||
- remote `nip34/grasp/<host>` = `https://<host>/<npub>/<id>.git` → nak remote signal, owner + id
|
||||
recovered from the URL.
|
||||
- `refs/heads/nip34/state/HEAD` present and `nip34.json` in `.git/info/exclude` → nak state signals.
|
||||
- `.git/nostr-cache.lmdb` alone → `ToolingOnly`.
|
||||
- plain repository → `None`.
|
||||
- `is_grasp_url`: accept `https://host/npub1…/repo.git` and `grasp://host/npub1…/repo.git`; reject
|
||||
`https://host/repo.git` (no owner), a first segment that is not a valid `npub`, and an unrelated
|
||||
scheme such as `ssh://`.
|
||||
|
||||
Integration test in `crates/signed_state`:
|
||||
|
||||
- A scanned repository whose binding resolves to a `RepoAddr` that matches a seeded announcement is
|
||||
deduped out of the local list when it is the user's own; one with no matching announcement
|
||||
remains and is labelled.
|
||||
|
||||
Final verification: `cargo check -p signed_git -p signed_state -p workspace` and
|
||||
`cargo test -p signed_git`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Proving a repository is announced on relays. That stays the announcement layer's job.
|
||||
- Writing markers for Signed-published repositories is Phase 5 and optional.
|
||||
- Migrating or rewriting other tools' markers (we only read them).
|
||||
Reference in New Issue
Block a user