add nip34 detector
This commit is contained in:
@@ -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