From e556654be83fbb0d18af4225510aa0dded4be5cd Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 14 Sep 2026 08:44:19 +0700 Subject: [PATCH] add nip34 detector --- Cargo.lock | 2 + crates/signed_git/Cargo.toml | 2 + crates/signed_git/src/lib.rs | 2 + crates/signed_git/src/nip34.rs | 428 +++++++++++++++++++++++++++++ docs/local-repo-nip34-detection.md | 307 +++++++++++++++++++++ 5 files changed, 741 insertions(+) create mode 100644 crates/signed_git/src/nip34.rs create mode 100644 docs/local-repo-nip34-detection.md diff --git a/Cargo.lock b/Cargo.lock index 7a27540..a364b65 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7981,6 +7981,8 @@ dependencies = [ "gix-worktree-state", "ignore", "nostr", + "serde", + "serde_json", "signed_core", "tempfile", ] diff --git a/crates/signed_git/Cargo.toml b/crates/signed_git/Cargo.toml index a4cd750..d5992cb 100644 --- a/crates/signed_git/Cargo.toml +++ b/crates/signed_git/Cargo.toml @@ -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" diff --git a/crates/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs index 5d8dd65..efeb1ba 100644 --- a/crates/signed_git/src/lib.rs +++ b/crates/signed_git/src/lib.rs @@ -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, }; diff --git a/crates/signed_git/src/nip34.rs b/crates/signed_git/src/nip34.rs new file mode 100644 index 0000000..cd7ebc7 --- /dev/null +++ b/crates/signed_git/src/nip34.rs @@ -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/` 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, + pub identifier: Option, + pub grasp_urls: Vec, +} + +#[derive(serde::Deserialize)] +struct Nip34Json { + identifier: Option, + owner: Option, +} + +pub fn detect_nip34(repo_path: &Path) -> Option { + 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 = None; + let mut identifier: Option = None; + let mut grasp_urls: Vec = Vec::new(); + + if let Some(workdir) = &workdir { + if let Ok(bytes) = std::fs::read(workdir.join("nip34.json")) + && let Ok(config) = serde_json::from_slice::(&bytes) + { + signals.nip34_json = true; + identifier = config.identifier.and_then(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) -> Option { + 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::::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" + ))); + } +} diff --git a/docs/local-repo-nip34-detection.md b/docs/local-repo-nip34-detection.md new file mode 100644 index 0000000..125e658 --- /dev/null +++ b/docs/local-repo-nip34-detection.md @@ -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` | `/nip34.json` | Repo initialized. JSON fields: `identifier`, `name`, `description`, `owner`, `grasp-servers[]`, `earliest-unique-commit` | nak | Strong; yields owner + identifier | +| 2 | `nip34.json` line | `/info/exclude` | Corroborates #1 (nak hides the file this way) | nak | Corroborating | +| 3 | `refs/heads/nip34/state/HEAD` and `refs/heads/nip34/state/` | refs | nak materialized a kind-30618 state | nak | Strong | +| 4 | Remote `nip34/grasp/` | `.git/config`: `remote.nip34/grasp/.url` = `https:////.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` | `/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:////.git` (or `grasp://…`) | `.git/config` | Some client registered a grasp remote | unknown | Medium | +| 10 | `maintainers.yaml` | `/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
nak, owner+id known] + B -- no --> C{nostr.repo decodes
as kind-30617 naddr?} + C -- yes --> INIT2[NIP-34 initialized
ngit, coordinate known] + C -- no --> D{nip34/grasp remote
or nip34/state refs?} + D -- yes --> INIT3[NIP-34 initialized
nak, id from remote URL] + D -- no --> E{nostr:// remote only?} + E -- yes --> CLONE[NIP-34 clone
derive owner+id from URL] + E -- no --> F{lmdb / aux config /
grasp-shaped remote only?} + F -- yes --> TOOL[Nostr tooling seen
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/` 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/` 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, + pub identifier: Option, + pub grasp_urls: Vec, +} + +/// 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; + +/// Whether `url` has the grasp URL shape, `[https|http|grasp]:////.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, +} + +pub fn find_git_repos(root: &Path) -> Vec; +``` + +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`; 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>`; `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` 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` 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 `. +- 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/` = `https:////.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).