fix(quartz): NIP-34 wire-format interop with ngit (clone/web, issue p, plain r)

Verified Amethyst's NIP-34 events byte-for-byte against the ngit reference
implementation (DanConwayDev/ngit-cli) and the spec, and fixed three real
interop divergences in quartz — so ngit/gitworkshop and Amethyst read each
other's git repos, issues, patches, and PRs without losing data.

- Repository announcement `clone`/`web` were emitted as REPEATED single-value
  tags (`["clone", a]`, `["clone", b]`). The spec and ngit use ONE multi-value
  tag (`["clone", a, b]`), and ngit's parser keeps only the LAST of repeated
  known tags — so multi-URL repos silently lost every URL but one in both
  directions. Now emitted as a single multi-value tag; `clones()`/`webs()` read
  BOTH the spec form and the legacy repeated form, so old events still parse.
  (`relays`/`maintainers` were already correct multi-value tags.)

- Issues (kind 1621) were missing the `["p", <repo-owner>]` tag that patches and
  PRs already include — a maintainer watching `#p` wouldn't see them. The
  builder now adds it (fixes both the CLI and the Android issue-creation path,
  which both passed an empty notify list).

- Patch / PR / PR-update `r` tags carried the `"euc"` marker
  (`["r", commit, "euc"]`). Per the spec and ngit that marker belongs only on
  the kind-30617 announcement; other `r` tags are plain `["r", commit]`. A `#r`
  filter matches either shape, so this is a spec-compliance/byte-parity fix.

`alt` (NIP-31) tags are intentionally still omitted — quartz treats the generic
alt client-hint as deprecated, and ngit/gitworkshop parse the structured tags,
so it isn't required for interop.

Adds `GitNip34InteropTest` (5 cases: multi-value write, tolerant read of both
forms, issue p-tag, plain patch r-tag) and 4 wire-format assertions to the CLI
git harness (37 offline). No regressions in the nip34 or Search suites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
This commit is contained in:
Claude
2026-07-21 00:33:55 +00:00
parent d3e208322c
commit 1a77c95531
11 changed files with 197 additions and 16 deletions

View File

@@ -109,7 +109,7 @@ vs streaming `subscribe`). Stateless verbs run with no account or network.
| `nip` | `amy nip` | ✅ | repo-first lookup + Nostr fallback (NipText kind:30817, wiki:30818, long-form:30023); `nip list`. |
| `kind` | `amy kind` | ✅ | quartz `KindNames` registry (kind → English label + NIP) covering **every** event kind quartz defines (280 entries); number lookup + name search. |
| `sync` | `amy sync` | ✅ | NIP-77 Negentropy reconcile with the local store (down/up/both). |
| `git` | `amy git` | ✅ (events + read) | NIP-34: `init` bootstraps a repo from the local `git` checkout (announce + state, like `ngit init`); repo announce (30617) + state (30618), patches (1617), pull requests (1618/1619), issues (1621), NIP-22 comments (1111), NIP-32 labels (1985), status open/applied/closed/draft (1630-1633), GRASP server list (10317); `issues`/`patches`/`prs`/`thread` reads derive status; `apply` applies a fetched patch to the local tree (`git am`); `browse`/`cat`/`log` read git objects over smart-HTTP v2 (quartz `GitHttpClient`, the same shallow-clone path the Android browser uses). Only git-packfile **push** (writing objects to clone/GRASP servers) and NIP-34 cover notes (1624, no quartz builder yet) are out of scope. |
| `git` | `amy git` | ✅ (events + read) | NIP-34: `init` bootstraps a repo from the local `git` checkout (announce + state, like `ngit init`); repo announce (30617) + state (30618), patches (1617), pull requests (1618/1619), issues (1621), NIP-22 comments (1111), NIP-32 labels (1985), status open/applied/closed/draft (1630-1633), GRASP server list (10317); `issues`/`patches`/`prs`/`thread` reads derive status; `apply` applies a fetched patch to the local tree (`git am`); `browse`/`cat`/`log` read git objects over smart-HTTP v2 (quartz `GitHttpClient`, the same shallow-clone path the Android browser uses). Only git-packfile **push** (writing objects to clone/GRASP servers) and NIP-34 cover notes (1624, no quartz builder yet) are out of scope. Event tag shapes were verified byte-for-byte against the ngit reference implementation and the NIP-34 spec (`clone`/`web` as single multi-value tags, issue `p`-tag for maintainer routing, plain patch/PR `r` tags); the quartz readers stay tolerant of the legacy repeated form. See `quartz/…/nip34Git/GitNip34InteropTest`. |
| `podcast` | `amy podcast` | ✅ | NIP-F4 show metadata (10154) + episode publish (54) + list. |
| `bunker` | `amy bunker[ connect]` + `amy login bunker://`/`--nostrconnect` | ✅ | NIP-46 remote signer + login, both the `bunker://` and `nostrconnect://` flows, each direction, plus `auth_url` challenge handling (client surfaces the URL + keeps waiting). Interop-verified vs real `nak`. |
| `admin` | `amy admin RELAY METHOD` | ✅ | NIP-86 Relay Management over NIP-98 HTTP auth — full method set (ban/allow pubkey + event, kinds, IP block, change name/desc/icon, list-*). Reuses quartz `Nip86Client` + shared `commons` `Nip86Retriever`. Interop-verified against `amy serve`. |

