ngit compatible
This commit is contained in:
@@ -1,317 +0,0 @@
|
||||
# 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
|
||||
|
||||
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
|
||||
|
||||
Implemented.
|
||||
|
||||
- [x] `crates/signed_git/src/scan.rs`: change `find_git_repos` to `Vec<LocalRepo>`; in the walk,
|
||||
`detect_nip34` each kept path.
|
||||
- [x] `crates/signed_git/src/tests.rs`: update `find_git_repos_*` expectations to the new type
|
||||
(compare `.path`).
|
||||
- [x] `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.
|
||||
- [x] `crates/signed_state/src/checkouts.rs` (`run_refresh`): iterate `.path` instead of bare
|
||||
paths when collecting `scanned`.
|
||||
- [x] `crates/signed_state/src/lib.rs`: re-export `LocalRepo`, `Nip34Binding`, `Nip34Kind` and
|
||||
`GraspSignals`.
|
||||
- [x] `crates/signed_state/src/local_repos.rs`: `local_repo_addr(&LocalRepo) -> Option<RepoAddr>`
|
||||
resolves a binding when both owner and identifier are known.
|
||||
- [x] `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.
|
||||
|
||||
- [x] `crates/signed_state/src/local_repos.rs`: `ResolvedLocalRepo` and
|
||||
`resolve_local_repos(repos, known, own)` resolve each scanned repository's `RepoAddr` against
|
||||
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.
|
||||
- [x] `crates/signed_git/src/nip34.rs`: `Nip34Binding` derives `PartialEq` so the sidebar can
|
||||
detect changes to the resolved list.
|
||||
- [x] `crates/workspace/src/views/sidebar/mod.rs`: the local list is `Arc<Vec<ResolvedLocalRepo>>`;
|
||||
`refresh` calls `resolve_local_repos` instead of the folder-name dedupe. A matched entry's
|
||||
click opens the announced repository, the rest open the local detail view.
|
||||
- [x] Badge rendering in `render_local_row`, derived from `Nip34Binding::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.
|
||||
|
||||
- [x] `crates/signed_state/src/repo.rs`: `RepoStore::from_worktree(addr, announcement, path, cx)`
|
||||
builds the announced store and attaches the local path. `RepoStore` gained a `nip34` field
|
||||
carrying the detected binding, and `new_local` now takes it.
|
||||
- [x] `crates/workspace/src/views/repo/mod.rs`: `RepoDetailView::new_local_announced(...)` builds the
|
||||
announced store with the local worktree, then `new_common`. `new_local` gained an
|
||||
`Option<Nip34Binding>` parameter.
|
||||
- [x] `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 ignored
|
||||
`RepoStore::path` and cloned into the mirror.
|
||||
- [x] `crates/workspace/src/views/sidebar/mod.rs`: local-entry clicks route through
|
||||
`open_local_announced` when an announcement matched, and `open_local_repo` otherwise.
|
||||
- [x] `crates/workspace/src/views/repo/mod.rs` (`render_local_header`): an `Initialized` binding
|
||||
suppresses the "Initialize on Nostr" call to action and shows the owner and identifier
|
||||
instead. `Cloned` and `ToolingOnly` keep 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.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 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).
|
||||
Reference in New Issue
Block a user