Files
signed/docs/local-repo-nip34-detection.md
T
2026-09-14 08:50:15 +07:00

16 KiB
Raw Blame History

Local repository NIP-34 detection

Status: Phases 12 implemented. Phases 35 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

  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

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 naddrInitialized, 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:

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:

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: add serde.workspace = true, serde_json.workspace = true.
  • 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.
  • crates/signed_git/src/lib.rs: add mod 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: 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.
  • 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.

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 naddrInitialized with the matching coordinate.
  • origin = nostr://npub…/relay/identifier and no nostr.repoCloned.
  • 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).