View File

@@ -145,6 +145,21 @@ info "state: $STATE"
assert_eq "$(echo "$STATE" | jq -r '.branches')" "2" state.branches "two branch refs"
assert_eq "$(echo "$STATE" | jq -r '.head')" "main" state.head "HEAD=main"
# --- ngit interop: wire-format checks --------------------------------------
banner "ngit interop wire format"
# Announce a repo with TWO clone URLs; NIP-34/ngit want ONE multi-value tag.
IADDR="$(M git announce --name interop --clone https://a.git,https://b.git --web https://a.com,https://b.com --earliest-commit abc123 --relay "$RELAY_URL" | jq -r '.address')"
IEID="$(M git show "$IADDR" --relay "$RELAY_URL" | jq -r '.event_id')"
ITAGS="$(M fetch --id "$IEID" --relay "$RELAY_URL")"
assert_eq "$(echo "$ITAGS" | jq -r '[.events[0].tags[] | select(.[0]=="clone")] | length')" "1" interop.clone_single "clone is one tag (not repeated)"
assert_eq "$(echo "$ITAGS" | jq -r '.events[0].tags[] | select(.[0]=="clone") | length')" "3" interop.clone_multivalue "clone tag carries both URLs (name + 2 values)"
# The tolerant reader must round-trip both URLs back.
assert_eq "$(M git show "$IADDR" --relay "$RELAY_URL" | jq -r '.clone | length')" "2" interop.clone_read "reader returns both clone URLs"
# Issue must p-tag the repository owner (maintainer routing).
IISS="$(M git issue "$IADDR" --subject "interop" "b" --relay "$RELAY_URL" | jq -r '.event_id')"
IOWNER="$(echo "$IADDR" | cut -d: -f2)"
assert_eq "$(M fetch --id "$IISS" --relay "$RELAY_URL" | jq -r "[.events[0].tags[] | select(.[0]==\"p\" and .[1]==\"$IOWNER\")] | length")" "1" interop.issue_ptag "issue carries the repo owner p tag"
# GRASP server list (10317) round-trip.
GRASP="$(M git grasp set "wss://grasp.example.com,wss://grasp2.example.com" --relay "$RELAY_URL")"
assert_eq "$(echo "$GRASP" | jq -r '.kind')" "10317" grasp.kind "grasp list is kind 10317"

View File

@@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.nip14Subject.SubjectTag
import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag
@@ -130,6 +131,9 @@ class GitIssueEvent(
) = eventTemplate(KIND, content, createdAt) {
subject(subject)
repository(repository)
// NIP-34 issues carry the repository owner as a `p` tag so maintainers
// watching `#p` are notified; the same tag patches/PRs already include.
pTag(repository.event.pubKey, repository.authorHomeRelay)
notify(notify)
hashtags(topics)

View File

@@ -38,12 +38,12 @@ fun TagArrayBuilder<GitPatchEvent>.repository(rep: EventHintBundle<GitRepository
/**
* Adds the earliest-unique-commit `r` tag used by patches to point at the
* target repository. NIP-34 uses the plain `["r", <commit>]` shape for
* patches (unlike the repository announcement which marks the tag with
* `euc`). We also emit the marked form so the same event can be matched by
* implementations that look for the marker.
* target repository. NIP-34 uses the plain `["r", <commit>]` shape for patches
* — the `"euc"` marker appears only on the kind-30617 repository announcement
* (confirmed against the ngit reference implementation). A `#r` filter still
* matches either shape (both key off the value at index 1).
*/
fun TagArrayBuilder<GitPatchEvent>.euc(commit: String) = addUnique(EucTag.assemble(commit))
fun TagArrayBuilder<GitPatchEvent>.euc(commit: String) = addUnique(arrayOf(EucTag.TAG_NAME, commit))
fun TagArrayBuilder<GitPatchEvent>.commit(commit: String) = addUnique(CommitTag.assemble(commit))

View File

@@ -38,7 +38,9 @@ fun TagArrayBuilder<GitPullRequestEvent>.repository(rep: ATag) = addUnique(rep.t
fun TagArrayBuilder<GitPullRequestEvent>.repository(rep: EventHintBundle<GitRepositoryEvent>) = addUnique(rep.toATag().toATagArray())
fun TagArrayBuilder<GitPullRequestEvent>.euc(commit: String) = addUnique(EucTag.assemble(commit))
// NIP-34 pull requests use the plain `["r", <commit>]` shape; the `"euc"` marker
// is only on the kind-30617 announcement (see the patch builder for the rationale).
fun TagArrayBuilder<GitPullRequestEvent>.euc(commit: String) = addUnique(arrayOf(EucTag.TAG_NAME, commit))
fun TagArrayBuilder<GitPullRequestEvent>.currentCommit(commit: String) = addUnique(CurrentCommitTag.assemble(commit))

View File

@@ -36,7 +36,8 @@ fun TagArrayBuilder<GitPullRequestUpdateEvent>.repository(rep: ATag) = addUnique
fun TagArrayBuilder<GitPullRequestUpdateEvent>.repository(rep: EventHintBundle<GitRepositoryEvent>) = addUnique(rep.toATag().toATagArray())
fun TagArrayBuilder<GitPullRequestUpdateEvent>.euc(commit: String) = addUnique(EucTag.assemble(commit))
// Plain `["r", <commit>]` — the `"euc"` marker is only on the 30617 announcement.
fun TagArrayBuilder<GitPullRequestUpdateEvent>.euc(commit: String) = addUnique(arrayOf(EucTag.TAG_NAME, commit))
fun TagArrayBuilder<GitPullRequestUpdateEvent>.currentCommit(commit: String) = addUnique(CurrentCommitTag.assemble(commit))

View File

@@ -58,14 +58,21 @@ class GitRepositoryEvent(
fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse)
/** First web URL, for backwards compatibility. Prefer [webs]. */
fun web() = tags.firstNotNullOfOrNull(WebTag::parse)
fun web() = webs().firstOrNull()
fun webs(): List<String> = tags.mapNotNull(WebTag::parse)
/**
* All browse URLs. Tolerant of both the NIP-34 spec form (one multi-value
* `["web", url1, url2]` tag, which ngit emits) and the legacy repeated
* `["web", url1]` / `["web", url2]` form, so a repo announced by any client
* round-trips without dropping URLs.
*/
fun webs(): List<String> = tags.flatMap(WebTag::parseAll)
/** First clone URL, for backwards compatibility. Prefer [clones]. */
fun clone() = tags.firstNotNullOfOrNull(CloneTag::parse)
fun clone() = clones().firstOrNull()
fun clones(): List<String> = tags.mapNotNull(CloneTag::parse)
/** All clone URLs — tolerant of both the multi-value and repeated forms (see [webs]). */
fun clones(): List<String> = tags.flatMap(CloneTag::parseAll)
/**
* Relays the repository author monitors for patches and issues. NIP-34
@@ -131,8 +138,8 @@ class GitRepositoryEvent(
dTag(dTag)
name(name)
description?.let { description(it) }
webUrls.forEach { webUrl(it) }
cloneUrls.forEach { cloneUrl(it) }
if (webUrls.isNotEmpty()) webUrls(webUrls)
if (cloneUrls.isNotEmpty()) cloneUrls(cloneUrls)
if (relays.isNotEmpty()) relays(relays)
if (maintainers.isNotEmpty()) maintainers(maintainers)
if (hashtags.isNotEmpty()) hashtags(hashtags)

View File

@@ -36,11 +36,13 @@ fun TagArrayBuilder<GitRepositoryEvent>.description(description: String) = addUn
fun TagArrayBuilder<GitRepositoryEvent>.webUrl(webUrl: String) = add(WebTag.assemble(webUrl))
fun TagArrayBuilder<GitRepositoryEvent>.webUrls(webUrls: List<String>) = addAll(webUrls.map(WebTag::assemble))
/** Emit all browse URLs as one NIP-34 multi-value `["web", url1, url2, …]` tag (the spec/ngit form). */
fun TagArrayBuilder<GitRepositoryEvent>.webUrls(webUrls: List<String>) = addUnique(WebTag.assemble(webUrls))
fun TagArrayBuilder<GitRepositoryEvent>.cloneUrl(cloneUrl: String) = add(CloneTag.assemble(cloneUrl))
fun TagArrayBuilder<GitRepositoryEvent>.cloneUrls(cloneUrls: List<String>) = addAll(cloneUrls.map(CloneTag::assemble))
/** Emit all clone URLs as one NIP-34 multi-value `["clone", url1, url2, …]` tag (the spec/ngit form). */
fun TagArrayBuilder<GitRepositoryEvent>.cloneUrls(cloneUrls: List<String>) = addUnique(CloneTag.assemble(cloneUrls))
fun TagArrayBuilder<GitRepositoryEvent>.relays(relays: List<String>) = addUnique(RelaysTag.assemble(relays))

View File

@@ -23,6 +23,13 @@ package com.vitorpamplona.quartz.nip34Git.repository.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* NIP-34 repository `clone` tag. The spec encodes clone URLs as a single
* multi-value tag — `["clone", "<url1>", "<url2>", ...]` — so [assemble] emits
* that form and [parseAll] reads every value. [parse] (first value only) and
* the legacy repeated `["clone", "<url>"]` form are still read for backward
* compatibility (see `GitRepositoryEvent.clones`).
*/
class CloneTag {
companion object {
const val TAG_NAME = "clone"
@@ -34,6 +41,16 @@ class CloneTag {
return tag[1]
}
/** Every non-empty URL carried by a single `clone` tag. */
fun parseAll(tag: Array<String>): List<String> {
ensure(tag.has(1)) { return emptyList() }
ensure(tag[0] == TAG_NAME) { return emptyList() }
return tag.drop(1).filter { it.isNotEmpty() }
}
fun assemble(name: String) = arrayOf(TAG_NAME, name)
/** The spec form: one tag carrying all clone URLs. */
fun assemble(urls: List<String>): Array<String> = (listOf(TAG_NAME) + urls.filter { it.isNotEmpty() }).toTypedArray()
}
}

View File

@@ -23,6 +23,12 @@ package com.vitorpamplona.quartz.nip34Git.repository.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* NIP-34 repository `web` tag. Like `clone`, the spec encodes browse URLs as a
* single multi-value tag — `["web", "<url1>", "<url2>", ...]` — so [assemble]
* emits that form and [parseAll] reads every value. [parse] (first value only)
* and the legacy repeated form are still read for backward compatibility.
*/
class WebTag {
companion object {
const val TAG_NAME = "web"
@@ -34,6 +40,16 @@ class WebTag {
return tag[1]
}
/** Every non-empty URL carried by a single `web` tag. */
fun parseAll(tag: Array<String>): List<String> {
ensure(tag.has(1)) { return emptyList() }
ensure(tag[0] == TAG_NAME) { return emptyList() }
return tag.drop(1).filter { it.isNotEmpty() }
}
fun assemble(name: String) = arrayOf(TAG_NAME, name)
/** The spec form: one tag carrying all browse URLs. */
fun assemble(urls: List<String>): Array<String> = (listOf(TAG_NAME) + urls.filter { it.isNotEmpty() }).toTypedArray()
}
}

View File

@@ -0,0 +1,117 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip34Git
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Locks in byte-level interoperability with ngit (github.com/DanConwayDev/ngit-cli)
* and the NIP-34 spec for the tag shapes that previously diverged:
*
* 1. `clone` / `web` are ONE multi-value tag, not repeated single-value tags —
* ngit's parser keeps only the last of repeated known tags, so the repeated
* form silently dropped URLs across implementations.
* 2. Issues carry the repository owner as a `p` tag (maintainer routing).
* 3. Patch/PR `r` tags are plain `["r", <commit>]`; the `"euc"` marker lives
* only on the kind-30617 announcement.
*/
class GitNip34InteropTest {
private val owner = "aa".repeat(32)
private fun repo(tags: Array<Array<String>>) = GitRepositoryEvent("00", owner, 0, tags, "", "00")
@Test
fun announcementEmitsSingleMultiValueCloneAndWeb() {
val tmpl =
GitRepositoryEvent.build(
name = "demo",
description = null,
webUrls = listOf("https://a.com", "https://b.com"),
cloneUrls = listOf("https://a.git", "https://b.git"),
relays = emptyList(),
maintainers = emptyList(),
hashtags = emptyList(),
earliestUniqueCommit = null,
dTag = "demo",
)
assertEquals(1, tmpl.tags.count { it[0] == "clone" }, "clone must be a single tag")
assertEquals(1, tmpl.tags.count { it[0] == "web" }, "web must be a single tag")
assertEquals(listOf("clone", "https://a.git", "https://b.git"), tmpl.tags.first { it[0] == "clone" }.toList())
assertEquals(listOf("web", "https://a.com", "https://b.com"), tmpl.tags.first { it[0] == "web" }.toList())
}
@Test
fun readsSpecMultiValueForm() {
val r =
repo(
arrayOf(
arrayOf("d", "x"),
arrayOf("clone", "https://a.git", "https://b.git"),
arrayOf("web", "https://a.com", "https://b.com"),
),
)
assertEquals(listOf("https://a.git", "https://b.git"), r.clones())
assertEquals(listOf("https://a.com", "https://b.com"), r.webs())
}
@Test
fun readsLegacyRepeatedForm() {
val r =
repo(
arrayOf(
arrayOf("d", "x"),
arrayOf("clone", "https://a.git"),
arrayOf("clone", "https://b.git"),
arrayOf("web", "https://a.com"),
arrayOf("web", "https://b.com"),
),
)
assertEquals(listOf("https://a.git", "https://b.git"), r.clones())
assertEquals(listOf("https://a.com", "https://b.com"), r.webs())
}
@Test
fun issueCarriesRepositoryOwnerPTag() {
val repoEvent = repo(arrayOf(arrayOf("d", "x"), arrayOf("name", "x")))
val tmpl = GitIssueEvent.build("subject", "body", EventHintBundle(repoEvent), emptyList(), emptyList())
val pTags = tmpl.tags.filter { it[0] == "p" }.map { it[1] }
assertTrue(owner in pTags, "issue must p-tag the repository owner for maintainer routing")
}
@Test
fun patchRTagIsPlainWithoutEucMarker() {
val repoEvent = repo(arrayOf(arrayOf("d", "x"), arrayOf("r", "rootcommit", "euc")))
val tmpl =
GitPatchEvent.build(
patch = "diff",
repository = EventHintBundle(repoEvent),
earliestUniqueCommit = "rootcommit",
commit = "c1",
)
assertEquals(listOf("r", "rootcommit"), tmpl.tags.first { it[0] == "r" }.toList(), "patch r tag must be plain (no euc marker)")
}
}