16 KiB
Local repository NIP-34 detection
Status: Phases 1–4 implemented. Phase 5 optional and pending.
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
- 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.
- 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 nonostr.repoconfig 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.repois 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 annpub. The identifier segment conventionally ends in.git. - Signed's own clone URLs use the
grasp://scheme (seetransport_urlincrates/signed_git/src/remote.rs), so the shape check must acceptgrasp://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
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
nip34.jsonparses →Initialized, owner + identifier known (nak). - #5
nostr.repodecodes as a kind-30617naddr→Initialized, coordinate known (ngit). - #3 or #4, without #1/#2 →
Initialized(nak); derive owner + identifier from thenip34/grasp/<host>remote URL when present. - Only #6
nostr://remote →Cloned; derive owner + identifier from the URL when parseable. - Only #7, #8, #9 or #10 →
ToolingOnly. - 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:
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), notgitsubprocesses: the scan walks many repositories on a background thread. - Worktree root via
repo.workdir();.git-relative paths (info/exclude,nostr-cache.lmdb) viarepo.common_dir(). - Read
nostr.repofrom the local config file only (repo.common_dir().join("config")), mirroring ngit's own scope. - Parse
nip34.jsonwithserde_json. Theownerfield is an npub in nak's writer but its validator accepts hex too, so usePublicKey::parse(bech32 or hex). - Decode ngit's
naddrwith the nostr crate's NIP-19 coordinate decoder (Nip19Coordinate), and require kind 30617. - Add
serdeandserde_jsontocrates/signed_git/Cargo.toml(both are already workspace dependencies).
crates/signed_git/src/scan.rs returns richer entries:
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.
crates/signed_git/Cargo.toml: addserde.workspace = true,serde_json.workspace = true.crates/signed_git/src/nip34.rs: implementdetect_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.crates/signed_git/src/lib.rs: addmod nip34;and re-export the public items.- Tests in
crates/signed_git/src/nip34.rs(see Testing).
Phase 2 — thread LocalRepo through the state layer
Implemented.
crates/signed_git/src/scan.rs: changefind_git_repostoVec<LocalRepo>; in the walk,detect_nip34each kept path.crates/signed_git/src/tests.rs: updatefind_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.pathinstead of bare paths when collectingscanned.crates/signed_state/src/lib.rs: re-exportLocalRepo,Nip34Binding,Nip34KindandGraspSignals.crates/signed_state/src/local_repos.rs:local_repo_addr(&LocalRepo) -> Option<RepoAddr>resolves a binding when both owner and identifier are known.crates/workspace/src/views/sidebar/mod.rs: minimal adaptation of the existing folder-name dedupe to the new entry type so the tree compiles; the badge/dedupe rewrite is Phase 3.
Phase 3 — derive the local list and link it to announcements
Implemented. The matching lives in signed_state (per the data-model note above), so the sidebar
stays a thin renderer.
crates/signed_state/src/local_repos.rs:ResolvedLocalRepoandresolve_local_repos(repos, known, own)resolve each scanned repository'sRepoAddragainst the known announcements: - a binding matching one of the user's own announcements is dropped, since it is already listed as an announcement row; - a binding matching any other known announcement is kept and linked to it; - every other repository is kept unchanged.crates/signed_git/src/nip34.rs:Nip34BindingderivesPartialEqso the sidebar can detect changes to the resolved list.crates/workspace/src/views/sidebar/mod.rs: the local list isArc<Vec<ResolvedLocalRepo>>;refreshcallsresolve_local_reposinstead of the folder-name dedupe. A matched entry's click opens the announced repository, the rest open the local detail view.- Badge rendering in
render_local_row, derived fromNip34Binding::signals: -Initialized→ "NIP-34 · nak" / "NIP-34 · ngit" -Cloned→ "NIP-34 clone" (decision 2, its own visible state) -ToolingOnly→ "Nostr tooling" (muted) - plain → unchanged warning icon.
A matched entry opens the announced repository; Phase 4 attaches its local worktree.
Phase 4 — detail view: open as announced, gate the publish CTA
Implemented.
crates/signed_state/src/repo.rs:RepoStore::from_worktree(addr, announcement, path, cx)builds the announced store and attaches the local path.RepoStoregained anip34field carrying the detected binding, andnew_localnow takes it.crates/workspace/src/views/repo/mod.rs:RepoDetailView::new_local_announced(...)builds the announced store with the local worktree, thennew_common.new_localgained anOption<Nip34Binding>parameter.crates/workspace/src/views/repo/mod.rs(load_repo): a store with a local path now loads the worktree from that path even when it is announced, instead of always using the cached mirror. This is what actually attaches the local worktree; previously the announced branch ignoredRepoStore::pathand cloned into the mirror.crates/workspace/src/views/sidebar/mod.rs: local-entry clicks route throughopen_local_announcedwhen an announcement matched, andopen_local_repootherwise.crates/workspace/src/views/repo/mod.rs(render_local_header): anInitializedbinding suppresses the "Initialize on Nostr" call to action and shows the owner and identifier instead.ClonedandToolingOnlykeep the publish path.
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.jsonand 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
.gitentry to match), so their absence as a worktree is not a problem. Linked worktrees have a.gitfile and are scanned;gix::openresolves them andinfo/exclude,configandnostr-cache.lmdblive 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
gitsubprocesses. - Multiple grasp servers produce multiple
nip34/grasp/*remotes; use the first parseable one for the coordinate and keep all of them ingrasp_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 (
initpassesVec::new()), so detection code must not assume a non-empty root list; nocfggymnastics 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.jsonwith an npub owner →Initialized, owner + identifier parsed.- malformed
nip34.json→ falls back to no #1 signal, does not panic. nostr.reposet to a kind-30617naddr→Initializedwith the matching coordinate.origin=nostr://npub…/relay/identifierand nonostr.repo→Cloned.- remote
nip34/grasp/<host>=https://<host>/<npub>/<id>.git→ nak remote signal, owner + id recovered from the URL. refs/heads/nip34/state/HEADpresent andnip34.jsonin.git/info/exclude→ nak state signals..git/nostr-cache.lmdbalone →ToolingOnly.- plain repository →
None. is_grasp_url: accepthttps://host/npub1…/repo.gitandgrasp://host/npub1…/repo.git; rejecthttps://host/repo.git(no owner), a first segment that is not a validnpub, and an unrelated scheme such asssh://.
Integration tests in crates/signed_state/src/local_repos.rs (plain #[test], no GPUI harness):
- a repository bound to the user's own announcement is dropped from the local list;
- one bound to another owner's announcement is kept and linked to it;
- one with an unmatched binding is kept with its binding and no announcement;
- a plain repository is kept with no binding and no announcement.
Final verification: cargo clippy -p signed_git -p signed_state -p workspace --all-targets and
cargo test -p signed_git -p signed_state.
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).