diff --git a/crates/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs index f1eae1f..470aaee 100644 --- a/crates/signed_git/src/lib.rs +++ b/crates/signed_git/src/lib.rs @@ -20,7 +20,9 @@ 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 nip34::{ + GraspSignals, Nip34Binding, Nip34Kind, detect_nip34, is_grasp_url, set_nostr_repo, +}; 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 index bf58ac2..7c50c13 100644 --- a/crates/signed_git/src/nip34.rs +++ b/crates/signed_git/src/nip34.rs @@ -1,5 +1,6 @@ use std::path::Path; +use anyhow::Result; use gix::bstr::ByteSlice; use nostr::prelude::*; @@ -182,6 +183,16 @@ pub fn detect_nip34(repo_path: &Path) -> Option { }) } +/// Record a repository's NIP-34 coordinate in its local `nostr.repo` config. +pub fn set_nostr_repo(repo_path: &Path, naddr: &str) -> Result<()> { + let repo = gix::open(repo_path)?; + + crate::remote::edit_local_config(&repo, |config| { + config.set_raw_value("nostr.repo", naddr)?; + Ok(()) + }) +} + /// 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 { @@ -360,6 +371,21 @@ mod tests { assert_eq!(binding.identifier.as_deref(), Some("my-repo")); } + #[test] + fn the_written_nostr_repo_marker_is_detected() { + let (_dir, path) = init_repo(); + let owner = key(); + let naddr = naddr(Kind::GitRepoAnnouncement, owner, "my-repo"); + + set_nostr_repo(&path, &naddr).expect("write marker"); + + 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(); diff --git a/crates/signed_git/src/remote.rs b/crates/signed_git/src/remote.rs index 6bbc4e4..78d2452 100644 --- a/crates/signed_git/src/remote.rs +++ b/crates/signed_git/src/remote.rs @@ -252,10 +252,7 @@ pub fn set_origin(repo_path: &Path, url: &str) -> Result<()> { } /// Apply `edit` to the repository-local configuration and persist it. -/// -/// The config file is locked while it is read, edited and written back, -/// like git would when running `git config` or `git remote`. -fn edit_local_config( +pub(crate) fn edit_local_config( repo: &gix::Repository, edit: impl FnOnce(&mut gix::config::File) -> Result<()>, ) -> Result<()> { diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index fa9fccf..362c820 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -7,10 +7,11 @@ use anyhow::{Error, anyhow, bail}; use bitcoin_hashes::sha1::Hash as Sha1Hash; use gpui::{App, AppContext, BackgroundExecutor, Context, Entity, EventEmitter, Global, Task}; use nostr::event::IntoEventBuilder; +use nostr::nips::nip19::Nip19Coordinate; use nostr_connect::prelude::*; use nostr_sdk::client::SyncSummary; use nostr_sdk::prelude::*; -use signed_core::{Announcement, RepoAddr, build_state, filters, identifier_from_name}; +use signed_core::{Announcement, RepoAddr, build_state, filters, identifier_from_name, repo_addr}; use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update}; use crate::git_store::repo_mirror_path; @@ -743,6 +744,25 @@ impl Backend { .await; } + // Record the ngit-compatible `nostr.repo` marker, + // so the next scan detects the repository instead of offering to publish it again. + let coordinate = repo_addr(event.pubkey, repo_id.clone()); + match Nip19Coordinate::new(coordinate, servers.clone()).to_bech32() { + Ok(naddr) => { + let path = path.clone(); + cx.background_spawn(async move { + if let Err(error) = signed_git::set_nostr_repo(&path, &naddr) { + log::warn!( + "failed to record the NIP-34 marker for {}: {error}", + path.display() + ); + } + }) + .await; + } + Err(error) => log::warn!("failed to encode the repository coordinate: {error}"), + } + Announcement::from_event(&event).ok_or_else(|| anyhow!("failed to parse announcement")) }) } diff --git a/docs/local-repo-nip34-detection.md b/docs/local-repo-nip34-detection.md deleted file mode 100644 index 1511fdb..0000000 --- a/docs/local-repo-nip34-detection.md +++ /dev/null @@ -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` | `/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 - -Implemented. - -- [x] `crates/signed_git/src/scan.rs`: change `find_git_repos` to `Vec`; 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>`; `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` - 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>`; - `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` 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 `. -- 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 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).