feat: detect local grasp repositories #21

Merged
reya merged 8 commits from feat/detect-nip34 into master 2026-09-14 04:06:11 +00:00
5 changed files with 741 additions and 0 deletions
Showing only changes of commit e556654be8 - Show all commits
Generated
+2
View File
@@ -7981,6 +7981,8 @@ dependencies = [
"gix-worktree-state",
"ignore",
"nostr",
"serde",
"serde_json",
"signed_core",
"tempfile",
]
+2
View File
@@ -8,6 +8,8 @@ publish.workspace = true
signed_core = { path = "../signed_core" }
nostr.workspace = true
serde.workspace = true
serde_json.workspace = true
gix = { workspace = true, features = ["revision", "blob-diff"] }
gix-worktree = "0.56"
gix-worktree-state = "0.34"
+2
View File
@@ -1,6 +1,7 @@
mod cache;
mod diff;
mod history;
mod nip34;
mod patch;
mod remote;
mod repo;
@@ -19,6 +20,7 @@ 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 patch::{
apply_patch, format_patch_between, patch_commits, patch_diffs, split_patch_series,
};
+428
View File
@@ -0,0 +1,428 @@
use std::path::Path;
use gix::bstr::ByteSlice;
use nostr::prelude::*;
/// 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, by `nak`'s `nip34.json` or `ngit`'s `nostr.repo`.
Initialized,
/// Cloned from a `nostr://` remote but never initialized locally.
Cloned,
/// Nostr tooling touched the repository but no binding is recoverable.
ToolingOnly,
}
#[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,
/// `nip34_grasp_remote` is the `nak`-specific `nip34/grasp/<host>` remote name.
pub nip34_grasp_remote: bool,
pub nip34_state_refs: bool,
pub nostr_cache: bool,
pub nostr_aux_config: bool,
pub maintainers_yaml: bool,
}
impl GraspSignals {
pub fn any(&self) -> bool {
*self != Self::default()
}
}
/// What a local repository's on-disk state says about its NIP-34 binding.
#[derive(Debug, Clone)]
pub struct Nip34Binding {
pub kind: Nip34Kind,
pub signals: GraspSignals,
/// Coordinate owner and identifier, from `nip34.json` or `nostr.repo`.
pub owner: Option<PublicKey>,
pub identifier: Option<String>,
pub grasp_urls: Vec<String>,
}
#[derive(serde::Deserialize)]
struct Nip34Json {
identifier: Option<String>,
owner: Option<String>,
}
pub fn detect_nip34(repo_path: &Path) -> Option<Nip34Binding> {
let repo = gix::open(repo_path).ok()?;
let common_dir = repo.common_dir().to_path_buf();
let workdir = repo.workdir().map(Path::to_path_buf);
let mut signals = GraspSignals::default();
let mut owner: Option<PublicKey> = None;
let mut identifier: Option<String> = None;
let mut grasp_urls: Vec<String> = Vec::new();
if let Some(workdir) = &workdir {
if let Ok(bytes) = std::fs::read(workdir.join("nip34.json"))
&& let Ok(config) = serde_json::from_slice::<Nip34Json>(&bytes)
{
signals.nip34_json = true;
identifier = config.identifier.and_then(non_empty);
owner = config
.owner
.as_deref()
.and_then(|value| PublicKey::parse(value).ok());
}
if workdir.join("maintainers.yaml").is_file() {
signals.maintainers_yaml = true;
}
}
if let Ok(exclude) = std::fs::read_to_string(common_dir.join("info/exclude"))
&& exclude.contains("nip34.json")
{
signals.nip34_excluded = true;
}
// `ngit` keeps its repository event cache in the Git common directory.
if common_dir.join("nostr-cache.lmdb").is_file() {
signals.nostr_cache = true;
}
// `ngit` reads and writes `nostr.repo` at repository-local scope only.
if let Ok(config) = gix::config::File::from_path_no_includes(
common_dir.join("config"),
gix::config::Source::Local,
) {
if let Some(value) = config.string("nostr.repo")
&& let Some((key, id)) = coordinate_from_naddr(&value.to_str_lossy())
{
signals.nostr_repo_config = true;
owner = Some(key);
identifier = Some(id);
}
for key in ["nostr.repo-relay-only", "nostr.nostate", "nostr.private"] {
if config.string(key).is_some() {
signals.nostr_aux_config = true;
}
}
if let Some(sections) = config.sections_by_name("remote") {
for section in sections {
let Some(name) = section.header().subsection_name() else {
continue;
};
let nak_grasp_remote = name.to_str_lossy().starts_with("nip34/grasp/");
for url in section.values("url") {
let url = url.to_str_lossy();
if url.starts_with("nostr://") {
signals.nostr_remote = true;
// Strong markers win; only fill an empty binding.
if owner.is_none()
&& identifier.is_none()
&& let Some((key, id)) = parse_nostr_url(&url)
{
owner = Some(key);
identifier = Some(id);
}
}
if is_grasp_url(&url) {
signals.grasp_remote = true;
signals.nip34_grasp_remote |= nak_grasp_remote;
grasp_urls.push(url.to_string());
if owner.is_none()
&& identifier.is_none()
&& let Some((key, id)) = grasp_parts(&url)
{
owner = Some(key);
identifier = Some(id);
}
}
}
}
}
}
// `nak` materializes a kind-30618 state as `refs/heads/nip34/state/*`.
if let Ok(platform) = repo.references()
&& let Ok(mut refs) = platform.prefixed(b"refs/heads/nip34/state/")
&& refs.next().is_some()
{
signals.nip34_state_refs = true;
}
if !signals.any() {
return None;
}
let kind = if signals.nip34_json
|| signals.nostr_repo_config
|| signals.nip34_grasp_remote
|| signals.nip34_state_refs
{
Nip34Kind::Initialized
} else if signals.nostr_remote {
Nip34Kind::Cloned
} else {
Nip34Kind::ToolingOnly
};
Some(Nip34Binding {
kind,
signals,
owner,
identifier,
grasp_urls,
})
}
/// 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 {
let Ok(parsed) = Url::parse(url) else {
return false;
};
if !matches!(parsed.scheme(), "http" | "https" | "grasp") {
return false;
}
let path = parsed.path();
if path.matches('/').count() != 2 || path.len() < 65 {
return false;
}
grasp_parts(url).is_some()
}
fn grasp_parts(url: &str) -> Option<(PublicKey, String)> {
let parsed = Url::parse(url).ok()?;
let mut segments = parsed.path_segments()?.filter(|part| !part.is_empty());
let owner = PublicKey::parse(segments.next()?).ok()?;
let identifier = non_empty(segments.next()?.trim_end_matches(".git"))?;
Some((owner, identifier))
}
fn coordinate_from_naddr(value: &str) -> Option<(PublicKey, String)> {
let coordinate = Nip19Coordinate::from_bech32(value).ok()?;
if coordinate.kind != Kind::GitRepoAnnouncement {
return None;
}
let identifier = non_empty(coordinate.identifier.clone())?;
Some((coordinate.public_key, identifier))
}
/// Handles a bare `naddr`, an `npub`, and the optional `[ssh-key-file@]`,
/// `[protocol/]` and `[relay/]` components. An `nip05` owner yields no binding.
fn parse_nostr_url(url: &str) -> Option<(PublicKey, String)> {
let rest = url.strip_prefix("nostr://")?;
if rest.starts_with("naddr1") {
return coordinate_from_naddr(rest);
}
let rest = rest.rsplit_once('@').map_or(rest, |(_, after)| after);
let mut parts: Vec<&str> = rest.split('/').filter(|part| !part.is_empty()).collect();
if parts
.first()
.is_some_and(|first| matches!(*first, "ssh" | "https" | "http"))
{
parts.remove(0);
}
// `[owner, (relay), identifier]`.
if parts.len() < 2 {
return None;
}
let owner = PublicKey::parse(parts[0]).ok()?;
let identifier = non_empty(parts.last()?.trim_end_matches(".git"))?;
Some((owner, identifier))
}
fn non_empty(value: impl Into<String>) -> Option<String> {
let value = value.into();
(!value.is_empty()).then_some(value)
}
#[cfg(test)]
mod tests {
use std::process::Command;
use super::*;
fn init_repo() -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("repo");
std::fs::create_dir_all(&path).expect("mkdir");
git(&path, &["init", "-q"]);
(dir, path)
}
fn git(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "Test Author")
.env("GIT_AUTHOR_EMAIL", "test@example.com")
.env("GIT_COMMITTER_NAME", "Test Author")
.env("GIT_COMMITTER_EMAIL", "test@example.com")
.env("GIT_EDITOR", "true")
.args(args)
.status()
.expect("spawn git");
assert!(status.success(), "git {args:?} failed");
}
fn key() -> PublicKey {
Keys::generate().public_key()
}
fn naddr(kind: Kind, owner: PublicKey, identifier: &str) -> String {
let coordinate = Coordinate::new(kind, owner).identifier(identifier);
Nip19Coordinate::new(coordinate, Vec::<RelayUrl>::new())
.to_bech32()
.expect("naddr")
}
#[test]
fn plain_repository_has_no_binding() {
let (_dir, path) = init_repo();
assert!(detect_nip34(&path).is_none());
}
#[test]
fn nip34_json_marks_a_repository_initialized() {
let (_dir, path) = init_repo();
let owner = key();
let npub = owner.to_bech32().expect("npub");
std::fs::write(
path.join("nip34.json"),
format!(r#"{{"identifier":"my-repo","owner":"{npub}"}}"#),
)
.expect("write");
let binding = detect_nip34(&path).expect("binding");
assert_eq!(binding.kind, Nip34Kind::Initialized);
assert!(binding.signals.nip34_json);
assert_eq!(binding.owner, Some(owner));
assert_eq!(binding.identifier.as_deref(), Some("my-repo"));
}
#[test]
fn malformed_nip34_json_is_ignored() {
let (_dir, path) = init_repo();
std::fs::write(path.join("nip34.json"), b"not json").expect("write");
assert!(detect_nip34(&path).is_none());
}
#[test]
fn nak_exclude_and_state_refs_are_detected() {
let (_dir, path) = init_repo();
std::fs::create_dir_all(path.join(".git/info")).expect("mkdir");
std::fs::write(path.join(".git/info/exclude"), "nip34.json\n").expect("write");
git(&path, &["commit", "-q", "--allow-empty", "-m", "initial"]);
git(
&path,
&["update-ref", "refs/heads/nip34/state/HEAD", "HEAD"],
);
let binding = detect_nip34(&path).expect("binding");
assert_eq!(binding.kind, Nip34Kind::Initialized);
assert!(binding.signals.nip34_excluded);
assert!(binding.signals.nip34_state_refs);
}
#[test]
fn nostr_repo_config_marks_a_repository_initialized() {
let (_dir, path) = init_repo();
let owner = key();
let naddr = naddr(Kind::GitRepoAnnouncement, owner, "my-repo");
git(&path, &["config", "nostr.repo", &naddr]);
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();
let owner = key();
let npub = owner.to_bech32().expect("npub");
let url = format!("nostr://{npub}/relay.ngit.dev/my-repo");
git(&path, &["remote", "add", "origin", &url]);
let binding = detect_nip34(&path).expect("binding");
assert_eq!(binding.kind, Nip34Kind::Cloned);
assert!(binding.signals.nostr_remote);
assert_eq!(binding.owner, Some(owner));
assert_eq!(binding.identifier.as_deref(), Some("my-repo"));
}
#[test]
fn nak_grasp_remote_marks_a_repository_initialized() {
let (_dir, path) = init_repo();
let owner = key();
let npub = owner.to_bech32().expect("npub");
let url = format!("https://gitnostr.com/{npub}/my-repo.git");
git(
&path,
&["config", "remote.nip34/grasp/gitnostr.com.url", &url],
);
let binding = detect_nip34(&path).expect("binding");
assert_eq!(binding.kind, Nip34Kind::Initialized);
assert!(binding.signals.nip34_grasp_remote);
assert!(binding.signals.grasp_remote);
assert_eq!(binding.grasp_urls, vec![url]);
assert_eq!(binding.owner, Some(owner));
assert_eq!(binding.identifier.as_deref(), Some("my-repo"));
}
#[test]
fn nostr_cache_alone_is_tooling_only() {
let (_dir, path) = init_repo();
std::fs::write(path.join(".git/nostr-cache.lmdb"), b"cache").expect("write");
let binding = detect_nip34(&path).expect("binding");
assert_eq!(binding.kind, Nip34Kind::ToolingOnly);
assert!(binding.signals.nostr_cache);
}
#[test]
fn grasp_urls_are_recognised_by_shape() {
let owner = key();
let npub = owner.to_bech32().expect("npub");
assert!(is_grasp_url(&format!(
"https://gitnostr.com/{npub}/my-repo.git"
)));
assert!(is_grasp_url(&format!(
"grasp://gitnostr.com/{npub}/my-repo.git"
)));
assert!(!is_grasp_url("https://gitnostr.com/my-repo.git"));
assert!(!is_grasp_url(
"https://gitnostr.com/not-a-pubkey/my-repo.git"
));
assert!(!is_grasp_url(&format!(
"ssh://gitnostr.com/{npub}/my-repo.git"
)));
}
}
+307
View File
@@ -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).