From ad66e6c22422567011d29ea6931832283f4bce9a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 23:03:43 +0000 Subject: [PATCH 01/11] feat(cli): full NIP-34 git collaboration parity for `amy git` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend `amy git` from repo announce/list/show/issue to the complete pure-Nostr surface of `ngit` and `nak git`, so every NIP-34 collaboration flow is scriptable without a GUI. New sub-verbs (all thin assembly over quartz's `nip34Git` builders): - `git state` — kind:30618 repository state (branch/tag tips + HEAD) - `git patch` — kind:1617 patch from `git format-patch` (--file or stdin), with --root/--root-revision, --commit, --parent-commit, and --in-reply-to for revision chains - `git pr` / `git pr-update` — kind:1618 pull request + kind:1619 tip update - `git comment` — NIP-22 kind:1111 reply on an issue/patch/PR/repo (the modern replacement for the deprecated kind:1622 git reply) - `git open|applied|close|draft` — kind:1630/1631/1632/1633 status events (aliases `merged`/`resolved` for applied); applied carries --merge-commit / --commit / --patch - `git issues|patches|prs` — list a repo's items with status derived from the newest authoritative (owner/maintainer/author) status event, with --open/--applied/--closed/--draft/--status filters - `git thread` — one item plus its status timeline and comments Shared parsing/fetch/routing glue lives in `GitSupport`; the existing announce/list/show/issue verbs now reuse it. The git *packfile* transport (clone/fetch/push of real objects to clone/GRASP servers) stays out of scope — it needs a git plumbing layer, not an event builder — and is documented as such. Adds `cli/tests/git/git-nip34-headless.sh` (21 assertions, drives the whole flow against `amy serve` and checks the status-deriving reads) and updates the README/ROADMAP command tables. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr --- cli/README.md | 38 ++- cli/ROADMAP.md | 16 +- .../com/vitorpamplona/amethyst/cli/Main.kt | 17 +- .../amethyst/cli/commands/GitCommands.kt | 200 +++++++++------- .../cli/commands/GitCommentCommand.kt | 92 ++++++++ .../amethyst/cli/commands/GitPatchCommands.kt | 131 +++++++++++ .../amethyst/cli/commands/GitPrCommands.kt | 156 +++++++++++++ .../amethyst/cli/commands/GitReadCommands.kt | 211 +++++++++++++++++ .../cli/commands/GitStatusCommands.kt | 177 ++++++++++++++ .../amethyst/cli/commands/GitSupport.kt | 216 ++++++++++++++++++ cli/tests/.gitignore | 1 + cli/tests/README.md | 10 +- cli/tests/git/git-nip34-headless.sh | 201 ++++++++++++++++ 13 files changed, 1365 insertions(+), 101 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommentCommand.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitPatchCommands.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitPrCommands.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitReadCommands.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitStatusCommands.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitSupport.kt create mode 100755 cli/tests/git/git-nip34-headless.sh diff --git a/cli/README.md b/cli/README.md index ceaaed8de6..f60de8e029 100644 --- a/cli/README.md +++ b/cli/README.md @@ -307,14 +307,46 @@ default search relays. ### Git (NIP-34) -nak's `clone`/`push`/`pull` (git-packfile transport over relays/GRASP) are out of scope — these are the metadata + collaboration events. +`amy git` mirrors the pure-Nostr surface of [`ngit`](https://github.com/DanConwayDev/ngit-cli) +and `nak git`: repository announcements + state, patches, pull requests, issues, +threaded NIP-22 comments, and status updates. The git **packfile** transport +(`clone`/`fetch`/`push` of real git objects to clone/GRASP servers) is out of +scope — that needs a git plumbing layer, not an event builder — so `amy git` +publishes and reads the collaboration events, and you clone/push with `git` +itself (or `ngit`). + +Every write verb accepts `[--relay URL[,URL]]` to override the target relays; +the default is the repo's advertised relays, else your outbox. + +**Repository** | Command | What it does | |---|---| -| `amy git announce --name N [--description D] [--clone URL[,URL]] [--web URL[,URL]] [--relay URL[,URL]] [--maintainer HEX[,HEX]] [--hashtag T[,T]] [--earliest-commit C] [--d ID]` | Publish a kind:30617 repository announcement. | +| `amy git announce --name N [--description D] [--clone URL[,URL]] [--web URL[,URL]] [--relay URL[,URL]] [--maintainer HEX[,HEX]] [--hashtag T[,T]] [--earliest-commit C] [--personal-fork] [--d ID]` | Publish a kind:30617 repository announcement. | +| `amy git state REPO\|IDENTIFIER [--head BRANCH] [--branch name=commit[,…]] [--tag name=commit[,…]]` | Publish a kind:30618 repository state (branch/tag tips + HEAD). | | `amy git list [USER]` | List a user's repo announcements (defaults to self). | | `amy git show NADDR\|kind:pubkey:id` | Print one repo announcement (cache-first). | -| `amy git issue NADDR\|coords --subject S [BODY] [--hashtag T[,T]]` | Publish a kind:1621 issue against a repo. BODY from arg or stdin. | + +**Issues, patches & pull requests** + +| Command | What it does | +|---|---| +| `amy git issue REPO --subject S [BODY] [--hashtag T[,T]]` | Publish a kind:1621 issue. BODY from arg or stdin. | +| `amy git patch REPO [--file PATH] [--root\|--root-revision] [--commit C] [--parent-commit P] [--in-reply-to ID]` | Publish a kind:1617 patch. Body is `git format-patch` output from `--file` or stdin. | +| `amy git pr REPO --commit TIP --clone URL[,URL] [--subject S] [--branch-name N] [--merge-base C] [--label L[,L]] [DESC]` | Publish a kind:1618 pull request (references a pushed branch tip by clone URL + commit). | +| `amy git pr-update PR --commit TIP --clone URL[,URL] [--merge-base C]` | Publish a kind:1619 update to a pull request's tip. | +| `amy git issues\|patches\|prs REPO [--open\|--applied\|--closed\|--draft\|--status a,b] [--limit N]` | List a repo's issues / patches / PRs with their derived status. | +| `amy git thread EVENT_ID` | Print one item plus its status timeline and comments. | + +**Comments & status** + +| Command | What it does | +|---|---| +| `amy git comment TARGET [BODY]` | Reply to an issue/patch/PR/repo with a NIP-22 kind:1111 comment. BODY from arg or stdin. | +| `amy git open TARGET [MSG]` | Publish a kind:1630 status (open / reopen / ready-for-review). | +| `amy git applied TARGET [MSG] [--merge-commit C] [--commit C[,C]] [--patch ID[,ID]]` | Publish a kind:1631 status (applied / merged / resolved). Aliases: `merged`, `resolved`. | +| `amy git close TARGET [MSG]` | Publish a kind:1632 status (closed). | +| `amy git draft TARGET [MSG]` | Publish a kind:1633 status (draft). | ### Podcasts (NIP-F4) diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 07747f6508..e6506fa3bf 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -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` | ✅ in part | NIP-34 repo announce/list/show/issue. clone/push (packfile transport) out of scope. | +| `git` | `amy git` | ✅ (events) | NIP-34: repo announce (30617) + state (30618), patches (1617), pull requests (1618/1619), issues (1621), NIP-22 comments (1111), status open/applied/closed/draft (1630-1633); `issues`/`patches`/`prs`/`thread` reads derive status. clone/push (git-packfile transport) out of scope. | | `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`. | @@ -129,8 +129,9 @@ nak has 34 functional commands (introspected from `nak --help`). Coverage: Protocol-sensitive ones (`bunker`, `sync`, `key` NIP-49, `encode`/`decode`, `admin`) are interop-verified against the real `nak` binary or `amy serve`. - **Partial / adapted (3):** `key` (no `expand`/`combine`(MuSig2)/`default`), - `git` (NIP-34 events only — no packfile transport), `outbox` (NIP-65 model vs - nak's local hints DB). + `git` (full NIP-34 event surface — announce/state/patch/PR/issue/comment/status + + status-deriving reads — but no git-packfile clone/push transport), `outbox` + (NIP-65 model vs nak's local hints DB). - **Missing (6):** `dekey` (NIP-4E), `mcp`, `curl` (NIP-98), `fs` (FUSE), `spell` (MuSig2/FROST), and `validate` (event-schema validation). @@ -173,10 +174,11 @@ move anything, re-audit — you're probably duplicating logic. 8. **Distribution** — Homebrew + Scoop + `.deb` in the same release pipeline as desktop. Plan: `cli/plans/2026-04-21-cli-distribution.md`. 9. **Test suite** — largely in place, two layers: - - **Shell harnesses** under `cli/tests/` — nine suites: `blossom` - (live servers), `cache`, `clink`, `dm`, `marmot` (vs whitenoise-rs), - `nests` (manual audio-rooms matrix), `pow`, `relaygroup`, `sync`, - plus the shared `headless/` helpers. See `cli/tests/README.md`. + - **Shell harnesses** under `cli/tests/` — ten suites: `blossom` + (live servers), `cache`, `clink`, `dm`, `git` (NIP-34 vs `amy serve`), + `marmot` (vs whitenoise-rs), `nests` (manual audio-rooms matrix), `pow`, + `relaygroup`, `sync`, plus the shared `headless/` helpers. See + `cli/tests/README.md`. None run in CI yet (the relay-backed ones need Rust + a ~3 min cold `nostr-rs-relay` build). - **JVM unit suite** at `cli/src/test/kotlin/` — `Args` parsing, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 541d1cb45f..b6e2b4b877 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -628,10 +628,23 @@ private fun printUsage() { | [--clone URL[,URL]] [--web URL[,URL]] (--d sets the identifier; defaults to name) | [--relay URL[,URL]] [--maintainer HEX[,]] | [--hashtag T[,T]] [--earliest-commit C] [--d ID] + | git state REPO [--head B] [--branch n=c[,…]] publish a kind:30618 repository state + | [--tag n=c[,…]] | git list [USER] list a user's repo announcements (default self) | git show NADDR|kind:pubkey:id print one repo announcement - | git issue NADDR|coords --subject S [BODY] publish a kind:1621 issue against a repo - | [--hashtag T[,T]] [--relay URL[,URL]] (BODY from arg or stdin) + | git issue REPO --subject S [BODY] publish a kind:1621 issue against a repo + | [--hashtag T[,T]] (BODY from arg or stdin) + | git patch REPO [--file P] [--root] publish a kind:1617 patch (format-patch/stdin) + | [--commit C] [--parent-commit P] [--in-reply-to ID] + | git pr REPO --commit TIP --clone URL[,URL] publish a kind:1618 pull request [DESC] + | [--subject S] [--branch-name N] [--merge-base C] [--label L[,L]] + | git pr-update PR --commit TIP --clone URL publish a kind:1619 pull-request update + | git comment TARGET [BODY] NIP-22 kind:1111 comment on issue/patch/PR/repo + | git open|applied|close|draft TARGET [MSG] publish a kind:1630/1631/1632/1633 status + | git issues|patches|prs REPO list a repo's issues/patches/PRs + status + | [--open|--applied|--closed|--draft] [--limit N] + | git thread EVENT_ID print an item + status timeline + comments + | (git-packfile clone/push transport is out of scope — see cli/ROADMAP.md) | |Podcasts (NIP-F4): | podcast metadata --title T --image URL publish kind:10154 show metadata diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt index e4aeaf7232..9079a7ceb2 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt @@ -25,43 +25,61 @@ import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.quartz.nip01Core.core.Address -import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer -import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent +import com.vitorpamplona.quartz.nip34Git.state.GitRepositoryStateEvent +import com.vitorpamplona.quartz.nip34Git.state.tags.RefTag /** - * `amy git ` — NIP-34 Nostr-native git - * repositories (nak's `git`, adapted to amy's event-publish model). + * `amy git ` — NIP-34 Nostr-native git collaboration, adapted to amy's + * event-publish model. Mirrors the pure-Nostr surface of `nak git` and `ngit`: + * repository announcements + state, patches, pull requests, issues, threaded + * comments (NIP-22), and status updates. Thin assembly only — every event lives + * in quartz's `nip34Git` package; the shared glue is in [GitSupport]. * - * announce publish a kind:30617 repository announcement - * list list a user's repository announcements - * show print one repository announcement (naddr or coordinates) - * issue publish a kind:1621 issue against a repository - * - * nak's clone/push/pull (git-packfile transport over relays/GRASP) are out - * of scope — they need a real git plumbing layer. These are the metadata / - * collaboration events. Thin assembly only: every event lives in quartz - * (`GitRepositoryEvent`, `GitIssueEvent`). + * Out of scope: the git *packfile* transport (`clone` / `fetch` / `push` of real + * git objects to clone/GRASP servers). That needs a git plumbing layer, not an + * event builder — see `cli/ROADMAP.md`. */ object GitCommands { val USAGE: String = """ - |amy git — NIP-34 Nostr-native git repositories + |amy git — NIP-34 Nostr-native git collaboration | - | git announce --name N [--description D] publish a kind:30617 repo announcement - | [--clone URL[,URL]] [--web URL[,URL]] (--d / --identifier sets the identifier; - | [--relay URL[,URL]] [--maintainer HEX[,]] defaults to name) + |Repository: + | git announce --name N [--description D] publish a kind:30617 repo announcement + | [--clone URL[,URL]] [--web URL[,URL]] (--d / --identifier sets the identifier; + | [--relay URL[,URL]] [--maintainer HEX[,]] defaults to name) | [--hashtag T[,T]] [--earliest-commit C] | [--personal-fork] [--d ID | --identifier ID] - | git list [USER] [--relay URL[,URL]] list a user's repo announcements (default self) - | git show NADDR|kind:pubkey:id print one repo announcement - | [--relay URL[,URL]] - | git issue NADDR|coords --subject S [BODY] publish a kind:1621 issue against a repo - | [--hashtag T[,T]] [--relay URL[,URL]] (BODY from arg or stdin) + | git state REPO|IDENTIFIER publish a kind:30618 repository state + | [--head BRANCH] [--branch name=commit[,…]] (branches/tags as name=commit CSV) + | [--tag name=commit[,…]] + | git list [USER] list a user's repo announcements (default self) + | git show NADDR|kind:pubkey:id print one repo announcement + | + |Issues / patches / pull requests: + | git issue REPO --subject S [BODY] publish a kind:1621 issue (BODY arg or stdin) + | [--hashtag T[,T]] + | git patch REPO [--file PATH] publish a kind:1617 patch (git format-patch + | [--root|--root-revision] [--commit C] from --file or stdin) + | [--parent-commit P] [--in-reply-to ID] + | git pr REPO --commit TIP --clone URL[,URL] publish a kind:1618 pull request [DESC arg] + | [--subject S] [--branch-name N] [--merge-base C] [--label L[,L]] + | git pr-update PR --commit TIP --clone URL[,URL] publish a kind:1619 pull-request update + | git issues|patches|prs REPO list a repo's issues/patches/PRs + status + | [--open|--applied|--closed|--draft|--status a,b] [--limit N] + | git thread EVENT_ID print one item + its status timeline + comments + | + |Comments & status: + | git comment TARGET [BODY] NIP-22 kind:1111 comment (BODY arg or stdin) + | git open|applied|close|draft TARGET [MESSAGE] publish a kind:1630/1631/1632/1633 status + | applied: [--merge-commit C] [--commit C[,C]] [--patch ID[,ID]] + | + |Every write verb takes [--relay URL[,URL]] to override the target relays + |(default: the repo's advertised relays, else your outbox). """.trimMargin() suspend fun dispatch( @@ -71,12 +89,27 @@ object GitCommands { route( "git", tail, - "git ", + "git ", mapOf( "announce" to { rest -> announce(dataDir, rest) }, + "state" to { rest -> state(dataDir, rest) }, "list" to { rest -> list(dataDir, rest) }, "show" to { rest -> show(dataDir, rest) }, "issue" to { rest -> issue(dataDir, rest) }, + "issues" to { rest -> GitReadCommands.issues(dataDir, rest) }, + "patch" to { rest -> GitPatchCommands.patch(dataDir, rest) }, + "patches" to { rest -> GitReadCommands.patches(dataDir, rest) }, + "pr" to { rest -> GitPrCommands.pr(dataDir, rest) }, + "pr-update" to { rest -> GitPrCommands.prUpdate(dataDir, rest) }, + "prs" to { rest -> GitReadCommands.prs(dataDir, rest) }, + "thread" to { rest -> GitReadCommands.thread(dataDir, rest) }, + "comment" to { rest -> GitCommentCommand.comment(dataDir, rest) }, + "open" to { rest -> GitStatusCommands.open(dataDir, rest) }, + "applied" to { rest -> GitStatusCommands.applied(dataDir, rest) }, + "merged" to { rest -> GitStatusCommands.applied(dataDir, rest) }, + "resolved" to { rest -> GitStatusCommands.applied(dataDir, rest) }, + "close" to { rest -> GitStatusCommands.close(dataDir, rest) }, + "draft" to { rest -> GitStatusCommands.draft(dataDir, rest) }, ), help = USAGE, ) @@ -91,14 +124,6 @@ object GitCommands { // eagerly so passing both spellings doesn't trip rejectUnknown(). val identifierAlias = args.flag("identifier") val identifier = args.flag("d") ?: identifierAlias ?: name - val csv = { key: String -> - args - .flag(key) - ?.split(',') - ?.map { it.trim() } - ?.filter { it.isNotEmpty() } - .orEmpty() - } Context.open(dataDir).use { ctx -> ctx.prepare() @@ -106,11 +131,11 @@ object GitCommands { GitRepositoryEvent.build( name = name, description = args.flag("description"), - webUrls = csv("web"), - cloneUrls = csv("clone"), - relays = csv("relay"), - maintainers = csv("maintainer"), - hashtags = csv("hashtag"), + webUrls = GitSupport.csv(args, "web"), + cloneUrls = GitSupport.csv(args, "clone"), + relays = GitSupport.csv(args, "relay"), + maintainers = GitSupport.csv(args, "maintainer"), + hashtags = GitSupport.csv(args, "hashtag"), earliestUniqueCommit = args.flag("earliest-commit"), personalFork = args.bool("personal-fork"), dTag = identifier, @@ -130,6 +155,49 @@ object GitCommands { } } + private suspend fun state( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val ref = args.positional(0, "repo-identifier-or-naddr") + val addr = GitSupport.resolveAddress(ref) + val dTag = addr?.dTag ?: ref + val head = args.flag("head") + val branches = GitSupport.keyValueCsv(args, "branch") + val tagRefs = GitSupport.keyValueCsv(args, "tag") + args.rejectUnknown("relay") + if (branches.isEmpty() && tagRefs.isEmpty() && head == null) { + return Output.error("bad_args", "git state needs at least one --branch, --tag, or --head") + } + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val refs = + branches.map { RefTag.branch(it.first, it.second) } + + tagRefs.map { RefTag.tag(it.first, it.second) } + val template = GitRepositoryStateEvent.build(dTag = dTag, refs = refs, head = head) + val signed = ctx.signer.sign(template) + // Deliver to the announcement's advertised relays (the announcement may + // be someone else's when we're a maintainer publishing state for it). + val announceOwner = addr?.pubKeyHex ?: ctx.identity.pubKeyHex + val repo = GitSupport.fetchRepo(ctx, Address(GitRepositoryEvent.KIND, announceOwner, dTag), args) + val targets = GitSupport.deliveryTargets(ctx, repo, args) + val ack = ctx.publish(signed, targets) + RawEventSupport.publishGuard(ack, signed.id)?.let { return it } + Output.emit( + mapOf( + "event_id" to signed.id, + "address" to Address.assemble(GitRepositoryStateEvent.KIND, signed.pubKey, dTag), + "branches" to branches.size, + "tags" to tagRefs.size, + "head" to head, + ) + RawEventSupport.ackFields(ack), + ) + return 0 + } + } + private suspend fun list( dataDir: DataDir, rest: Array, @@ -165,14 +233,14 @@ object GitCommands { val coord = args.positional(0, "naddr-or-coordinates") // `--relay` is read later inside fetchRepo's queryTargets. args.rejectUnknown("relay") - val addr = resolveAddress(coord) ?: return Output.error("bad_args", "expected an naddr or kind:pubkey:identifier (or pubkey:identifier)") + val addr = GitSupport.resolveAddress(coord) ?: return Output.error("bad_args", "expected an naddr or kind:pubkey:identifier (or pubkey:identifier)") if (addr.kind != GitRepositoryEvent.KIND) { return Output.error("bad_args", "not a git repository address (expected kind ${GitRepositoryEvent.KIND}, got ${addr.kind})") } Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() - val repo = fetchRepo(ctx, addr, args) ?: return Output.error("not_found", "no repository announcement found for $coord") + val repo = GitSupport.fetchRepo(ctx, addr, args) ?: return Output.error("not_found", "no repository announcement found for $coord") Output.emit(repoSummary(repo) + mapOf("event_id" to repo.id, "content" to repo.content)) return 0 } @@ -185,21 +253,15 @@ object GitCommands { val args = Args(rest) val coord = args.positional(0, "repo-naddr-or-coordinates") val subject = args.flag("subject") ?: return Output.error("bad_args", "git issue requires --subject") - val addr = resolveAddress(coord) ?: return Output.error("bad_args", "expected an naddr or kind:pubkey:identifier") + val addr = GitSupport.resolveAddress(coord) ?: return Output.error("bad_args", "expected an naddr or kind:pubkey:identifier") val body = args.positionalOrNull(1) ?: "" - val topics = - args - .flag("hashtag") - ?.split(',') - ?.map { it.trim() } - ?.filter { it.isNotEmpty() } - .orEmpty() - // `--relay` is read later (relayFlag + fetchRepo's queryTargets). + val topics = GitSupport.csv(args, "hashtag") + // `--relay` is read later (deliveryTargets + fetchRepo's queryTargets). args.rejectUnknown("relay") Context.open(dataDir).use { ctx -> ctx.prepare() - val repo = fetchRepo(ctx, addr, args) ?: return Output.error("not_found", "no repository announcement found for $coord") + val repo = GitSupport.fetchRepo(ctx, addr, args) ?: return Output.error("not_found", "no repository announcement found for $coord") val template = GitIssueEvent.build( subject = subject, @@ -209,9 +271,7 @@ object GitCommands { topics = topics, ) val signed = ctx.signer.sign(template) - // Deliver to the repo's advertised relays when present, else our targets. - val repoRelays = repo.relays().mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet() - val targets = RawEventSupport.relayFlag(args).ifEmpty { repoRelays }.ifEmpty { ctx.outboxRelays() } + val targets = GitSupport.deliveryTargets(ctx, repo, args) val ack = ctx.publish(signed, targets) RawEventSupport.publishGuard(ack, signed.id)?.let { return it } Output.emit( @@ -226,42 +286,6 @@ object GitCommands { } } - // ------------------------------------------------------------------ - - private suspend fun fetchRepo( - ctx: Context, - addr: Address, - args: Args, - ): GitRepositoryEvent? { - // Cache-first, then drain the query relays. - val filter = - Filter( - kinds = listOf(GitRepositoryEvent.KIND), - authors = listOf(addr.pubKeyHex), - tags = mapOf("d" to listOf(addr.dTag)), - limit = 1, - ) - ctx.store - .query(filter) - .firstOrNull() - ?.let { return it as? GitRepositoryEvent } - val relays = RawEventSupport.queryTargets(ctx, args) - ctx.drain(relays.associateWith { listOf(filter) }) - return ctx.store.query(filter).firstOrNull() as? GitRepositoryEvent - } - - /** Accept `naddr1…`, `kind:pubkey:dtag`, or `pubkey:dtag` (kind defaults to 30617). */ - private fun resolveAddress(input: String): Address? { - val trimmed = input.trim().removePrefix("nostr:") - if (trimmed.startsWith("naddr")) { - val n = NAddress.parse(trimmed) ?: return null - return Address(n.kind, n.author, n.dTag) - } - Address.parse(trimmed)?.let { return it } - val parts = trimmed.split(":") - return if (parts.size == 2 && parts[0].length == 64) Address(GitRepositoryEvent.KIND, parts[0], parts[1]) else null - } - private fun repoSummary(repo: GitRepositoryEvent): Map = mapOf( "identifier" to repo.dTag(), diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommentCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommentCommand.kt new file mode 100644 index 0000000000..48ac8de712 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommentCommand.kt @@ -0,0 +1,92 @@ +/* + * 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.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent + +/** + * `amy git comment TARGET [BODY]` — reply to a NIP-34 issue, patch, pull + * request, or repository with a NIP-22 kind:1111 comment (the modern + * replacement for the deprecated kind:1622 git reply). TARGET is a + * note/nevent/64-hex event id, or an naddr / `kind:pubkey:id` repo coordinate. + * BODY comes from the argument or stdin. + */ +object GitCommentCommand { + suspend fun comment( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val targetRef = args.positional(0, "target-event-or-repo") + val body = (args.positionalOrNull(1) ?: System.`in`.readBytes().decodeToString()).trim() + if (body.isBlank()) return Output.error("bad_args", "empty comment (pass BODY as an argument or on stdin)") + args.rejectUnknown("relay") + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val target = + resolveTarget(ctx, targetRef, args) + ?: return Output.error("not_found", "no event or repository found for $targetRef") + val template = CommentEvent.replyBuilder(body, EventHintBundle(target)) + val signed = ctx.signer.sign(template) + + // Deliver to the repository's advertised relays when we can find them. + val repo = + (target as? GitRepositoryEvent) + ?: GitSupport.repositoryOf(target)?.let { GitSupport.fetchRepo(ctx, Address(it.kind, it.pubKeyHex, it.dTag), args) } + val targets = GitSupport.deliveryTargets(ctx, repo, args) + val ack = ctx.publish(signed, targets) + RawEventSupport.publishGuard(ack, signed.id)?.let { return it } + Output.emit( + mapOf( + "event_id" to signed.id, + "kind" to signed.kind, + "in_reply_to" to target.id, + "target_kind" to target.kind, + ) + RawEventSupport.ackFields(ack), + ) + return 0 + } + } + + /** Resolve TARGET as an event id first, then fall back to a repo coordinate. */ + private suspend fun resolveTarget( + ctx: Context, + ref: String, + args: Args, + ): Event? { + GitSupport.resolveEventId(ref)?.let { id -> + GitSupport.fetchEvent(ctx, id, args)?.let { return it } + } + GitSupport.resolveAddress(ref)?.let { addr -> + if (addr.kind == GitRepositoryEvent.KIND) return GitSupport.fetchRepo(ctx, addr, args) + } + return null + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitPatchCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitPatchCommands.kt new file mode 100644 index 0000000000..8f5a8f5d3a --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitPatchCommands.kt @@ -0,0 +1,131 @@ +/* + * 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.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent +import java.io.File + +/** + * `amy git patch REPO` — publish a NIP-34 kind:1617 patch against a repository. + * + * The patch body is the raw `git format-patch` output, read from `--file PATH` + * or (by default) stdin, so the natural pipeline is: + * + * git format-patch --stdout HEAD~1 | amy git patch nostr:naddr1… --root + * + * Thin assembly only — every tag lives in quartz's [GitPatchEvent]. + */ +object GitPatchCommands { + suspend fun patch( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val coord = args.positional(0, "repo-naddr-or-coordinates") + val addr = + GitSupport.resolveAddress(coord) + ?: return Output.error("bad_args", "expected an naddr or kind:pubkey:identifier") + val root = args.bool("root") + val rootRevision = args.bool("root-revision") + val commit = args.flag("commit") + val parentCommit = args.flag("parent-commit") + val eucOverride = args.flag("earliest-commit") + val replyTo = args.flag("in-reply-to") + val file = args.flag("file") + // `--relay` is consumed later by deliveryTargets / fetchRepo's queryTargets. + args.rejectUnknown("relay") + + val body = readPatch(file) + if (body.isBlank()) return Output.error("bad_args", "empty patch (pass --file PATH or pipe `git format-patch` to stdin)") + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val repo = + GitSupport.fetchRepo(ctx, addr, args) + ?: return Output.error("not_found", "no repository announcement found for $coord") + val euc = + repo.earliestUniqueCommit() + ?: eucOverride + ?: return Output.error( + "bad_args", + "repository announcement has no earliest-unique-commit; pass --earliest-commit ", + ) + val repoBundle = EventHintBundle(repo) + + val template = + if (replyTo != null) { + val replyId = + GitSupport.resolveEventId(replyTo) + ?: return Output.error("bad_args", "--in-reply-to expects a note/nevent/64-hex, got '$replyTo'") + val prior = + GitSupport.fetchEvent(ctx, replyId, args) as? GitPatchEvent + ?: return Output.error("not_found", "no patch found to reply to: $replyTo") + GitPatchEvent.reply( + patch = body, + repository = repoBundle, + earliestUniqueCommit = euc, + replyingTo = EventHintBundle(prior), + commit = commit, + parentCommit = parentCommit, + ) + } else { + GitPatchEvent.build( + patch = body, + repository = repoBundle, + earliestUniqueCommit = euc, + commit = commit, + parentCommit = parentCommit, + root = root, + rootRevision = rootRevision, + ) + } + + val signed = ctx.signer.sign(template) + val targets = GitSupport.deliveryTargets(ctx, repo, args) + val ack = ctx.publish(signed, targets) + RawEventSupport.publishGuard(ack, signed.id)?.let { return it } + Output.emit( + mapOf( + "event_id" to signed.id, + "kind" to signed.kind, + "repository" to Address.assemble(addr.kind, addr.pubKeyHex, addr.dTag), + "subject" to (signed as? GitPatchEvent)?.subject(), + ) + RawEventSupport.ackFields(ack), + ) + return 0 + } + } + + /** Read the patch body from [file] when given, otherwise from stdin. */ + private fun readPatch(file: String?): String = + if (file != null) { + File(file).takeIf { it.isFile }?.readText() + ?: throw IllegalArgumentException("--file not found: $file") + } else { + System.`in`.readBytes().decodeToString() + }.trim() +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitPrCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitPrCommands.kt new file mode 100644 index 0000000000..3e5ce3a04b --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitPrCommands.kt @@ -0,0 +1,156 @@ +/* + * 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.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent +import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent + +/** + * `amy git pr REPO` (kind:1618) and `amy git pr-update PR` (kind:1619) — + * branch-based NIP-34 contributions that reference a pushed tip by clone URL + + * commit id instead of inlining a patch. The actual git branch push (to a clone + * or GRASP server) is out of scope — amy only publishes the collaboration event. + */ +object GitPrCommands { + suspend fun pr( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val coord = args.positional(0, "repo-naddr-or-coordinates") + val addr = + GitSupport.resolveAddress(coord) + ?: return Output.error("bad_args", "expected an naddr or kind:pubkey:identifier") + val currentCommit = args.flag("commit") ?: return Output.error("bad_args", "git pr requires --commit ") + val cloneUrls = GitSupport.csv(args, "clone") + if (cloneUrls.isEmpty()) return Output.error("bad_args", "git pr requires --clone [,] where the branch tip can be fetched") + val subject = args.flag("subject") + val branchName = args.flag("branch-name") + val mergeBase = args.flag("merge-base") + val labels = GitSupport.csv(args, "label") + val eucOverride = args.flag("earliest-commit") + val description = args.positionalOrNull(1) ?: "" + args.rejectUnknown("relay") + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val repo = + GitSupport.fetchRepo(ctx, addr, args) + ?: return Output.error("not_found", "no repository announcement found for $coord") + val euc = + repo.earliestUniqueCommit() + ?: eucOverride + ?: return Output.error("bad_args", "repository announcement has no earliest-unique-commit; pass --earliest-commit ") + + val template = + GitPullRequestEvent.build( + description = description, + repository = EventHintBundle(repo), + earliestUniqueCommit = euc, + currentCommit = currentCommit, + cloneUrls = cloneUrls, + subject = subject, + labels = labels, + branchName = branchName, + mergeBase = mergeBase, + ) + val signed = ctx.signer.sign(template) + val targets = GitSupport.deliveryTargets(ctx, repo, args) + val ack = ctx.publish(signed, targets) + RawEventSupport.publishGuard(ack, signed.id)?.let { return it } + Output.emit( + mapOf( + "event_id" to signed.id, + "kind" to signed.kind, + "repository" to Address.assemble(addr.kind, addr.pubKeyHex, addr.dTag), + "subject" to subject, + "current_commit" to currentCommit, + ) + RawEventSupport.ackFields(ack), + ) + return 0 + } + } + + suspend fun prUpdate( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val prRef = args.positional(0, "pull-request-id") + val prId = + GitSupport.resolveEventId(prRef) + ?: return Output.error("bad_args", "expected a note/nevent/64-hex pull-request id") + val currentCommit = args.flag("commit") ?: return Output.error("bad_args", "git pr-update requires --commit ") + val cloneUrls = GitSupport.csv(args, "clone") + if (cloneUrls.isEmpty()) return Output.error("bad_args", "git pr-update requires --clone [,]") + val mergeBase = args.flag("merge-base") + val eucOverride = args.flag("earliest-commit") + args.rejectUnknown("relay") + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val parent = + GitSupport.fetchEvent(ctx, prId, args) as? GitPullRequestEvent + ?: return Output.error("not_found", "no pull request (kind 1618) found for $prRef") + val repoAddr = + parent.repositoryAddress() + ?: return Output.error("bad_args", "pull request $prRef carries no repository address") + val repo = + GitSupport.fetchRepo(ctx, repoAddr, args) + ?: return Output.error("not_found", "no repository announcement found for ${GitSupport.repoCoordinate(repoAddr)}") + val euc = + repo.earliestUniqueCommit() + ?: parent.earliestUniqueCommit() + ?: eucOverride + ?: return Output.error("bad_args", "no earliest-unique-commit available; pass --earliest-commit ") + + val template = + GitPullRequestUpdateEvent.build( + parentPullRequest = EventHintBundle(parent), + repository = EventHintBundle(repo), + earliestUniqueCommit = euc, + currentCommit = currentCommit, + cloneUrls = cloneUrls, + mergeBase = mergeBase, + ) + val signed = ctx.signer.sign(template) + val targets = GitSupport.deliveryTargets(ctx, repo, args) + val ack = ctx.publish(signed, targets) + RawEventSupport.publishGuard(ack, signed.id)?.let { return it } + Output.emit( + mapOf( + "event_id" to signed.id, + "kind" to signed.kind, + "pull_request" to parent.id, + "current_commit" to currentCommit, + ) + RawEventSupport.ackFields(ack), + ) + return 0 + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitReadCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitReadCommands.kt new file mode 100644 index 0000000000..27523910e6 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitReadCommands.kt @@ -0,0 +1,211 @@ +/* + * 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.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent +import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent +import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent +import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent +import com.vitorpamplona.quartz.nip34Git.status.GitStatusEvent + +/** + * Read-side `amy git` verbs — list a repository's issues / patches / pull + * requests with their derived status, and print one collaboration thread with + * its status timeline and comments. All read-only (anonymous-capable). + */ +object GitReadCommands { + private val STATUS_KINDS = + listOf( + GitStatusEvent.KIND_OPEN, + GitStatusEvent.KIND_APPLIED, + GitStatusEvent.KIND_CLOSED, + GitStatusEvent.KIND_DRAFT, + ) + + suspend fun issues( + dataDir: DataDir, + rest: Array, + ): Int = listItems(dataDir, rest, GitIssueEvent.KIND) + + suspend fun patches( + dataDir: DataDir, + rest: Array, + ): Int = listItems(dataDir, rest, GitPatchEvent.KIND) + + suspend fun prs( + dataDir: DataDir, + rest: Array, + ): Int = listItems(dataDir, rest, GitPullRequestEvent.KIND) + + /** Shared list path for issues (1621) / patches (1617) / pull requests (1618). */ + private suspend fun listItems( + dataDir: DataDir, + rest: Array, + itemKind: Int, + ): Int { + val args = Args(rest) + val coord = args.positional(0, "repo-naddr-or-coordinates") + val addr = + GitSupport.resolveAddress(coord) + ?: return Output.error("bad_args", "expected an naddr or kind:pubkey:identifier") + val limit = args.intFlag("limit", 100) + val wanted = statusFilter(args) + args.rejectUnknown("relay") + + Context.openOrAnonymous(dataDir).use { ctx -> + ctx.prepare() + val repoAddress = GitSupport.repoCoordinate(addr) + val relays = RawEventSupport.queryTargets(ctx, args) + // One drain pulls the items AND their status events (both `a`-tag the repo). + val received = + ctx.drain( + relays.associateWith { + listOf(Filter(kinds = listOf(itemKind) + STATUS_KINDS, tags = mapOf("a" to listOf(repoAddress)), limit = limit + 200)) + }, + ) + val events = received.map { it.second } + val authorities = repoAuthorities(ctx, addr, args) + val statuses = events.filterIsInstance() + + val items = + events + .filter { it.kind == itemKind } + .distinctBy { it.id } + .sortedByDescending { it.createdAt } + .map { item -> + val status = latestStatus(item, statuses, authorities) + GitSupport.targetSummary(item) + mapOf("status" to status) + }.filter { wanted == null || it["status"] in wanted } + .take(limit) + + Output.emit(mapOf("repository" to repoAddress, "count" to items.size, "items" to items)) + return 0 + } + } + + /** + * `amy git thread TARGET` — the target event plus its status timeline and + * NIP-22 comments (and legacy kind:1622 replies). + */ + suspend fun thread( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val ref = args.positional(0, "target-event-id") + val id = + GitSupport.resolveEventId(ref) + ?: return Output.error("bad_args", "expected a note/nevent/64-hex event id") + args.rejectUnknown("relay") + + Context.openOrAnonymous(dataDir).use { ctx -> + ctx.prepare() + val target = + GitSupport.fetchEvent(ctx, id, args) + ?: return Output.error("not_found", "no event found for $ref") + val relays = RawEventSupport.queryTargets(ctx, args) + // Everything that `e`-references the target: statuses, comments, replies. + val related = + ctx + .drain( + relays.associateWith { + listOf(Filter(kinds = STATUS_KINDS + listOf(CommentEvent.KIND, GitReplyEvent.KIND), tags = mapOf("e" to listOf(id)))) + }, + ).map { it.second } + .distinctBy { it.id } + + val repoATag = GitSupport.repositoryOf(target) + val authorities = repoATag?.let { repoAuthorities(ctx, Address(it.kind, it.pubKeyHex, it.dTag), args) } ?: setOf(target.pubKey) + val statuses = related.filterIsInstance().filter { it.rootEventId() == id } + val comments = + related + .filter { it.kind == CommentEvent.KIND || it.kind == GitReplyEvent.KIND } + .sortedBy { it.createdAt } + .map { mapOf("event_id" to it.id, "kind" to it.kind, "author" to it.pubKey, "created_at" to it.createdAt, "content" to it.content) } + + Output.emit( + GitSupport.targetSummary(target) + + mapOf( + "content" to target.content, + "status" to latestStatus(target, statuses, authorities), + "status_events" to + statuses.sortedBy { it.createdAt }.map { + mapOf("event_id" to it.id, "status" to GitSupport.statusLabel(it.kind), "author" to it.pubKey, "created_at" to it.createdAt) + }, + "comments" to comments, + ), + ) + return 0 + } + } + + // ------------------------------------------------------------------ + + /** The set of pubkeys whose status is authoritative for a repo: the owner + declared maintainers. */ + private suspend fun repoAuthorities( + ctx: Context, + addr: Address, + args: Args, + ): Set { + val repo = GitSupport.fetchRepo(ctx, addr, args) + return buildSet { + add(addr.pubKeyHex) + repo?.maintainers()?.let { addAll(it) } + } + } + + /** + * The authoritative status label for [item]: the newest status event (by + * `created_at`) that `e`-roots this item and is signed by the item author, + * the repo owner, or a maintainer. Defaults to `open` when none exists. + */ + private fun latestStatus( + item: Event, + statuses: List, + authorities: Set, + ): String { + val allowed = authorities + item.pubKey + val newest = + statuses + .filter { it.rootEventId() == item.id && it.pubKey in allowed } + .maxByOrNull { it.createdAt } + return GitSupport.statusLabel(newest?.kind) + } + + /** Translate `--status a,b` plus the `--open/--applied/--closed/--draft/--all` bools into a wanted set (null = all). */ + private fun statusFilter(args: Args): Set? { + val explicit = GitSupport.csv(args, "status").toMutableSet() + if (args.bool("open")) explicit.add("open") + if (args.bool("applied")) explicit.add("applied") + if (args.bool("closed")) explicit.add("closed") + if (args.bool("draft")) explicit.add("draft") + args.bool("all") // consumed; means "no filter" + return explicit.takeIf { it.isNotEmpty() } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitStatusCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitStatusCommands.kt new file mode 100644 index 0000000000..902dd750e7 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitStatusCommands.kt @@ -0,0 +1,177 @@ +/* + * 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.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent +import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent +import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent +import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent +import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent + +/** + * `amy git open|applied|close|draft TARGET` — publish a NIP-34 status event + * (kinds 1630 / 1631 / 1632 / 1633) against a patch, pull request, or issue. + * + * open 1630 — mark open / reopen / ready-for-review + * applied 1631 — mark applied / merged (patches, PRs) or resolved (issues) + * close 1632 — close without applying + * draft 1633 — move back to draft + * + * The newest status from the root author or a repo maintainer is authoritative + * (NIP-34). amy publishes the event; it does not enforce maintainership. + */ +object GitStatusCommands { + suspend fun open( + dataDir: DataDir, + rest: Array, + ): Int = simpleStatus(dataDir, rest, GitStatusOpenEvent.KIND) + + suspend fun close( + dataDir: DataDir, + rest: Array, + ): Int = simpleStatus(dataDir, rest, GitStatusClosedEvent.KIND) + + suspend fun draft( + dataDir: DataDir, + rest: Array, + ): Int = simpleStatus(dataDir, rest, GitStatusDraftEvent.KIND) + + /** open / close / draft share one shape: `TARGET [MESSAGE]`. */ + private suspend fun simpleStatus( + dataDir: DataDir, + rest: Array, + kind: Int, + ): Int { + val args = Args(rest) + val (targetRef, msg) = args.targetAndMessage() ?: return Output.error("bad_args", "expected a note/nevent/64-hex target id") + args.rejectUnknown("relay") + + return Context.open(dataDir).use { ctx -> + ctx.prepare() + val target = ctx.resolveTarget(targetRef, args) ?: return@use Output.error("not_found", "no event found for $targetRef") + val repoATag = GitSupport.repositoryOf(target) + val template = + when (kind) { + GitStatusOpenEvent.KIND -> GitStatusOpenEvent.build(msg, EventHintBundle(target)) { repoTags(repoATag, target) } + GitStatusClosedEvent.KIND -> GitStatusClosedEvent.build(msg, EventHintBundle(target)) { repoTags(repoATag, target) } + GitStatusDraftEvent.KIND -> GitStatusDraftEvent.build(msg, EventHintBundle(target)) { repoTags(repoATag, target) } + else -> error("unreachable status kind $kind") + } + ctx.emitStatus(template, target, repoATag, args, kind) + } + } + + /** `applied` (1631) additionally carries merge-commit / applied-as-commits / applied-patch tags. */ + suspend fun applied( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val (targetRef, msg) = args.targetAndMessage() ?: return Output.error("bad_args", "expected a note/nevent/64-hex target id") + val mergeCommit = args.flag("merge-commit") + val appliedAsCommits = GitSupport.csv(args, "commit") + val patchRefs = GitSupport.csv(args, "patch") + args.rejectUnknown("relay") + + return Context.open(dataDir).use { ctx -> + ctx.prepare() + val target = ctx.resolveTarget(targetRef, args) ?: return@use Output.error("not_found", "no event found for $targetRef") + val repoATag = GitSupport.repositoryOf(target) + val appliedPatches = + patchRefs.mapNotNull { ref -> + GitSupport.resolveEventId(ref)?.let { id -> GitSupport.fetchEvent(ctx, id, args) as? GitPatchEvent }?.let { EventHintBundle(it) } + } + val template = + GitStatusAppliedEvent.build( + content = msg, + target = EventHintBundle(target), + appliedPatches = appliedPatches, + mergeCommit = mergeCommit, + appliedAsCommits = appliedAsCommits, + ) { repoTags(repoATag, target) } + ctx.emitStatus(template, target, repoATag, args, GitStatusAppliedEvent.KIND) + } + } + + // ------------------------------------------------------------------ + + /** `TARGET [MESSAGE]` — the shape every status verb shares. */ + private fun Args.targetAndMessage(): Pair? { + val ref = positionalOrNull(0) ?: return null + return ref to (positionalOrNull(1) ?: "") + } + + private suspend fun Context.resolveTarget( + ref: String, + args: Args, + ): Event? { + val id = GitSupport.resolveEventId(ref) ?: return null + return GitSupport.fetchEvent(this, id, args) + } + + /** + * Attach the repository `a` tag and, when it differs from the target author + * (already p-tagged by the shared status builder), the repo owner `p` tag. + */ + private fun TagArrayBuilder.repoTags( + repoATag: ATag?, + target: Event, + ) { + repoATag ?: return + add(repoATag.toATagArray()) + if (repoATag.pubKeyHex != target.pubKey) pTag(repoATag.pubKeyHex) + } + + private suspend fun Context.emitStatus( + template: EventTemplate, + target: Event, + repoATag: ATag?, + args: Args, + kind: Int, + ): Int { + val signed = signer.sign(template) + val repo = repoATag?.let { GitSupport.fetchRepo(this, Address(it.kind, it.pubKeyHex, it.dTag), args) } + val targets = GitSupport.deliveryTargets(this, repo, args) + val ack = publish(signed, targets) + RawEventSupport.publishGuard(ack, signed.id)?.let { return it } + Output.emit( + mapOf( + "event_id" to signed.id, + "kind" to signed.kind, + "status" to GitSupport.statusLabel(kind), + "target" to target.id, + "repository" to repoATag?.toTag(), + ) + RawEventSupport.ackFields(ack), + ) + return 0 + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitSupport.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitSupport.kt new file mode 100644 index 0000000000..7156de0c09 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitSupport.kt @@ -0,0 +1,216 @@ +/* + * 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.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip19Bech32.decodeEventIdAsHexOrNull +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent +import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent +import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent +import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent +import com.vitorpamplona.quartz.nip34Git.status.GitStatusEvent + +/** + * Shared, protocol-free glue for the `amy git` (NIP-34) sub-verbs — address / + * event-id parsing, cache-first fetch helpers, relay routing, and the read-side + * summaries. Every real Nostr piece lives in quartz's `nip34Git` package; this + * object only parses CLI input and shapes the `--json` result maps. + */ +object GitSupport { + /** Accept `naddr1…`, `kind:pubkey:dtag`, or `pubkey:dtag` (kind defaults to 30617). */ + fun resolveAddress(input: String): Address? { + val trimmed = input.trim().removePrefix("nostr:") + if (trimmed.startsWith("naddr")) { + val n = NAddress.parse(trimmed) ?: return null + return Address(n.kind, n.author, n.dTag) + } + Address.parse(trimmed)?.let { return it } + val parts = trimmed.split(":") + return if (parts.size == 2 && parts[0].length == 64) Address(GitRepositoryEvent.KIND, parts[0], parts[1]) else null + } + + /** Decode a `note1…` / `nevent1…` / 64-hex event reference to its raw event id. */ + fun resolveEventId(input: String): String? = decodeEventIdAsHexOrNull(input.trim().removePrefix("nostr:")) + + /** The `["a", …]` coordinate value of a repository announcement (`30617:pubkey:identifier`). */ + fun repoCoordinate(addr: Address): String = Address.assemble(GitRepositoryEvent.KIND, addr.pubKeyHex, addr.dTag) + + /** + * Cache-first fetch of a repository announcement (kind 30617) for [addr], + * draining the query relays only on a store miss. + */ + suspend fun fetchRepo( + ctx: Context, + addr: Address, + args: Args, + ): GitRepositoryEvent? { + val filter = + Filter( + kinds = listOf(GitRepositoryEvent.KIND), + authors = listOf(addr.pubKeyHex), + tags = mapOf("d" to listOf(addr.dTag)), + limit = 1, + ) + ctx.store + .query(filter) + .firstOrNull() + ?.let { return it as? GitRepositoryEvent } + val relays = RawEventSupport.queryTargets(ctx, args) + ctx.drain(relays.associateWith { listOf(filter) }) + return ctx.store.query(filter).firstOrNull() as? GitRepositoryEvent + } + + /** + * Cache-first fetch of any event by its raw [idHex], draining the query + * relays only on a store miss. Used to resolve the patch / PR / issue a + * status, comment, or thread view targets. + */ + suspend fun fetchEvent( + ctx: Context, + idHex: String, + args: Args, + ): Event? { + val filter = Filter(ids = listOf(idHex), limit = 1) + ctx.store + .query(filter) + .firstOrNull() + ?.let { return it } + val relays = RawEventSupport.queryTargets(ctx, args) + ctx.drain(relays.associateWith { listOf(filter) }) + return ctx.store.query(filter).firstOrNull() + } + + /** The repository this issue / patch / PR / status refers to, as an [ATag] (or null). */ + fun repositoryOf(event: Event): ATag? = + when (event) { + is GitIssueEvent -> event.repository() + is GitPatchEvent -> event.repository() + is GitPullRequestEvent -> event.repository() + is GitPullRequestUpdateEvent -> event.repository() + is GitStatusEvent -> event.repository() + else -> null + } + + /** + * Where to deliver a collaboration event: the repo announcement's advertised + * `relays` (the NIP-34 monitored set), else the explicit `--relay` flag, else + * the account outbox. [repo] is the fetched announcement, or null when it + * couldn't be resolved. + */ + suspend fun deliveryTargets( + ctx: Context, + repo: GitRepositoryEvent?, + args: Args, + ): Set { + val advertised = + repo + ?.relays() + ?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + ?.toSet() + .orEmpty() + return RawEventSupport.relayFlag(args).ifEmpty { advertised }.ifEmpty { ctx.outboxRelays() } + } + + /** Parse a `--flag a,b,c` CSV flag into a trimmed, non-empty list. */ + fun csv( + args: Args, + key: String, + ): List = + args + .flag(key) + ?.split(',') + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + .orEmpty() + + /** + * Parse a `--flag name=commit,other=commit` CSV of `key=value` pairs (used + * for `git state --branch`/`--tag`). An entry without `=` is a bad-args + * error so a typo can't silently drop a ref. + */ + fun keyValueCsv( + args: Args, + key: String, + ): List> = + csv(args, key).map { entry -> + val idx = entry.indexOf('=') + require(idx > 0 && idx < entry.length - 1) { "--$key expects name=commit pairs, got '$entry'" } + entry.take(idx).trim() to entry.substring(idx + 1).trim() + } + + /** The single-word status of a patch/PR/issue, derived from its status events. */ + fun statusLabel(kind: Int?): String = + when (kind) { + GitStatusEvent.KIND_OPEN -> "open" + GitStatusEvent.KIND_APPLIED -> "applied" + GitStatusEvent.KIND_CLOSED -> "closed" + GitStatusEvent.KIND_DRAFT -> "draft" + null -> "open" + else -> "open" + } + + /** A compact `--json`-friendly summary of an issue / patch / PR event. */ + fun targetSummary(event: Event): Map { + val base = + mutableMapOf( + "event_id" to event.id, + "kind" to event.kind, + "author" to event.pubKey, + "created_at" to event.createdAt, + ) + when (event) { + is GitIssueEvent -> { + base["type"] = "issue" + base["subject"] = event.subject() + base["labels"] = event.topics() + } + is GitPatchEvent -> { + base["type"] = "patch" + base["subject"] = event.subject() + base["commit"] = event.commit() + base["parent_commit"] = event.parentCommit() + base["root"] = event.isRoot() + } + is GitPullRequestEvent -> { + base["type"] = "pull-request" + base["subject"] = event.subject() + base["current_commit"] = event.currentCommit() + base["clone"] = event.cloneUrls() + base["branch_name"] = event.branchName() + base["labels"] = event.labels() + } + is GitPullRequestUpdateEvent -> { + base["type"] = "pull-request-update" + base["current_commit"] = event.currentCommit() + } + } + return base + } +} diff --git a/cli/tests/.gitignore b/cli/tests/.gitignore index 181dffb131..61f2fb29a9 100644 --- a/cli/tests/.gitignore +++ b/cli/tests/.gitignore @@ -6,3 +6,4 @@ clink/state-clink-headless/ relaygroup/state-relaygroup-headless/ sync/state-sync-deletions/ blossom/state-blossom-live/ +git/state-git-nip34/ diff --git a/cli/tests/README.md b/cli/tests/README.md index 3772ca1884..26f8ab5432 100644 --- a/cli/tests/README.md +++ b/cli/tests/README.md @@ -2,7 +2,7 @@ Shell-based end-to-end harnesses that drive the `amy` CLI binary — against a loopback `nostr-rs-relay`, an embedded `amy serve` relay, live public servers, -or no relay at all, depending on the suite. Ten directories: +or no relay at all, depending on the suite. Eleven directories: ``` cli/tests/ @@ -19,6 +19,8 @@ cli/tests/ │ ├── dm-interop-headless.sh │ ├── setup.sh # preflight + identities │ └── tests-dm.sh +├── git/ # NIP-34 git collaboration vs embedded `amy serve` +│ └── git-nip34-headless.sh ├── marmot/ # Marmot / MLS group-messaging interop │ ├── marmot-interop.sh # interactive — prompts Amethyst Android UI │ ├── marmot-interop-headless.sh # zero-prompt @@ -65,6 +67,12 @@ Suite notes: - **`sync/sync-deletions-headless.sh`** proves NIP-77 deletion propagation both directions (plus the `--no-sync-deletions` opt-out) against `amy serve`, with one `$HOME` per account so stores don't share. +- **`git/git-nip34-headless.sh`** drives the full NIP-34 collaboration surface + against `amy serve`: announce (30617) + state (30618), issue (1621), patch + (1617), pull request (1618) + update (1619), NIP-22 comment (1111), and + status events (1630-1633), then asserts the `issues`/`patches`/`prs`/`thread` + reads derive the right status (a closed issue reads `closed`, an applied PR + reads `applied`) and that `--open`/`--closed` filter correctly. The Marmot harnesses come in two flavours, same scenarios: diff --git a/cli/tests/git/git-nip34-headless.sh b/cli/tests/git/git-nip34-headless.sh new file mode 100755 index 0000000000..5797cfaf13 --- /dev/null +++ b/cli/tests/git/git-nip34-headless.sh @@ -0,0 +1,201 @@ +#!/usr/bin/env bash +# +# git-nip34-headless.sh — drives the real `amy` binary against a real +# `amy serve` relay to prove the NIP-34 (git-over-nostr) collaboration surface +# end-to-end: repository announcement + state, patches, pull requests, issues, +# NIP-22 comments, and status events — plus the status-deriving reads. +# +# The verbs mirror the pure-Nostr surface of `ngit` and `nak git`. The git +# packfile transport (clone/fetch/push of real objects) is intentionally out of +# scope, so this harness only exercises the events amy publishes and reads back. +# +# Flow (single maintainer account against one relay): +# announce (30617) → state (30618) → issue (1621) → patch (1617) → +# pr (1618) → pr-update (1619) → comment (1111) → close the issue (1632) → +# mark the pr applied (1631) → list issues/patches/prs (status derived) → +# thread view (status timeline + comments). +# +# Usage: ./git-nip34-headless.sh [--port N] [--no-build] +set -uo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)" +TESTS_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" +STATE_DIR="$SCRIPT_DIR/state-git-nip34" +LOG_DIR="$STATE_DIR/logs" +RUN_TS="$(date +%Y%m%d-%H%M%S)" +LOG_FILE="$LOG_DIR/run-$RUN_TS.log" +RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv" + +AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy" +RELAY_HOST="127.0.0.1" +RELAY_PORT="${RELAY_PORT:-7793}" +RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT" +NO_BUILD=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --port) RELAY_PORT="$2"; RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"; shift ;; + --no-build) NO_BUILD=1 ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac + shift +done + +rm -rf "$STATE_DIR" +mkdir -p "$LOG_DIR" +: >"$RESULTS_FILE" + +# shellcheck source=../lib.sh +source "$TESTS_DIR/lib.sh" + +assert_eq() { + local actual="$1" expected="$2" test_id="$3" note="${4:-}" + if [[ "${actual// /}" == "${expected// /}" ]]; then + info "assert: $test_id \"$actual\" == \"$expected\"" + record_result "$test_id" pass "$note" + return 0 + fi + fail_msg "$test_id: expected \"$expected\", got \"$actual\" (${note:-})" + record_result "$test_id" fail "${note:-mismatch}" + return 1 +} + +assert_nonempty() { + local actual="$1" test_id="$2" note="${3:-}" + if [[ -n "$actual" && "$actual" != "null" ]]; then + info "assert: $test_id nonempty (\"$actual\")" + record_result "$test_id" pass "$note" + return 0 + fi + fail_msg "$test_id: expected a value, got empty/null (${note:-})" + record_result "$test_id" fail "${note:-empty}" + return 1 +} + +SERVE_PID="" +cleanup() { + [[ -n "$SERVE_PID" ]] && kill "$SERVE_PID" 2>/dev/null + trap - EXIT INT TERM HUP + print_summary +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +banner "amy git — NIP-34 collaboration headless ($RUN_TS)" + +# ---- build ------------------------------------------------------------------ +if [[ "$NO_BUILD" -eq 0 ]]; then + step "Building amy (installDist)…" + (cd "$REPO_ROOT" && ./gradlew -q :cli:installDist) >>"$LOG_FILE" 2>&1 \ + || { fail_msg "build failed (see $LOG_FILE)"; exit 1; } +fi +[[ -x "$AMY_BIN" ]] || { fail_msg "amy binary not found at $AMY_BIN"; exit 1; } + +strip() { grep -vE "Picked up JAVA_TOOL|DEBUG:|INFO:"; } +mk_home() { mktemp -d "$STATE_DIR/home.XXXXXX"; } +# amy_run args... +amy_run() { + local home="$1" acct="$2"; shift 2 + HOME="$home" "$AMY_BIN" --account "$acct" --secret-backend plaintext --json "$@" 2>>"$LOG_FILE" | strip +} + +M_HOME="$(mk_home)" +amy_run "$M_HOME" m init >/dev/null +M() { amy_run "$M_HOME" m "$@"; } + +step "Starting amy serve on $RELAY_URL…" +HOME="$M_HOME" "$AMY_BIN" --account m --secret-backend plaintext \ + serve --host "$RELAY_HOST" --port "$RELAY_PORT" >>"$LOG_FILE" 2>&1 & +SERVE_PID=$! +for _ in $(seq 1 60); do + grep -q "relay up at" "$LOG_FILE" && break + sleep 0.5 +done +grep -q "relay up at" "$LOG_FILE" || { fail_msg "relay did not come up"; exit 1; } + +# ============================================================================= +# Repository announcement (30617) + state (30618) +# ============================================================================= +banner "announce + state" +ANN="$(M git announce --name demo-repo --description "a demo" \ + --clone https://example.com/demo.git --earliest-commit abc123 --relay "$RELAY_URL")" +info "announce: $ANN" +ADDR="$(echo "$ANN" | jq -r '.address')" +assert_nonempty "$ADDR" announce.address "kind:pubkey:id coordinate returned" +assert_eq "$(echo "$ANN" | jq -r '.published_to | length')" "1" announce.published "relay accepted 30617" + +STATE="$(M git state "$ADDR" --head main --branch main=deadbeef,dev=cafe00 --tag v1.0=abc123 --relay "$RELAY_URL")" +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" + +# ============================================================================= +# Issue (1621) + patch (1617) + PR (1618) + PR update (1619) +# ============================================================================= +banner "issue / patch / pr / pr-update" +ISS="$(M git issue "$ADDR" --subject "First bug" "the issue body" --relay "$RELAY_URL")" +ISSID="$(echo "$ISS" | jq -r '.event_id')" +assert_eq "$(echo "$ISS" | jq -r '.kind')" "1621" issue.kind "issue is kind 1621" +assert_nonempty "$ISSID" issue.id "issue event id" + +PATCH="$(printf 'From abc\nSubject: [PATCH] fix the thing\n\ndiff --git a/x b/x\n' \ + | M git patch "$ADDR" --root --commit deadbeef --relay "$RELAY_URL")" +info "patch: $PATCH" +assert_eq "$(echo "$PATCH" | jq -r '.kind')" "1617" patch.kind "patch is kind 1617" +assert_eq "$(echo "$PATCH" | jq -r '.subject')" "fix the thing" patch.subject "subject parsed from format-patch" + +PR="$(M git pr "$ADDR" --commit feed01 --clone https://example.com/demo.git \ + --subject "add feature" "pr description" --relay "$RELAY_URL")" +PRID="$(echo "$PR" | jq -r '.event_id')" +assert_eq "$(echo "$PR" | jq -r '.kind')" "1618" pr.kind "pr is kind 1618" + +PRUP="$(M git pr-update "$PRID" --commit feed02 --clone https://example.com/demo.git --relay "$RELAY_URL")" +assert_eq "$(echo "$PRUP" | jq -r '.kind')" "1619" prupdate.kind "pr-update is kind 1619" + +# ============================================================================= +# Comment (1111) + status events (1632 close, 1631 applied) +# ============================================================================= +banner "comment + status" +CMT="$(M git comment "$ISSID" "thanks for reporting" --relay "$RELAY_URL")" +assert_eq "$(echo "$CMT" | jq -r '.kind')" "1111" comment.kind "comment is NIP-22 kind 1111" + +CLOSE="$(M git close "$ISSID" "wontfix" --relay "$RELAY_URL")" +assert_eq "$(echo "$CLOSE" | jq -r '.kind')" "1632" close.kind "close status is kind 1632" + +APPLIED="$(M git applied "$PRID" "merged it" --merge-commit feed99 --commit feed02 --relay "$RELAY_URL")" +assert_eq "$(echo "$APPLIED" | jq -r '.kind')" "1631" applied.kind "applied status is kind 1631" + +# ============================================================================= +# Status-deriving reads +# ============================================================================= +banner "reads derive status" +ISSUES="$(M git issues "$ADDR" --relay "$RELAY_URL")" +info "issues: $ISSUES" +assert_eq "$(echo "$ISSUES" | jq -r '.items[0].status')" "closed" issues.status "closed issue reads as closed" + +PRS="$(M git prs "$ADDR" --relay "$RELAY_URL")" +assert_eq "$(echo "$PRS" | jq -r '.items[0].status')" "applied" prs.status "applied pr reads as applied" + +PATCHES="$(M git patches "$ADDR" --relay "$RELAY_URL")" +assert_eq "$(echo "$PATCHES" | jq -r '.items[0].status')" "open" patches.status "un-statused patch reads as open" + +# --status filter: closed issues present, open issues empty. +FILTERED="$(M git issues "$ADDR" --closed --relay "$RELAY_URL")" +assert_eq "$(echo "$FILTERED" | jq -r '.count')" "1" issues.filter_closed "--closed keeps the closed issue" +OPEN_ONLY="$(M git issues "$ADDR" --open --relay "$RELAY_URL")" +assert_eq "$(echo "$OPEN_ONLY" | jq -r '.count')" "0" issues.filter_open "--open drops the closed issue" + +# ============================================================================= +# Thread view +# ============================================================================= +banner "thread view" +THREAD="$(M git thread "$ISSID" --relay "$RELAY_URL")" +info "thread: $THREAD" +assert_eq "$(echo "$THREAD" | jq -r '.status')" "closed" thread.status "thread shows closed" +assert_eq "$(echo "$THREAD" | jq -r '.status_events | length')" "1" thread.status_events "one status event in timeline" +assert_eq "$(echo "$THREAD" | jq -r '.comments | length')" "1" thread.comments "one comment in thread" + +grep -q $'\tfail\t' "$RESULTS_FILE" && exit 1 +exit 0 From 9922eef9f7b1a97c2c05b25fe9e61fe622cfd940 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 23:23:07 +0000 Subject: [PATCH 02/11] feat(cli): add `amy git grasp list|set` (NIP-34 GRASP server list, kind 10317) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Declare/read a user's preferred GRASP (Git-over-Nostr hosting) servers in preference order — the NIP-65-style list `ngit`/`nak git` consult to decide where PR tip branches (`refs/nostr/`) get pushed. `set` publishes a kind:10317 to the outbox; `list` reads it back cache-first (anonymous-capable). Thin assembly over quartz `UserGraspListEvent`. The git push itself stays out of scope, as with the rest of the packfile transport. Extends the git NIP-34 harness with a grasp round-trip (24 assertions) and updates the README/ROADMAP/help tables. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr --- cli/README.md | 2 + cli/ROADMAP.md | 2 +- .../com/vitorpamplona/amethyst/cli/Main.kt | 1 + .../amethyst/cli/commands/GitCommands.kt | 4 +- .../amethyst/cli/commands/GitGraspCommands.kt | 116 ++++++++++++++++++ cli/tests/git/git-nip34-headless.sh | 7 ++ 6 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitGraspCommands.kt diff --git a/cli/README.md b/cli/README.md index f60de8e029..4024f787aa 100644 --- a/cli/README.md +++ b/cli/README.md @@ -326,6 +326,8 @@ the default is the repo's advertised relays, else your outbox. | `amy git state REPO\|IDENTIFIER [--head BRANCH] [--branch name=commit[,…]] [--tag name=commit[,…]]` | Publish a kind:30618 repository state (branch/tag tips + HEAD). | | `amy git list [USER]` | List a user's repo announcements (defaults to self). | | `amy git show NADDR\|kind:pubkey:id` | Print one repo announcement (cache-first). | +| `amy git grasp list [USER]` | List a user's kind:10317 GRASP hosting-server list (defaults to self). | +| `amy git grasp set URL[,URL]` | Publish your GRASP hosting-server list (preference order — where PR tips get pushed). | **Issues, patches & pull requests** diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index e6506fa3bf..df1b2db5ae 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -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) | NIP-34: repo announce (30617) + state (30618), patches (1617), pull requests (1618/1619), issues (1621), NIP-22 comments (1111), status open/applied/closed/draft (1630-1633); `issues`/`patches`/`prs`/`thread` reads derive status. clone/push (git-packfile transport) out of scope. | +| `git` | `amy git` | ✅ (events) | NIP-34: repo announce (30617) + state (30618), patches (1617), pull requests (1618/1619), issues (1621), NIP-22 comments (1111), status open/applied/closed/draft (1630-1633), GRASP server list (10317); `issues`/`patches`/`prs`/`thread` reads derive status. clone/push (git-packfile transport) out of scope. | | `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`. | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index b6e2b4b877..ff8e5a3c09 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -632,6 +632,7 @@ private fun printUsage() { | [--tag n=c[,…]] | git list [USER] list a user's repo announcements (default self) | git show NADDR|kind:pubkey:id print one repo announcement + | git grasp list [USER] | set URL[,URL] GRASP hosting-server list (kind 10317) | git issue REPO --subject S [BODY] publish a kind:1621 issue against a repo | [--hashtag T[,T]] (BODY from arg or stdin) | git patch REPO [--file P] [--root] publish a kind:1617 patch (format-patch/stdin) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt index 9079a7ceb2..f30008c9a9 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt @@ -59,6 +59,7 @@ object GitCommands { | [--tag name=commit[,…]] | git list [USER] list a user's repo announcements (default self) | git show NADDR|kind:pubkey:id print one repo announcement + | git grasp list [USER] | set URL[,URL] a user's GRASP hosting-server list (kind 10317) | |Issues / patches / pull requests: | git issue REPO --subject S [BODY] publish a kind:1621 issue (BODY arg or stdin) @@ -89,12 +90,13 @@ object GitCommands { route( "git", tail, - "git ", + "git ", mapOf( "announce" to { rest -> announce(dataDir, rest) }, "state" to { rest -> state(dataDir, rest) }, "list" to { rest -> list(dataDir, rest) }, "show" to { rest -> show(dataDir, rest) }, + "grasp" to { rest -> GitGraspCommands.dispatch(dataDir, rest) }, "issue" to { rest -> issue(dataDir, rest) }, "issues" to { rest -> GitReadCommands.issues(dataDir, rest) }, "patch" to { rest -> GitPatchCommands.patch(dataDir, rest) }, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitGraspCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitGraspCommands.kt new file mode 100644 index 0000000000..e330c7e621 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitGraspCommands.kt @@ -0,0 +1,116 @@ +/* + * 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.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip34Git.grasp.UserGraspListEvent + +/** + * `amy git grasp ` — a user's NIP-34 GRASP (Git-over-Nostr hosting) + * server list (kind 10317). Functions like NIP-65's relay list: it declares, + * in preference order, where PR tip branches (`refs/nostr/`) get pushed + * so maintainers know where to fetch them. `ngit` and `nak git` read this to + * pick a push host; amy publishes and reads the list (the git push itself is + * out of scope — see cli/ROADMAP.md). + */ +object GitGraspCommands { + val USAGE: String = + """ + |amy git grasp — NIP-34 GRASP server list (kind 10317) + | + | git grasp list [USER] list a user's grasp servers (default self) + | git grasp set URL[,URL] [--relay URL[,URL]] publish your grasp server list (preference order) + """.trimMargin() + + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + "git grasp", + tail, + "git grasp ", + mapOf( + "list" to { rest -> list(dataDir, rest) }, + "set" to { rest -> set(dataDir, rest) }, + ), + help = USAGE, + ) + + private suspend fun list( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + Context.openOrAnonymous(dataDir).use { ctx -> + ctx.prepare() + val author = args.positionalOrNull(0)?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex + args.rejectUnknown("relay") + val filter = Filter(kinds = listOf(UserGraspListEvent.KIND), authors = listOf(author), limit = 1) + var event = ctx.store.query(filter).firstOrNull() as? UserGraspListEvent + if (event == null) { + val relays = RawEventSupport.queryTargets(ctx, args) + ctx.drain(relays.associateWith { listOf(filter) }) + event = ctx.store.query(filter).firstOrNull() as? UserGraspListEvent + } + val grasps = event?.grasps().orEmpty() + Output.emit(mapOf("pubkey" to author, "count" to grasps.size, "grasps" to grasps)) + return 0 + } + } + + private suspend fun set( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val grasps = + args + .positional(0, "grasp-server-urls") + .split(',') + .map { it.trim() } + .filter { it.isNotEmpty() } + if (grasps.isEmpty()) return Output.error("bad_args", "git grasp set requires URL[,URL] (a comma-separated grasp server list)") + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val template = UserGraspListEvent.build(grasps) + val signed = ctx.signer.sign(template) + val targets = RawEventSupport.publishTargets(ctx, args) + args.rejectUnknown() + val ack = ctx.publish(signed, targets) + RawEventSupport.publishGuard(ack, signed.id)?.let { return it } + Output.emit( + mapOf( + "event_id" to signed.id, + "kind" to signed.kind, + "grasps" to grasps, + ) + RawEventSupport.ackFields(ack), + ) + return 0 + } + } +} diff --git a/cli/tests/git/git-nip34-headless.sh b/cli/tests/git/git-nip34-headless.sh index 5797cfaf13..400ebe1248 100755 --- a/cli/tests/git/git-nip34-headless.sh +++ b/cli/tests/git/git-nip34-headless.sh @@ -131,6 +131,13 @@ 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" +# 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" +GRASP_READ="$(M git grasp list --relay "$RELAY_URL")" +assert_eq "$(echo "$GRASP_READ" | jq -r '.count')" "2" grasp.count "two grasp servers read back" +assert_eq "$(echo "$GRASP_READ" | jq -r '.grasps[0]')" "wss://grasp.example.com" grasp.order "preference order preserved" + # ============================================================================= # Issue (1621) + patch (1617) + PR (1618) + PR update (1619) # ============================================================================= From 45d33c016eaca4eb4ff55e705c7e0b36e26c5e22 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 23:30:03 +0000 Subject: [PATCH 03/11] =?UTF-8?q?feat(cli):=20add=20`amy=20git=20browse|ca?= =?UTF-8?q?t|log`=20=E2=80=94=20read=20git=20objects=20over=20smart-HTTP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give `amy git` the git-object read side of `nak git download` / a shallow clone. `browse` lists a repo's tree, `cat` prints (or `--out` writes) a file at a ref, and `log` shows recent commit history — all over the git smart-HTTP v2 protocol via quartz's `GitHttpClient` (the same shallow-clone path the Android repo browser uses). REPO may be a NIP-34 coordinate/naddr (whose announcement supplies the clone URL) or a raw http(s) clone URL; `--clone` and `--ref` override the URL and branch/tag. Read-only: pushing git objects back to clone/GRASP servers stays out of scope. Verified live against a public repo (octocat/Hello-World) — browse/cat/log all return correct trees, blobs, and history. The harness gains a `--live` block (28 assertions with `--live`, 24 in the default offline run) exercising these against `$LIVE_REPO`, skipped by default since it needs a reachable git host. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr --- cli/README.md | 12 + cli/ROADMAP.md | 5 +- .../com/vitorpamplona/amethyst/cli/Main.kt | 2 + .../cli/commands/GitBrowseCommands.kt | 249 ++++++++++++++++++ .../amethyst/cli/commands/GitCommands.kt | 10 +- cli/tests/README.md | 14 +- cli/tests/git/git-nip34-headless.sh | 23 ++ 7 files changed, 307 insertions(+), 8 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitBrowseCommands.kt diff --git a/cli/README.md b/cli/README.md index 4024f787aa..5e27abf780 100644 --- a/cli/README.md +++ b/cli/README.md @@ -329,6 +329,18 @@ the default is the repo's advertised relays, else your outbox. | `amy git grasp list [USER]` | List a user's kind:10317 GRASP hosting-server list (defaults to self). | | `amy git grasp set URL[,URL]` | Publish your GRASP hosting-server list (preference order — where PR tips get pushed). | +**Read repository content** (git smart-HTTP v2, read-only — needs a reachable git host) + +`REPO` here is a repo coordinate/naddr (whose announcement supplies the clone +URL) **or** a raw `http(s)` clone URL. This is the git-object read side of +`nak git download` / a shallow clone; pushing objects back is out of scope. + +| Command | What it does | +|---|---| +| `amy git browse REPO [PATH] [--ref R] [--clone URL]` | List a repo's tree entries at PATH (default: root). | +| `amy git cat REPO PATH [--ref R] [--out FILE]` | Print a file's contents at a ref (or write raw bytes to `--out`). | +| `amy git log REPO [--ref R] [--depth N] [--clone URL]` | Recent commit history, most recent first. | + **Issues, patches & pull requests** | Command | What it does | diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index df1b2db5ae..02f45a13ea 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -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) | NIP-34: repo announce (30617) + state (30618), patches (1617), pull requests (1618/1619), issues (1621), NIP-22 comments (1111), status open/applied/closed/draft (1630-1633), GRASP server list (10317); `issues`/`patches`/`prs`/`thread` reads derive status. clone/push (git-packfile transport) out of scope. | +| `git` | `amy git` | ✅ (events + read) | NIP-34: repo announce (30617) + state (30618), patches (1617), pull requests (1618/1619), issues (1621), NIP-22 comments (1111), status open/applied/closed/draft (1630-1633), GRASP server list (10317); `issues`/`patches`/`prs`/`thread` reads derive status; `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) is out of scope. | | `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`. | @@ -130,7 +130,8 @@ nak has 34 functional commands (introspected from `nak --help`). Coverage: `admin`) are interop-verified against the real `nak` binary or `amy serve`. - **Partial / adapted (3):** `key` (no `expand`/`combine`(MuSig2)/`default`), `git` (full NIP-34 event surface — announce/state/patch/PR/issue/comment/status - + status-deriving reads — but no git-packfile clone/push transport), `outbox` + + status-deriving reads + GRASP list, plus `browse`/`cat`/`log` reading git + objects over smart-HTTP; only packfile **push** is out of scope), `outbox` (NIP-65 model vs nak's local hints DB). - **Missing (6):** `dekey` (NIP-4E), `mcp`, `curl` (NIP-98), `fs` (FUSE), `spell` (MuSig2/FROST), and `validate` (event-schema validation). diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index ff8e5a3c09..2b802e9a53 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -633,6 +633,8 @@ private fun printUsage() { | git list [USER] list a user's repo announcements (default self) | git show NADDR|kind:pubkey:id print one repo announcement | git grasp list [USER] | set URL[,URL] GRASP hosting-server list (kind 10317) + | git browse REPO [PATH] | cat REPO PATH read repo tree/file over git smart-HTTP + | git log REPO [--depth N] recent commit history (read-only) | git issue REPO --subject S [BODY] publish a kind:1621 issue against a repo | [--hashtag T[,T]] (BODY from arg or stdin) | git patch REPO [--file P] [--root] publish a kind:1617 patch (format-patch/stdin) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitBrowseCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitBrowseCommands.kt new file mode 100644 index 0000000000..e56a2e4309 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitBrowseCommands.kt @@ -0,0 +1,249 @@ +/* + * 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.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip34Git.git.GitHttpClient +import com.vitorpamplona.quartz.nip34Git.git.GitRepoSnapshot +import com.vitorpamplona.quartz.nip34Git.git.GitTreeEntry +import java.io.File + +/** + * `amy git browse|cat|log` — read a repository's actual git content over the + * git smart-HTTP v2 protocol (the same [GitHttpClient] the Android repo browser + * uses). Resolves the http(s) clone URL from the kind:30617 announcement and + * does a shallow fetch, so this is read-only and needs a reachable git host. + * This is the git-object read side of `nak git download` / a shallow `clone`; + * pushing objects back is still out of scope (see cli/ROADMAP.md). + */ +object GitBrowseCommands { + /** `git browse REPO [PATH]` — list the tree entries at PATH (default: repo root). */ + suspend fun browse( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val coord = args.positional(0, "repo-naddr-or-coordinates") + val path = args.positionalOrNull(1).orEmpty() + val ref = args.flag("ref") + val cloneOverride = args.flag("clone") + args.rejectUnknown("relay") + + return withSnapshot(dataDir, coord, ref, cloneOverride, args) { _, snapshot -> + val segments = splitPath(path) + val entries = + if (segments.isEmpty()) { + snapshot.rootEntries() + } else { + snapshot.entriesAt(segments) + ?: return@withSnapshot Output.error("not_found", "no directory at path '$path'") + } + Output.emit( + mapOf( + "clone_url" to snapshot.cloneUrl, + "branch" to snapshot.branch, + "head_commit" to snapshot.headCommit, + "path" to path, + "count" to entries.size, + "entries" to entries.map(::entrySummary), + ), + ) + 0 + } + } + + /** `git cat REPO PATH` — print (or `--out FILE`) a file's contents at a ref. */ + suspend fun cat( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val coord = args.positional(0, "repo-naddr-or-coordinates") + val path = args.positional(1, "file-path") + val ref = args.flag("ref") + val cloneOverride = args.flag("clone") + val out = args.flag("out") + args.rejectUnknown("relay") + + return withSnapshot(dataDir, coord, ref, cloneOverride, args) { _, snapshot -> + val entry = + snapshot.entryAt(splitPath(path)) + ?: return@withSnapshot Output.error("not_found", "no file at path '$path'") + if (entry.isFolder) return@withSnapshot Output.error("bad_args", "'$path' is a directory (use `git browse`)") + val bytes = snapshot.readBlob(entry.oid) + val binary = isBinary(bytes) + if (out != null) { + File(out).writeBytes(bytes) + } + Output.emit( + mapOf( + "clone_url" to snapshot.cloneUrl, + "path" to path, + "oid" to entry.oid, + "size_bytes" to bytes.size, + "binary" to binary, + "written_to" to out, + // Text content is inlined only when it isn't binary and wasn't written to a file. + "content" to if (!binary && out == null) bytes.decodeToString() else null, + ), + ) + 0 + } + } + + /** `git log REPO` — recent commit history (most recent first). */ + suspend fun log( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val coord = args.positional(0, "repo-naddr-or-coordinates") + val ref = args.flag("ref") + val cloneOverride = args.flag("clone") + val depth = args.intFlag("depth", 50) + args.rejectUnknown("relay") + + return withSnapshot(dataDir, coord, ref, cloneOverride, args) { http, snapshot -> + val commits = http.loadHistory(snapshot.cloneUrl, snapshot.headCommit, depth) + Output.emit( + mapOf( + "clone_url" to snapshot.cloneUrl, + "branch" to snapshot.branch, + "count" to commits.size, + "commits" to + commits.map { + mapOf( + "oid" to it.oid, + "short_oid" to it.shortOid, + "summary" to it.summary, + "author" to it.authorName, + "author_email" to it.authorEmail, + "author_time" to it.authorTimeSec, + "parents" to it.parents, + ) + }, + ), + ) + 0 + } + } + + // ------------------------------------------------------------------ + + /** + * Resolve the repo announcement, pick its http(s) clone URLs, open a shallow + * snapshot (trying each candidate URL), and hand the client + snapshot to + * [block]. Emits a `not_found` / `unreachable` error when the repo or a + * working clone URL can't be resolved. + */ + private suspend fun withSnapshot( + dataDir: DataDir, + coord: String, + ref: String?, + cloneOverride: String?, + args: Args, + block: suspend (GitHttpClient, GitRepoSnapshot) -> Int, + ): Int { + // REPO may be a raw http(s) clone URL, an naddr, or `kind:pubkey:id`. + val directUrl = coord.takeIf { it.startsWith("http://") || it.startsWith("https://") } + val addr = + if (directUrl == null) { + GitSupport.resolveAddress(coord) + ?: return Output.error("bad_args", "expected a clone URL, an naddr, or kind:pubkey:identifier") + } else { + null + } + + Context.openOrAnonymous(dataDir).use { ctx -> + ctx.prepare() + val cloneUrls = + when { + cloneOverride != null -> listOf(cloneOverride) + directUrl != null -> listOf(directUrl) + else -> { + val repo = + GitSupport.fetchRepo(ctx, addr!!, args) + ?: return Output.error("not_found", "no repository announcement found for $coord") + repo.clones() + } + } + val candidates = candidateUrls(cloneUrls) + if (candidates.isEmpty()) { + return Output.error("bad_args", "repository has no http(s) clone URL (pass --clone URL)") + } + val http = GitHttpClient { ctx.okhttp } + val errors = StringBuilder() + for (url in candidates) { + val snapshot = + try { + http.open(url, ref) + } catch (e: Exception) { + errors + .append(url) + .append(" → ") + .append(e.message ?: e.toString()) + .append('\n') + null + } + if (snapshot != null) return block(http, snapshot) + } + return Output.error("unreachable", "could not clone any candidate URL:\n${errors.toString().trim()}") + } + } + + /** http(s) clone URLs to try, in order, adding a `.git` variant when missing. */ + private fun candidateUrls(cloneUrls: List): List { + val out = LinkedHashSet() + for (raw in cloneUrls) { + val url = raw.trim() + if (!url.startsWith("http://") && !url.startsWith("https://")) continue + out.add(url) + if (!url.removeSuffix("/").endsWith(".git")) out.add(url.removeSuffix("/") + ".git") + } + return out.toList() + } + + private fun splitPath(path: String): List = + path + .trim() + .trim('/') + .split('/') + .filter { it.isNotEmpty() } + + private fun entrySummary(entry: GitTreeEntry): Map = + mapOf( + "name" to entry.name, + "type" to + when { + entry.isFolder -> "dir" + entry.isSubmodule -> "submodule" + entry.isSymlink -> "symlink" + else -> "file" + }, + "oid" to entry.oid, + ) + + /** A blob is treated as binary when it contains a NUL byte in its head. */ + private fun isBinary(bytes: ByteArray): Boolean = bytes.take(8000).any { it.toInt() == 0 } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt index f30008c9a9..f78812c5a9 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt @@ -61,6 +61,11 @@ object GitCommands { | git show NADDR|kind:pubkey:id print one repo announcement | git grasp list [USER] | set URL[,URL] a user's GRASP hosting-server list (kind 10317) | + |Read repo content (git smart-HTTP, read-only — needs a reachable git host): + | git browse REPO [PATH] [--ref R] [--clone URL] list a repo's tree at PATH (default root) + | git cat REPO PATH [--ref R] [--out FILE] print (or write) a file's contents at a ref + | git log REPO [--ref R] [--depth N] recent commit history (most recent first) + | |Issues / patches / pull requests: | git issue REPO --subject S [BODY] publish a kind:1621 issue (BODY arg or stdin) | [--hashtag T[,T]] @@ -90,13 +95,16 @@ object GitCommands { route( "git", tail, - "git ", + "git ", mapOf( "announce" to { rest -> announce(dataDir, rest) }, "state" to { rest -> state(dataDir, rest) }, "list" to { rest -> list(dataDir, rest) }, "show" to { rest -> show(dataDir, rest) }, "grasp" to { rest -> GitGraspCommands.dispatch(dataDir, rest) }, + "browse" to { rest -> GitBrowseCommands.browse(dataDir, rest) }, + "cat" to { rest -> GitBrowseCommands.cat(dataDir, rest) }, + "log" to { rest -> GitBrowseCommands.log(dataDir, rest) }, "issue" to { rest -> issue(dataDir, rest) }, "issues" to { rest -> GitReadCommands.issues(dataDir, rest) }, "patch" to { rest -> GitPatchCommands.patch(dataDir, rest) }, diff --git a/cli/tests/README.md b/cli/tests/README.md index 26f8ab5432..5dc5ae5373 100644 --- a/cli/tests/README.md +++ b/cli/tests/README.md @@ -68,11 +68,15 @@ Suite notes: both directions (plus the `--no-sync-deletions` opt-out) against `amy serve`, with one `$HOME` per account so stores don't share. - **`git/git-nip34-headless.sh`** drives the full NIP-34 collaboration surface - against `amy serve`: announce (30617) + state (30618), issue (1621), patch - (1617), pull request (1618) + update (1619), NIP-22 comment (1111), and - status events (1630-1633), then asserts the `issues`/`patches`/`prs`/`thread` - reads derive the right status (a closed issue reads `closed`, an applied PR - reads `applied`) and that `--open`/`--closed` filter correctly. + against `amy serve`: announce (30617) + state (30618) + GRASP list (10317), + issue (1621), patch (1617), pull request (1618) + update (1619), NIP-22 + comment (1111), and status events (1630-1633), then asserts the + `issues`/`patches`/`prs`/`thread` reads derive the right status (a closed + issue reads `closed`, an applied PR reads `applied`) and that + `--open`/`--closed` filter correctly. Pass `--live` to additionally exercise + the git smart-HTTP reads (`git browse`/`cat`/`log`) against a real public + repo (`$LIVE_REPO`, default octocat/Hello-World) — skipped by default since + it needs a reachable git host. The Marmot harnesses come in two flavours, same scenarios: diff --git a/cli/tests/git/git-nip34-headless.sh b/cli/tests/git/git-nip34-headless.sh index 400ebe1248..48b59ac58e 100755 --- a/cli/tests/git/git-nip34-headless.sh +++ b/cli/tests/git/git-nip34-headless.sh @@ -32,11 +32,14 @@ RELAY_HOST="127.0.0.1" RELAY_PORT="${RELAY_PORT:-7793}" RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT" NO_BUILD=0 +LIVE=0 +LIVE_REPO="${LIVE_REPO:-https://github.com/octocat/Hello-World.git}" while [[ $# -gt 0 ]]; do case "$1" in --port) RELAY_PORT="$2"; RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"; shift ;; --no-build) NO_BUILD=1 ;; + --live) LIVE=1 ;; *) echo "unknown arg: $1" >&2; exit 2 ;; esac shift @@ -204,5 +207,25 @@ assert_eq "$(echo "$THREAD" | jq -r '.status')" "closed" thread.status "thread s assert_eq "$(echo "$THREAD" | jq -r '.status_events | length')" "1" thread.status_events "one status event in timeline" assert_eq "$(echo "$THREAD" | jq -r '.comments | length')" "1" thread.comments "one comment in thread" +# ============================================================================= +# Read repo content over git smart-HTTP (--live only — needs a reachable host). +# ============================================================================= +if [[ "$LIVE" -eq 1 ]]; then + banner "live: git browse / cat / log ($LIVE_REPO)" + BROWSE="$(M git browse "$LIVE_REPO" --json)" + info "browse: $BROWSE" + assert_nonempty "$(echo "$BROWSE" | jq -r '.head_commit')" live.browse "browse resolves a head commit" + FIRST_FILE="$(echo "$BROWSE" | jq -r '.entries[] | select(.type=="file") | .name' | head -1)" + if [[ -n "$FIRST_FILE" ]]; then + CAT="$(M git cat "$LIVE_REPO" "$FIRST_FILE" --json)" + assert_eq "$(echo "$CAT" | jq -r '.path')" "$FIRST_FILE" live.cat "cat returns the requested file" + assert_nonempty "$(echo "$CAT" | jq -r '.oid')" live.cat_oid "cat resolves a blob oid" + fi + LOG="$(M git log "$LIVE_REPO" --depth 2 --json)" + assert_nonempty "$(echo "$LOG" | jq -r '.commits[0].oid')" live.log "log returns at least one commit" +else + skip_msg "live git smart-HTTP reads (browse/cat/log) — pass --live to run" +fi + grep -q $'\tfail\t' "$RESULTS_FILE" && exit 1 exit 0 From b06184ac497e85dd5b32cbf5bc1de45bd2d22fc6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 23:37:07 +0000 Subject: [PATCH 04/11] =?UTF-8?q?feat(cli):=20add=20`amy=20git=20init`=20?= =?UTF-8?q?=E2=80=94=20bootstrap=20a=20repo=20from=20the=20local=20git=20c?= =?UTF-8?q?heckout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match `ngit init` / `nak git init`: read the local git repository and publish a NIP-34 repository announcement, deriving the fields instead of making the user type them. Shells out to `git` to determine the name (top-level dir), clone URL (origin remote, ssh→https normalized), earliest-unique-commit (root commit), and — for the accompanying kind:30618 state — the branch/tag tips and HEAD. Publishes the 30617 announcement and (unless `--no-state`) the 30618 state in one shot. Every derived value is overridable with a flag; outside a git repo the derivation is skipped and `--name`/`--clone` are supplied manually. This is the one `amy git` verb that shells out to `git`, since it is inherently about the local working tree — exactly like the tools it mirrors. Verified against the amethyst checkout itself (derives name=amethyst, the origin clone URL, the root commit as EUC, and a 30618 with the live branches + HEAD). The harness gains 4 assertions driving `git init` against its own checkout. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr --- cli/README.md | 3 +- cli/ROADMAP.md | 2 +- .../com/vitorpamplona/amethyst/cli/Main.kt | 2 + .../amethyst/cli/commands/GitCommands.kt | 6 +- .../amethyst/cli/commands/GitInitCommand.kt | 173 ++++++++++++++++++ cli/tests/README.md | 4 +- cli/tests/git/git-nip34-headless.sh | 11 ++ 7 files changed, 197 insertions(+), 4 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitInitCommand.kt diff --git a/cli/README.md b/cli/README.md index 5e27abf780..90b55a5cef 100644 --- a/cli/README.md +++ b/cli/README.md @@ -322,7 +322,8 @@ the default is the repo's advertised relays, else your outbox. | Command | What it does | |---|---| -| `amy git announce --name N [--description D] [--clone URL[,URL]] [--web URL[,URL]] [--relay URL[,URL]] [--maintainer HEX[,HEX]] [--hashtag T[,T]] [--earliest-commit C] [--personal-fork] [--d ID]` | Publish a kind:30617 repository announcement. | +| `amy git init [--name N] [--description D] [--clone URL[,URL]] [--relay URL[,URL]] [--no-state] [--repo PATH] [--d ID]` | Bootstrap a repo from the local git checkout (like `ngit init`): derive name, clone URL, earliest-unique-commit, and branch/tag state via `git`, then publish the kind:30617 announcement and (unless `--no-state`) the kind:30618 state. Any flag overrides a derived value. | +| `amy git announce --name N [--description D] [--clone URL[,URL]] [--web URL[,URL]] [--relay URL[,URL]] [--maintainer HEX[,HEX]] [--hashtag T[,T]] [--earliest-commit C] [--personal-fork] [--d ID]` | Publish a kind:30617 repository announcement (manual, no local repo needed). | | `amy git state REPO\|IDENTIFIER [--head BRANCH] [--branch name=commit[,…]] [--tag name=commit[,…]]` | Publish a kind:30618 repository state (branch/tag tips + HEAD). | | `amy git list [USER]` | List a user's repo announcements (defaults to self). | | `amy git show NADDR\|kind:pubkey:id` | Print one repo announcement (cache-first). | diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 02f45a13ea..7fb1d48690 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -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: repo announce (30617) + state (30618), patches (1617), pull requests (1618/1619), issues (1621), NIP-22 comments (1111), status open/applied/closed/draft (1630-1633), GRASP server list (10317); `issues`/`patches`/`prs`/`thread` reads derive status; `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) is 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), status open/applied/closed/draft (1630-1633), GRASP server list (10317); `issues`/`patches`/`prs`/`thread` reads derive status; `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) is out of scope. | | `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`. | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 2b802e9a53..0a89fd9823 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -624,6 +624,8 @@ private fun printUsage() { | blossom mirror --server URL SOURCE-URL ask the server to mirror a blob (BUD-04) | |Git (NIP-34): + | git init [--name N] [--clone URL] bootstrap a repo from the local git checkout + | [--no-state] [--repo PATH] (derives fields via `git`; publishes 30617+30618) | git announce --name N [--description D] publish a kind:30617 repo announcement | [--clone URL[,URL]] [--web URL[,URL]] (--d sets the identifier; defaults to name) | [--relay URL[,URL]] [--maintainer HEX[,]] diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt index f78812c5a9..9391fd73bc 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt @@ -49,6 +49,9 @@ object GitCommands { |amy git — NIP-34 Nostr-native git collaboration | |Repository: + | git init [--name N] [--description D] bootstrap a repo from the local git checkout + | [--clone URL[,URL]] [--relay URL[,URL]] (derives name/clone/earliest-commit/state via + | [--no-state] [--repo PATH] [--d ID] `git`; flags override; publishes 30617 + 30618) | git announce --name N [--description D] publish a kind:30617 repo announcement | [--clone URL[,URL]] [--web URL[,URL]] (--d / --identifier sets the identifier; | [--relay URL[,URL]] [--maintainer HEX[,]] defaults to name) @@ -95,8 +98,9 @@ object GitCommands { route( "git", tail, - "git ", + "git ", mapOf( + "init" to { rest -> GitInitCommand.init(dataDir, rest) }, "announce" to { rest -> announce(dataDir, rest) }, "state" to { rest -> state(dataDir, rest) }, "list" to { rest -> list(dataDir, rest) }, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitInitCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitInitCommand.kt new file mode 100644 index 0000000000..1a9b68f131 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitInitCommand.kt @@ -0,0 +1,173 @@ +/* + * 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.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent +import com.vitorpamplona.quartz.nip34Git.state.GitRepositoryStateEvent +import com.vitorpamplona.quartz.nip34Git.state.tags.RefTag +import java.io.File + +/** + * `amy git init` — bootstrap a NIP-34 repository from the local git checkout, + * the way `ngit init` does: derive the name, clone URL, earliest-unique-commit, + * and branch/tag state from `git`, then publish the kind:30617 announcement and + * (unless `--no-state`) the kind:30618 state in one shot. Every field can be + * overridden with a flag; when the directory isn't a git repo, the derivation + * is skipped and you supply `--name` / `--clone` yourself. + * + * This is the one `amy git` verb that shells out to `git` — it's inherently + * about the local working tree, exactly like the `ngit`/`nak git` `init`. + */ +object GitInitCommand { + suspend fun init( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val repoDir = File(args.flag("repo") ?: ".").absoluteFile + val noState = args.bool("no-state") + + val toplevel = git(repoDir, "rev-parse", "--show-toplevel")?.let { File(it) } + val derivedName = toplevel?.name + val originUrl = git(repoDir, "remote", "get-url", "origin")?.let(::normalizeCloneUrl) + // The earliest unique commit is the root commit; `rev-list` prints newest + // first, so the last line is the initial commit. + val euc = git(repoDir, "rev-list", "--max-parents=0", "HEAD")?.lineSequence()?.lastOrNull { it.isNotBlank() } + + val name = + args.flag("name") ?: derivedName + ?: return Output.error("bad_args", "not a git repo and no --name given (run inside a repo, or pass --name)") + val identifier = args.flag("d") ?: args.flag("identifier") ?: kebab(name) + val cloneUrls = GitSupport.csv(args, "clone").ifEmpty { listOfNotNull(originUrl) } + val earliestCommit = args.flag("earliest-commit") ?: euc + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val announce = + GitRepositoryEvent.build( + name = name, + description = args.flag("description"), + webUrls = GitSupport.csv(args, "web"), + cloneUrls = cloneUrls, + relays = GitSupport.csv(args, "relay"), + maintainers = GitSupport.csv(args, "maintainer"), + hashtags = GitSupport.csv(args, "hashtag"), + earliestUniqueCommit = earliestCommit, + personalFork = args.bool("personal-fork"), + dTag = identifier, + ) + val signedAnnounce = ctx.signer.sign(announce) + val targets = RawEventSupport.publishTargets(ctx, args) + args.rejectUnknown() + val ackAnnounce = ctx.publish(signedAnnounce, targets) + RawEventSupport.publishGuard(ackAnnounce, signedAnnounce.id)?.let { return it } + + val result = + mutableMapOf( + "event_id" to signedAnnounce.id, + "address" to Address.assemble(signedAnnounce.kind, signedAnnounce.pubKey, identifier), + "name" to name, + "clone" to cloneUrls, + "earliest_commit" to earliestCommit, + "from_git_repo" to (toplevel != null), + ) + + if (!noState && toplevel != null) { + val refs = readRefs(repoDir) + val head = git(repoDir, "symbolic-ref", "--short", "HEAD") + if (refs.isNotEmpty() || head != null) { + val stateTemplate = GitRepositoryStateEvent.build(dTag = identifier, refs = refs, head = head) + val signedState = ctx.signer.sign(stateTemplate) + ctx.publish(signedState, targets) + result["state_event_id"] = signedState.id + result["branches"] = refs.count { it.kind == RefTag.Kind.BRANCH } + result["tags"] = refs.count { it.kind == RefTag.Kind.TAG } + result["head"] = head + } + } + Output.emit(result + RawEventSupport.ackFields(ackAnnounce)) + return 0 + } + } + + /** Read local branch + tag refs as NIP-34 [RefTag]s via `git for-each-ref`. */ + private fun readRefs(repoDir: File): List { + fun parse( + output: String?, + builder: (String, String) -> RefTag, + ): List = + output + ?.lineSequence() + ?.mapNotNull { line -> + val parts = line.trim().split(' ') + if (parts.size == 2 && parts[0].isNotEmpty() && parts[1].isNotEmpty()) builder(parts[0], parts[1]) else null + }?.toList() + .orEmpty() + + val branches = parse(git(repoDir, "for-each-ref", "--format=%(refname:short) %(objectname)", "refs/heads")) { n, c -> RefTag.branch(n, c) } + val tags = parse(git(repoDir, "for-each-ref", "--format=%(refname:short) %(objectname)", "refs/tags")) { n, c -> RefTag.tag(n, c) } + return branches + tags + } + + /** Run `git ` in [repoDir]; returns trimmed stdout on exit 0, else null (git missing / not a repo). */ + private fun git( + repoDir: File, + vararg gitArgs: String, + ): String? = + try { + val proc = + ProcessBuilder(listOf("git", *gitArgs)) + .directory(repoDir) + .redirectErrorStream(false) + .start() + val out = proc.inputStream.readBytes().decodeToString() + proc.errorStream.readBytes() + if (proc.waitFor() == 0) out.trim().ifEmpty { null } else null + } catch (_: Exception) { + null + } + + /** Convert an ssh remote (`git@host:owner/repo.git`, `ssh://…`) to a browsable https URL; pass others through. */ + private fun normalizeCloneUrl(url: String): String = + when { + url.startsWith("git@") -> { + val rest = url.removePrefix("git@") + val host = rest.substringBefore(':') + val path = rest.substringAfter(':') + "https://$host/$path" + } + url.startsWith("ssh://git@") -> "https://" + url.removePrefix("ssh://git@") + else -> url + } + + /** kebab-case a repo name for the `d` identifier: lowercase, non-alphanumerics collapse to single hyphens. */ + private fun kebab(name: String): String = + name + .lowercase() + .replace(Regex("[^a-z0-9]+"), "-") + .trim('-') + .ifEmpty { name } +} diff --git a/cli/tests/README.md b/cli/tests/README.md index 5dc5ae5373..d6e36efc37 100644 --- a/cli/tests/README.md +++ b/cli/tests/README.md @@ -68,7 +68,9 @@ Suite notes: both directions (plus the `--no-sync-deletions` opt-out) against `amy serve`, with one `$HOME` per account so stores don't share. - **`git/git-nip34-headless.sh`** drives the full NIP-34 collaboration surface - against `amy serve`: announce (30617) + state (30618) + GRASP list (10317), + against `amy serve`: `git init` bootstrapping a repo from the harness's own + git checkout (announce + state derived via `git`), announce (30617) + state + (30618) + GRASP list (10317), issue (1621), patch (1617), pull request (1618) + update (1619), NIP-22 comment (1111), and status events (1630-1633), then asserts the `issues`/`patches`/`prs`/`thread` reads derive the right status (a closed diff --git a/cli/tests/git/git-nip34-headless.sh b/cli/tests/git/git-nip34-headless.sh index 48b59ac58e..46d3fb284a 100755 --- a/cli/tests/git/git-nip34-headless.sh +++ b/cli/tests/git/git-nip34-headless.sh @@ -118,6 +118,17 @@ for _ in $(seq 1 60); do done grep -q "relay up at" "$LOG_FILE" || { fail_msg "relay did not come up"; exit 1; } +# ============================================================================= +# git init — bootstrap from the local git checkout (this repo) +# ============================================================================= +banner "git init (from the amethyst checkout)" +INIT="$(M git init --repo "$REPO_ROOT" --relay "$RELAY_URL")" +info "init: $INIT" +assert_eq "$(echo "$INIT" | jq -r '.from_git_repo')" "true" init.from_git "init derived fields from the git repo" +assert_nonempty "$(echo "$INIT" | jq -r '.name')" init.name "repo name derived" +assert_nonempty "$(echo "$INIT" | jq -r '.earliest_commit')" init.euc "earliest-unique-commit derived from git" +assert_nonempty "$(echo "$INIT" | jq -r '.state_event_id')" init.state "init also published a 30618 state event" + # ============================================================================= # Repository announcement (30617) + state (30618) # ============================================================================= From d3e208322c7a7d13436638c2dad73fd4b6b106a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 23:44:32 +0000 Subject: [PATCH 05/11] =?UTF-8?q?feat(cli):=20add=20`amy=20git=20label`=20?= =?UTF-8?q?(NIP-32)=20and=20`amy=20git=20apply`=20(patch=20=E2=86=92=20wor?= =?UTF-8?q?king=20tree)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close two more ngit/nak parity gaps: - `git label TARGET LABEL[,LABEL]` — attach NIP-32 kind:1985 labels to an issue/patch/PR (the `ngit pr label` / `issue label` surface), over quartz's existing `LabelEvent`. Namespace defaults to `ugc`; `--namespace` overrides. - `git apply PATCH_ID` — fetch a kind:1617 patch and apply it to the local working tree via `git am` (the `nak git patch apply` / `ngit pr apply` surface); `--check` dry-runs `git apply --check`, `--print` emits the patch. Shells out to `git` like `git init`, since it operates on the local checkout. Verified end-to-end: a patch published to a relay, fetched, and `git am`'d as a real commit into a scratch repo; labels land as kind 1985. The harness gains 5 assertions (label + a full publish→apply round-trip), now 33 offline. Remaining out-of-scope items are documented: git-packfile push (needs a git write layer quartz lacks) and NIP-34 cover notes (kind 1624, no quartz builder). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr --- cli/README.md | 2 + cli/ROADMAP.md | 2 +- .../com/vitorpamplona/amethyst/cli/Main.kt | 2 + .../amethyst/cli/commands/GitApplyCommand.kt | 114 ++++++++++++++++++ .../amethyst/cli/commands/GitCommands.kt | 9 +- .../amethyst/cli/commands/GitLabelCommand.kt | 88 ++++++++++++++ cli/tests/README.md | 9 +- cli/tests/git/git-nip34-headless.sh | 29 +++++ 8 files changed, 248 insertions(+), 7 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitApplyCommand.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitLabelCommand.kt diff --git a/cli/README.md b/cli/README.md index 90b55a5cef..2ea5a7bd51 100644 --- a/cli/README.md +++ b/cli/README.md @@ -348,6 +348,7 @@ URL) **or** a raw `http(s)` clone URL. This is the git-object read side of |---|---| | `amy git issue REPO --subject S [BODY] [--hashtag T[,T]]` | Publish a kind:1621 issue. BODY from arg or stdin. | | `amy git patch REPO [--file PATH] [--root\|--root-revision] [--commit C] [--parent-commit P] [--in-reply-to ID]` | Publish a kind:1617 patch. Body is `git format-patch` output from `--file` or stdin. | +| `amy git apply PATCH_ID [--check\|--print] [--repo PATH]` | Fetch a kind:1617 patch and apply it to the local working tree (`git am`); `--check` dry-runs, `--print` emits the patch. | | `amy git pr REPO --commit TIP --clone URL[,URL] [--subject S] [--branch-name N] [--merge-base C] [--label L[,L]] [DESC]` | Publish a kind:1618 pull request (references a pushed branch tip by clone URL + commit). | | `amy git pr-update PR --commit TIP --clone URL[,URL] [--merge-base C]` | Publish a kind:1619 update to a pull request's tip. | | `amy git issues\|patches\|prs REPO [--open\|--applied\|--closed\|--draft\|--status a,b] [--limit N]` | List a repo's issues / patches / PRs with their derived status. | @@ -358,6 +359,7 @@ URL) **or** a raw `http(s)` clone URL. This is the git-object read side of | Command | What it does | |---|---| | `amy git comment TARGET [BODY]` | Reply to an issue/patch/PR/repo with a NIP-22 kind:1111 comment. BODY from arg or stdin. | +| `amy git label TARGET LABEL[,LABEL] [--namespace N]` | Attach NIP-32 kind:1985 labels to an issue/patch/PR (namespace defaults to `ugc`). | | `amy git open TARGET [MSG]` | Publish a kind:1630 status (open / reopen / ready-for-review). | | `amy git applied TARGET [MSG] [--merge-commit C] [--commit C[,C]] [--patch ID[,ID]]` | Publish a kind:1631 status (applied / merged / resolved). Aliases: `merged`, `resolved`. | | `amy git close TARGET [MSG]` | Publish a kind:1632 status (closed). | diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 7fb1d48690..01b5ed0c75 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -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), status open/applied/closed/draft (1630-1633), GRASP server list (10317); `issues`/`patches`/`prs`/`thread` reads derive status; `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) is 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. | | `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`. | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 0a89fd9823..2f0ac667fa 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -645,6 +645,8 @@ private fun printUsage() { | [--subject S] [--branch-name N] [--merge-base C] [--label L[,L]] | git pr-update PR --commit TIP --clone URL publish a kind:1619 pull-request update | git comment TARGET [BODY] NIP-22 kind:1111 comment on issue/patch/PR/repo + | git label TARGET LABEL[,LABEL] NIP-32 kind:1985 labels on an issue/patch/PR + | git apply PATCH_ID [--check|--print] apply a fetched kind:1617 patch to the local tree | git open|applied|close|draft TARGET [MSG] publish a kind:1630/1631/1632/1633 status | git issues|patches|prs REPO list a repo's issues/patches/PRs + status | [--open|--applied|--closed|--draft] [--limit N] diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitApplyCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitApplyCommand.kt new file mode 100644 index 0000000000..fb33e7db66 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitApplyCommand.kt @@ -0,0 +1,114 @@ +/* + * 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.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent +import java.io.File + +/** + * `amy git apply PATCH_ID` — fetch a NIP-34 kind:1617 patch and apply it to the + * local git working tree (the `nak git patch apply` / `ngit pr apply` surface). + * The patch content is `git format-patch` output, so by default it is fed to + * `git am` (applied as a commit); `--check` dry-runs `git apply --check` and + * `--print` just emits the patch without touching the tree. + * + * This shells out to `git`, like `git init` — it operates on the local checkout. + */ +object GitApplyCommand { + suspend fun apply( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val patchRef = args.positional(0, "patch-event-id") + val repoDir = File(args.flag("repo") ?: ".").absoluteFile + val check = args.bool("check") + val print = args.bool("print") + val id = + GitSupport.resolveEventId(patchRef) + ?: return Output.error("bad_args", "expected a note/nevent/64-hex patch id") + args.rejectUnknown("relay") + + Context.openOrAnonymous(dataDir).use { ctx -> + ctx.prepare() + val patch = + GitSupport.fetchEvent(ctx, id, args) as? GitPatchEvent + ?: return Output.error("not_found", "no patch (kind 1617) found for $patchRef") + val content = patch.content + val subject = patch.subject() + + if (print) { + Output.emit(mapOf("patch_id" to patch.id, "subject" to subject, "content" to content)) + return 0 + } + + val (mode, gitArgs) = + if (check) { + "check" to arrayOf("apply", "--check", "-") + } else { + "am" to arrayOf("am", "--") + } + val (code, output) = runGit(repoDir, content, *gitArgs) + if (code != 0) { + // Leave the tree clean on a failed `git am` so a retry isn't blocked. + if (mode == "am") runGit(repoDir, null, "am", "--abort") + return Output.error( + "apply_failed", + "git $mode failed for patch ${patch.id}", + extra = mapOf("patch_id" to patch.id, "mode" to mode, "git_output" to output.trim()), + ) + } + Output.emit( + mapOf( + "patch_id" to patch.id, + "subject" to subject, + "mode" to mode, + "applied" to (mode == "am"), + "git_output" to output.trim(), + ), + ) + return 0 + } + } + + /** Run `git ` in [repoDir], optionally feeding [input] on stdin; returns (exitCode, merged stdout+stderr). */ + private fun runGit( + repoDir: File, + input: String?, + vararg gitArgs: String, + ): Pair = + try { + val proc = + ProcessBuilder(listOf("git", *gitArgs)) + .directory(repoDir) + .redirectErrorStream(true) + .start() + if (input != null) proc.outputStream.use { it.write(input.toByteArray()) } else proc.outputStream.close() + val out = proc.inputStream.readBytes().decodeToString() + proc.waitFor() to out + } catch (e: Exception) { + 1 to (e.message ?: "could not run git (is it installed and is this a git repo?)") + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt index 9391fd73bc..2ce6b6b2e9 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt @@ -75,6 +75,8 @@ object GitCommands { | git patch REPO [--file PATH] publish a kind:1617 patch (git format-patch | [--root|--root-revision] [--commit C] from --file or stdin) | [--parent-commit P] [--in-reply-to ID] + | git apply PATCH_ID [--check|--print] apply a fetched kind:1617 patch to the local tree + | [--repo PATH] (default: `git am`; --check dry-runs; --print emits it) | git pr REPO --commit TIP --clone URL[,URL] publish a kind:1618 pull request [DESC arg] | [--subject S] [--branch-name N] [--merge-base C] [--label L[,L]] | git pr-update PR --commit TIP --clone URL[,URL] publish a kind:1619 pull-request update @@ -82,8 +84,9 @@ object GitCommands { | [--open|--applied|--closed|--draft|--status a,b] [--limit N] | git thread EVENT_ID print one item + its status timeline + comments | - |Comments & status: + |Comments, labels & status: | git comment TARGET [BODY] NIP-22 kind:1111 comment (BODY arg or stdin) + | git label TARGET LABEL[,LABEL] [--namespace N] NIP-32 kind:1985 labels on an issue/patch/PR | git open|applied|close|draft TARGET [MESSAGE] publish a kind:1630/1631/1632/1633 status | applied: [--merge-commit C] [--commit C[,C]] [--patch ID[,ID]] | @@ -98,7 +101,7 @@ object GitCommands { route( "git", tail, - "git ", + "git ", mapOf( "init" to { rest -> GitInitCommand.init(dataDir, rest) }, "announce" to { rest -> announce(dataDir, rest) }, @@ -118,6 +121,8 @@ object GitCommands { "prs" to { rest -> GitReadCommands.prs(dataDir, rest) }, "thread" to { rest -> GitReadCommands.thread(dataDir, rest) }, "comment" to { rest -> GitCommentCommand.comment(dataDir, rest) }, + "label" to { rest -> GitLabelCommand.label(dataDir, rest) }, + "apply" to { rest -> GitApplyCommand.apply(dataDir, rest) }, "open" to { rest -> GitStatusCommands.open(dataDir, rest) }, "applied" to { rest -> GitStatusCommands.applied(dataDir, rest) }, "merged" to { rest -> GitStatusCommands.applied(dataDir, rest) }, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitLabelCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitLabelCommand.kt new file mode 100644 index 0000000000..4f39f9376e --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitLabelCommand.kt @@ -0,0 +1,88 @@ +/* + * 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.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip32Labeling.LabelEvent +import com.vitorpamplona.quartz.nip32Labeling.tags.LabelTag + +/** + * `amy git label TARGET LABEL[,LABEL]` — attach NIP-32 kind:1985 labels to a + * patch, pull request, or issue (the `ngit pr label` / `issue label` surface). + * Labels default to the `ugc` namespace; override with `--namespace`. + */ +object GitLabelCommand { + suspend fun label( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val targetRef = args.positional(0, "target-event-id") + val namespace = args.flag("namespace") ?: LabelTag.DEFAULT_NAMESPACE + val labels = + args + .positional(1, "label[,label]") + .split(',') + .map { it.trim() } + .filter { it.isNotEmpty() } + .map { LabelTag(it, namespace) } + if (labels.isEmpty()) return Output.error("bad_args", "git label requires at least one label") + val content = args.flag("content") ?: "" + val id = + GitSupport.resolveEventId(targetRef) + ?: return Output.error("bad_args", "expected a note/nevent/64-hex target id") + args.rejectUnknown("relay") + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val target = + GitSupport.fetchEvent(ctx, id, args) + ?: return Output.error("not_found", "no event found for $targetRef") + val template = + LabelEvent.buildEventLabel( + labeledEventId = target.id, + labeledEventAuthor = target.pubKey, + labels = labels, + content = content, + ) + val signed = ctx.signer.sign(template) + val repoATag = GitSupport.repositoryOf(target) + val repo = repoATag?.let { GitSupport.fetchRepo(ctx, Address(it.kind, it.pubKeyHex, it.dTag), args) } + val targets = GitSupport.deliveryTargets(ctx, repo, args) + val ack = ctx.publish(signed, targets) + RawEventSupport.publishGuard(ack, signed.id)?.let { return it } + Output.emit( + mapOf( + "event_id" to signed.id, + "kind" to signed.kind, + "target" to target.id, + "namespace" to namespace, + "labels" to labels.map { it.label }, + ) + RawEventSupport.ackFields(ack), + ) + return 0 + } + } +} diff --git a/cli/tests/README.md b/cli/tests/README.md index d6e36efc37..19d9098f1f 100644 --- a/cli/tests/README.md +++ b/cli/tests/README.md @@ -72,10 +72,11 @@ Suite notes: git checkout (announce + state derived via `git`), announce (30617) + state (30618) + GRASP list (10317), issue (1621), patch (1617), pull request (1618) + update (1619), NIP-22 - comment (1111), and status events (1630-1633), then asserts the - `issues`/`patches`/`prs`/`thread` reads derive the right status (a closed - issue reads `closed`, an applied PR reads `applied`) and that - `--open`/`--closed` filter correctly. Pass `--live` to additionally exercise + comment (1111), NIP-32 label (1985), and status events (1630-1633). It also + publishes a real `git format-patch` and `git apply`s it back into a scratch + working tree, and asserts the `issues`/`patches`/`prs`/`thread` reads derive + the right status (a closed issue reads `closed`, an applied PR reads + `applied`) and that `--open`/`--closed` filter correctly. Pass `--live` to additionally exercise the git smart-HTTP reads (`git browse`/`cat`/`log`) against a real public repo (`$LIVE_REPO`, default octocat/Hello-World) — skipped by default since it needs a reachable git host. diff --git a/cli/tests/git/git-nip34-headless.sh b/cli/tests/git/git-nip34-headless.sh index 46d3fb284a..bc39042998 100755 --- a/cli/tests/git/git-nip34-headless.sh +++ b/cli/tests/git/git-nip34-headless.sh @@ -182,12 +182,41 @@ banner "comment + status" CMT="$(M git comment "$ISSID" "thanks for reporting" --relay "$RELAY_URL")" assert_eq "$(echo "$CMT" | jq -r '.kind')" "1111" comment.kind "comment is NIP-22 kind 1111" +LABEL="$(M git label "$ISSID" "bug,help-wanted" --relay "$RELAY_URL")" +assert_eq "$(echo "$LABEL" | jq -r '.kind')" "1985" label.kind "label is NIP-32 kind 1985" +assert_eq "$(echo "$LABEL" | jq -r '.labels | length')" "2" label.count "two labels attached" + CLOSE="$(M git close "$ISSID" "wontfix" --relay "$RELAY_URL")" assert_eq "$(echo "$CLOSE" | jq -r '.kind')" "1632" close.kind "close status is kind 1632" APPLIED="$(M git applied "$PRID" "merged it" --merge-commit feed99 --commit feed02 --relay "$RELAY_URL")" assert_eq "$(echo "$APPLIED" | jq -r '.kind')" "1631" applied.kind "applied status is kind 1631" +# ============================================================================= +# git apply — publish a real patch and apply it to a local working tree +# ============================================================================= +banner "git apply (nostr patch → local git am)" +SCRATCH="$(mk_home)/scratch" +git init -q "$SCRATCH" +git -C "$SCRATCH" config user.email a@b.c +git -C "$SCRATCH" config user.name t +echo "line1" >"$SCRATCH/f.txt" +git -C "$SCRATCH" add f.txt +git -C "$SCRATCH" commit -qm "init" +# Make a real commit, capture its format-patch, then roll it back so `apply` can re-add it. +echo "line2" >>"$SCRATCH/f.txt" +git -C "$SCRATCH" commit -qam "add line2" +PATCHTXT="$(git -C "$SCRATCH" format-patch -1 --stdout)" +git -C "$SCRATCH" reset -q --hard HEAD~1 +APATCH="$(printf '%s' "$PATCHTXT" | M git patch "$ADDR" --root --relay "$RELAY_URL")" +APID="$(echo "$APATCH" | jq -r '.event_id')" +CHECK="$(M git apply "$APID" --check --repo "$SCRATCH")" +assert_eq "$(echo "$CHECK" | jq -r '.mode')" "check" apply.check "apply --check dry-runs cleanly" +APPLY="$(M git apply "$APID" --repo "$SCRATCH")" +info "apply: $APPLY" +assert_eq "$(echo "$APPLY" | jq -r '.applied')" "true" apply.applied "patch applied via git am" +assert_eq "$(git -C "$SCRATCH" log --oneline | head -1 | sed 's/^[0-9a-f]* //')" "add line2" apply.commit "commit landed in the working tree" + # ============================================================================= # Status-deriving reads # ============================================================================= From 1a77c9553191f66565071b40a9d999ff15dc7d7f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 00:33:55 +0000 Subject: [PATCH 06/11] fix(quartz): NIP-34 wire-format interop with ngit (clone/web, issue p, plain r) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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", ]` 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 Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr --- cli/ROADMAP.md | 2 +- cli/tests/git/git-nip34-headless.sh | 15 +++ .../quartz/nip34Git/issue/GitIssueEvent.kt | 4 + .../nip34Git/patch/TagArrayBuilderExt.kt | 10 +- .../quartz/nip34Git/pr/TagArrayBuilderExt.kt | 4 +- .../nip34Git/pr/UpdateTagArrayBuilderExt.kt | 3 +- .../nip34Git/repository/GitRepositoryEvent.kt | 19 ++- .../nip34Git/repository/TagArrayBuilderExt.kt | 6 +- .../nip34Git/repository/tags/CloneTag.kt | 17 +++ .../quartz/nip34Git/repository/tags/WebTag.kt | 16 +++ .../quartz/nip34Git/GitNip34InteropTest.kt | 117 ++++++++++++++++++ 11 files changed, 197 insertions(+), 16 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip34Git/GitNip34InteropTest.kt diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 01b5ed0c75..ee3998d54c 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -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`. | diff --git a/cli/tests/git/git-nip34-headless.sh b/cli/tests/git/git-nip34-headless.sh index bc39042998..88a54e21f5 100755 --- a/cli/tests/git/git-nip34-headless.sh +++ b/cli/tests/git/git-nip34-headless.sh @@ -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" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/issue/GitIssueEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/issue/GitIssueEvent.kt index 8a693019dc..794fad4da0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/issue/GitIssueEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/issue/GitIssueEvent.kt @@ -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) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/patch/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/patch/TagArrayBuilderExt.kt index 2e99b69ff7..1762f8cd97 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/patch/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/patch/TagArrayBuilderExt.kt @@ -38,12 +38,12 @@ fun TagArrayBuilder.repository(rep: EventHintBundle]` 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", ]` 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.euc(commit: String) = addUnique(EucTag.assemble(commit)) +fun TagArrayBuilder.euc(commit: String) = addUnique(arrayOf(EucTag.TAG_NAME, commit)) fun TagArrayBuilder.commit(commit: String) = addUnique(CommitTag.assemble(commit)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/pr/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/pr/TagArrayBuilderExt.kt index 551384dd0f..ca384e8108 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/pr/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/pr/TagArrayBuilderExt.kt @@ -38,7 +38,9 @@ fun TagArrayBuilder.repository(rep: ATag) = addUnique(rep.t fun TagArrayBuilder.repository(rep: EventHintBundle) = addUnique(rep.toATag().toATagArray()) -fun TagArrayBuilder.euc(commit: String) = addUnique(EucTag.assemble(commit)) +// NIP-34 pull requests use the plain `["r", ]` shape; the `"euc"` marker +// is only on the kind-30617 announcement (see the patch builder for the rationale). +fun TagArrayBuilder.euc(commit: String) = addUnique(arrayOf(EucTag.TAG_NAME, commit)) fun TagArrayBuilder.currentCommit(commit: String) = addUnique(CurrentCommitTag.assemble(commit)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/pr/UpdateTagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/pr/UpdateTagArrayBuilderExt.kt index 98f5e21c86..db9c1e84e9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/pr/UpdateTagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/pr/UpdateTagArrayBuilderExt.kt @@ -36,7 +36,8 @@ fun TagArrayBuilder.repository(rep: ATag) = addUnique fun TagArrayBuilder.repository(rep: EventHintBundle) = addUnique(rep.toATag().toATagArray()) -fun TagArrayBuilder.euc(commit: String) = addUnique(EucTag.assemble(commit)) +// Plain `["r", ]` — the `"euc"` marker is only on the 30617 announcement. +fun TagArrayBuilder.euc(commit: String) = addUnique(arrayOf(EucTag.TAG_NAME, commit)) fun TagArrayBuilder.currentCommit(commit: String) = addUnique(CurrentCommitTag.assemble(commit)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/repository/GitRepositoryEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/repository/GitRepositoryEvent.kt index cf3451008a..8944582507 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/repository/GitRepositoryEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/repository/GitRepositoryEvent.kt @@ -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 = 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 = 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 = tags.mapNotNull(CloneTag::parse) + /** All clone URLs — tolerant of both the multi-value and repeated forms (see [webs]). */ + fun clones(): List = 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) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/repository/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/repository/TagArrayBuilderExt.kt index 4fb760db5c..68a2b6e5a4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/repository/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/repository/TagArrayBuilderExt.kt @@ -36,11 +36,13 @@ fun TagArrayBuilder.description(description: String) = addUn fun TagArrayBuilder.webUrl(webUrl: String) = add(WebTag.assemble(webUrl)) -fun TagArrayBuilder.webUrls(webUrls: List) = 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.webUrls(webUrls: List) = addUnique(WebTag.assemble(webUrls)) fun TagArrayBuilder.cloneUrl(cloneUrl: String) = add(CloneTag.assemble(cloneUrl)) -fun TagArrayBuilder.cloneUrls(cloneUrls: List) = 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.cloneUrls(cloneUrls: List) = addUnique(CloneTag.assemble(cloneUrls)) fun TagArrayBuilder.relays(relays: List) = addUnique(RelaysTag.assemble(relays)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/repository/tags/CloneTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/repository/tags/CloneTag.kt index 8916624388..0c10335275 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/repository/tags/CloneTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/repository/tags/CloneTag.kt @@ -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", "", "", ...]` — so [assemble] emits + * that form and [parseAll] reads every value. [parse] (first value only) and + * the legacy repeated `["clone", ""]` 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): List { + 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): Array = (listOf(TAG_NAME) + urls.filter { it.isNotEmpty() }).toTypedArray() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/repository/tags/WebTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/repository/tags/WebTag.kt index db693d1671..b35e9a8863 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/repository/tags/WebTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/repository/tags/WebTag.kt @@ -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", "", "", ...]` — 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): List { + 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): Array = (listOf(TAG_NAME) + urls.filter { it.isNotEmpty() }).toTypedArray() } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip34Git/GitNip34InteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip34Git/GitNip34InteropTest.kt new file mode 100644 index 0000000000..f2fd10155e --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip34Git/GitNip34InteropTest.kt @@ -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", ]`; the `"euc"` marker lives + * only on the kind-30617 announcement. + */ +class GitNip34InteropTest { + private val owner = "aa".repeat(32) + + private fun repo(tags: Array>) = 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)") + } +} From fa32ea50a3b563555497b9643b491a3df31cb3ca Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 00:46:24 +0000 Subject: [PATCH 07/11] test(cli): live interop check against the real ngit-published amethyst repo Adds a `--live` block that reads the actual amethyst repository ngit publishes to relay.ngit.dev and asserts our reader parses ngit's real multi-value `clone` tag (currently 4 URLs) plus its published issues. This is the real-world proof of the multi-value interop fix: the pre-fix reader would have surfaced only the first clone URL. Opt-in (needs network + the live relay), skipped by default. Verified manually end-to-end against the live repo: repo announcement (4 clone URLs), issues (1621), patches (1617), pull requests (1618, with a real `closed` status derived from ngit's status event), and a NIP-22 comment via `git thread` all read correctly. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr --- cli/tests/git/git-nip34-headless.sh | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/cli/tests/git/git-nip34-headless.sh b/cli/tests/git/git-nip34-headless.sh index 88a54e21f5..6a898abd26 100755 --- a/cli/tests/git/git-nip34-headless.sh +++ b/cli/tests/git/git-nip34-headless.sh @@ -278,8 +278,30 @@ if [[ "$LIVE" -eq 1 ]]; then fi LOG="$(M git log "$LIVE_REPO" --depth 2 --json)" assert_nonempty "$(echo "$LOG" | jq -r '.commits[0].oid')" live.log "log returns at least one commit" + + # ngit interop against the REAL amethyst repo (published by ngit to relay.ngit.dev). + # Proves our reader parses ngit's actual multi-value `clone` tag (4 URLs) — the + # exact case the pre-fix reader collapsed to one. + banner "live: reading the real ngit-published amethyst repo" + NGIT_RELAY="${NGIT_RELAY:-wss://relay.ngit.dev}" + NGIT_REPO="30617:460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c:amethyst" + RSHOW="$(M git show "$NGIT_REPO" --relay "$NGIT_RELAY")" + if [[ -n "$RSHOW" && "$(echo "$RSHOW" | jq -r '.name // empty')" == "amethyst" ]]; then + CLONES="$(echo "$RSHOW" | jq -r '.clone | length')" + if [[ "$CLONES" -ge 2 ]]; then + pass_msg "ngit.clone_multivalue: read $CLONES clone URLs from ngit's multi-value tag" + record_result ngit.clone_multivalue pass "$CLONES clone URLs" + else + fail_msg "ngit.clone_multivalue: only $CLONES clone URL parsed from ngit's multi-value tag" + record_result ngit.clone_multivalue fail "expected >=2, got $CLONES" + fi + RISS="$(M git issues "$NGIT_REPO" --relay "$NGIT_RELAY" --limit 1 | jq -r '.count')" + assert_nonempty "$RISS" ngit.issues "read ngit-published issues" + else + skip_msg "ngit live repo unreachable (relay.ngit.dev) — skipping real-repo interop check" + fi else - skip_msg "live git smart-HTTP reads (browse/cat/log) — pass --live to run" + skip_msg "live git smart-HTTP reads (browse/cat/log) + real ngit repo — pass --live to run" fi grep -q $'\tfail\t' "$RESULTS_FILE" && exit 1 From 63d01c9287e7ecd6a753a4eb72f86db6af8e9d3a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 01:37:03 +0000 Subject: [PATCH 08/11] =?UTF-8?q?fix(cli):=20audit=20fixes=20=E2=80=94=20s?= =?UTF-8?q?hallow-clone=20euc,=20read=20truncation,=20process=20deadlock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings from a review pass over the git-parity branch, with fixes: - **`git init` announced a WRONG earliest-unique-commit on shallow clones** (interop-critical). `git rev-list --max-parents=0 HEAD` returns the shallow boundary commits, not the true root, so the repo would be announced under a different cross-fork identity than ngit computes. Now: detect shallow clones and omit the euc with a warning to pass `--earliest-commit`; on full clones derive the deterministic `--first-parent` mainline root instead of an arbitrary `tail -1`. - **`git issues|patches|prs` silently truncated and mis-derived status** on active repos: one single-page `drain` pulled items AND status events under a shared cap, so status events (newer, more numerous) could crowd items out of the window and the close-status that determines an item's state could fall outside it → a closed item read as open. Now paginate the items (`drainAllPages`) and fetch exactly the statuses that `e`-reference them. Verified on the live amethyst repo: 51 PRs paginated, 19 correctly closed. - **Pipe-buffer deadlocks** (latent): `GitInitCommand.git()` discards stderr to the OS (a chatty command can no longer fill its stderr pipe and hang the stdout read); `GitApplyCommand.runGit()` writes stdin on a background thread while draining stdout, so a patch larger than the pipe buffer can't deadlock. - Minor: `git cat` binary detection uses an index loop instead of boxing 8000 bytes; `GitRepositoryEvent.clones()/webs()` dedupe. The harness `git init` test now runs against a fresh full checkout (this repo's CI checkout is shallow) and adds a shallow-clone case asserting the euc is omitted. 38/38. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr --- .../amethyst/cli/commands/GitApplyCommand.kt | 16 ++++++- .../cli/commands/GitBrowseCommands.kt | 6 ++- .../amethyst/cli/commands/GitInitCommand.kt | 33 +++++++++++--- .../amethyst/cli/commands/GitReadCommands.kt | 44 ++++++++++++------- cli/tests/git/git-nip34-headless.sh | 26 +++++++++-- .../nip34Git/repository/GitRepositoryEvent.kt | 6 +-- 6 files changed, 99 insertions(+), 32 deletions(-) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitApplyCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitApplyCommand.kt index fb33e7db66..4ab44369b6 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitApplyCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitApplyCommand.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent import java.io.File +import kotlin.concurrent.thread /** * `amy git apply PATCH_ID` — fetch a NIP-34 kind:1617 patch and apply it to the @@ -105,9 +106,20 @@ object GitApplyCommand { .directory(repoDir) .redirectErrorStream(true) .start() - if (input != null) proc.outputStream.use { it.write(input.toByteArray()) } else proc.outputStream.close() + // Feed stdin on a separate thread while we drain stdout on this one: a + // large patch (bigger than the OS pipe buffer) would otherwise deadlock + // — git blocks writing output we haven't read, we block writing stdin. + val writer = + if (input != null) { + thread(name = "git-stdin") { runCatching { proc.outputStream.use { it.write(input.toByteArray()) } } } + } else { + proc.outputStream.close() + null + } val out = proc.inputStream.readBytes().decodeToString() - proc.waitFor() to out + val code = proc.waitFor() + writer?.join() + code to out } catch (e: Exception) { 1 to (e.message ?: "could not run git (is it installed and is this a git repo?)") } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitBrowseCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitBrowseCommands.kt index e56a2e4309..ef2a62f4d9 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitBrowseCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitBrowseCommands.kt @@ -245,5 +245,9 @@ object GitBrowseCommands { ) /** A blob is treated as binary when it contains a NUL byte in its head. */ - private fun isBinary(bytes: ByteArray): Boolean = bytes.take(8000).any { it.toInt() == 0 } + private fun isBinary(bytes: ByteArray): Boolean { + val end = minOf(8000, bytes.size) + for (i in 0 until end) if (bytes[i].toInt() == 0) return true + return false + } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitInitCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitInitCommand.kt index 1a9b68f131..c6d7877fd2 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitInitCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitInitCommand.kt @@ -53,9 +53,20 @@ object GitInitCommand { val toplevel = git(repoDir, "rev-parse", "--show-toplevel")?.let { File(it) } val derivedName = toplevel?.name val originUrl = git(repoDir, "remote", "get-url", "origin")?.let(::normalizeCloneUrl) - // The earliest unique commit is the root commit; `rev-list` prints newest - // first, so the last line is the initial commit. - val euc = git(repoDir, "rev-list", "--max-parents=0", "HEAD")?.lineSequence()?.lastOrNull { it.isNotBlank() } + // The earliest-unique-commit is the repo's mainline (first-parent) root + // commit — the cross-fork identity every NIP-34 client must agree on. A + // SHALLOW clone cannot know its true root (`rev-list --max-parents=0` + // returns the shallow-boundary commits, not the real first commit), so we + // must NOT derive a bogus euc there — that would announce the repo under a + // wrong identity and fork it away from ngit's view. `--first-parent` keeps + // the mainline root deterministic when a history has merged-in subtree roots. + val shallow = git(repoDir, "rev-parse", "--is-shallow-repository") == "true" + val euc = + if (toplevel == null || shallow) { + null + } else { + git(repoDir, "rev-list", "--max-parents=0", "--first-parent", "HEAD")?.lineSequence()?.lastOrNull { it.isNotBlank() } + } val name = args.flag("name") ?: derivedName @@ -63,6 +74,13 @@ object GitInitCommand { val identifier = args.flag("d") ?: args.flag("identifier") ?: kebab(name) val cloneUrls = GitSupport.csv(args, "clone").ifEmpty { listOfNotNull(originUrl) } val earliestCommit = args.flag("earliest-commit") ?: euc + if (earliestCommit == null && toplevel != null) { + System.err.println( + "[git init] warning: could not derive the earliest-unique-commit" + + (if (shallow) " (shallow clone)" else "") + + " — the announcement will omit it. Pass --earliest-commit so the repo keeps a stable cross-fork identity.", + ) + } Context.open(dataDir).use { ctx -> ctx.prepare() @@ -132,7 +150,11 @@ object GitInitCommand { return branches + tags } - /** Run `git ` in [repoDir]; returns trimmed stdout on exit 0, else null (git missing / not a repo). */ + /** + * Run `git ` in [repoDir]; returns trimmed stdout on exit 0, else null + * (git missing / not a repo). stderr is discarded straight to the OS so a + * chatty command can never fill its stderr pipe and deadlock the stdout read. + */ private fun git( repoDir: File, vararg gitArgs: String, @@ -141,10 +163,9 @@ object GitInitCommand { val proc = ProcessBuilder(listOf("git", *gitArgs)) .directory(repoDir) - .redirectErrorStream(false) + .redirectError(ProcessBuilder.Redirect.DISCARD) .start() val out = proc.inputStream.readBytes().decodeToString() - proc.errorStream.readBytes() if (proc.waitFor() == 0) out.trim().ifEmpty { null } else null } catch (_: Exception) { null diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitReadCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitReadCommands.kt index 27523910e6..e8bc689b10 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitReadCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitReadCommands.kt @@ -82,27 +82,39 @@ object GitReadCommands { ctx.prepare() val repoAddress = GitSupport.repoCoordinate(addr) val relays = RawEventSupport.queryTargets(ctx, args) - // One drain pulls the items AND their status events (both `a`-tag the repo). - val received = - ctx.drain( - relays.associateWith { - listOf(Filter(kinds = listOf(itemKind) + STATUS_KINDS, tags = mapOf("a" to listOf(repoAddress)), limit = limit + 200)) - }, - ) - val events = received.map { it.second } - val authorities = repoAuthorities(ctx, addr, args) - val statuses = events.filterIsInstance() - val items = - events + // Two queries so item volume and status volume never starve each other + // (a busy repo can have far more status events than items). First page + // the items to the limit; then fetch exactly the status events that + // `e`-reference those items, so derived status is never truncated away. + val itemEvents = + ctx + .drainAllPages(relays.associateWith { listOf(Filter(kinds = listOf(itemKind), tags = mapOf("a" to listOf(repoAddress)), limit = limit)) }) + .asSequence() + .map { it.second } .filter { it.kind == itemKind } .distinctBy { it.id } .sortedByDescending { it.createdAt } - .map { item -> - val status = latestStatus(item, statuses, authorities) - GitSupport.targetSummary(item) + mapOf("status" to status) - }.filter { wanted == null || it["status"] in wanted } .take(limit) + .toList() + + val itemIds = itemEvents.map { it.id } + val statuses = + if (itemIds.isEmpty()) { + emptyList() + } else { + ctx + .drain(relays.associateWith { listOf(Filter(kinds = STATUS_KINDS, tags = mapOf("e" to itemIds))) }) + .map { it.second } + .filterIsInstance() + .distinctBy { it.id } + } + val authorities = repoAuthorities(ctx, addr, args) + + val items = + itemEvents + .map { item -> GitSupport.targetSummary(item) + mapOf("status" to latestStatus(item, statuses, authorities)) } + .filter { wanted == null || it["status"] in wanted } Output.emit(mapOf("repository" to repoAddress, "count" to items.size, "items" to items)) return 0 diff --git a/cli/tests/git/git-nip34-headless.sh b/cli/tests/git/git-nip34-headless.sh index 6a898abd26..ca72bb154a 100755 --- a/cli/tests/git/git-nip34-headless.sh +++ b/cli/tests/git/git-nip34-headless.sh @@ -119,16 +119,34 @@ done grep -q "relay up at" "$LOG_FILE" || { fail_msg "relay did not come up"; exit 1; } # ============================================================================= -# git init — bootstrap from the local git checkout (this repo) +# git init — bootstrap from a local (full, non-shallow) git checkout # ============================================================================= -banner "git init (from the amethyst checkout)" -INIT="$(M git init --repo "$REPO_ROOT" --relay "$RELAY_URL")" +banner "git init (from a full scratch checkout)" +INITREPO="$(mk_home)/initrepo" +git init -q "$INITREPO" +git -C "$INITREPO" config user.email a@b.c +git -C "$INITREPO" config user.name t +echo "hello" >"$INITREPO/README.md" +git -C "$INITREPO" add README.md +git -C "$INITREPO" commit -qm "initial commit" +ROOT_COMMIT="$(git -C "$INITREPO" rev-list --max-parents=0 --first-parent HEAD | tail -1)" +INIT="$(M git init --repo "$INITREPO" --relay "$RELAY_URL")" info "init: $INIT" assert_eq "$(echo "$INIT" | jq -r '.from_git_repo')" "true" init.from_git "init derived fields from the git repo" assert_nonempty "$(echo "$INIT" | jq -r '.name')" init.name "repo name derived" -assert_nonempty "$(echo "$INIT" | jq -r '.earliest_commit')" init.euc "earliest-unique-commit derived from git" +assert_eq "$(echo "$INIT" | jq -r '.earliest_commit')" "$ROOT_COMMIT" init.euc "earliest-unique-commit is the first-parent root" assert_nonempty "$(echo "$INIT" | jq -r '.state_event_id')" init.state "init also published a 30618 state event" +# A SHALLOW clone must NOT invent an euc (it can't know the true root). +SHALLOWREPO="$(mk_home)/shallowrepo" +git clone -q --depth 1 "file://$INITREPO" "$SHALLOWREPO" 2>/dev/null || git clone -q --depth 1 "$INITREPO" "$SHALLOWREPO" +if [[ "$(git -C "$SHALLOWREPO" rev-parse --is-shallow-repository)" == "true" ]]; then + SINIT="$(M git init --repo "$SHALLOWREPO" --d shallow-test --relay "$RELAY_URL")" + assert_eq "$(echo "$SINIT" | jq -r '.earliest_commit')" "null" init.shallow_euc "shallow clone omits the euc (no wrong identity)" +else + skip_msg "could not create a shallow clone for init.shallow_euc" +fi + # ============================================================================= # Repository announcement (30617) + state (30618) # ============================================================================= diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/repository/GitRepositoryEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/repository/GitRepositoryEvent.kt index 8944582507..521c6c9e9e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/repository/GitRepositoryEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/repository/GitRepositoryEvent.kt @@ -66,13 +66,13 @@ class GitRepositoryEvent( * `["web", url1]` / `["web", url2]` form, so a repo announced by any client * round-trips without dropping URLs. */ - fun webs(): List = tags.flatMap(WebTag::parseAll) + fun webs(): List = tags.flatMap(WebTag::parseAll).distinct() /** First clone URL, for backwards compatibility. Prefer [clones]. */ fun clone() = clones().firstOrNull() - /** All clone URLs — tolerant of both the multi-value and repeated forms (see [webs]). */ - fun clones(): List = tags.flatMap(CloneTag::parseAll) + /** All clone URLs — tolerant of both the multi-value and repeated forms (see [webs]); deduped. */ + fun clones(): List = tags.flatMap(CloneTag::parseAll).distinct() /** * Relays the repository author monitors for patches and issues. NIP-34 From 4e0ac212ee884f125562440276a02e79b33b0c28 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 02:08:21 +0000 Subject: [PATCH 09/11] =?UTF-8?q?fix(cli):=20second=20audit=20pass=20?= =?UTF-8?q?=E2=80=94=20read=20routing,=20status=20perf,=20publish-ack=20+?= =?UTF-8?q?=20robustness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings from a second review round (two independent reviewers), with fixes: Read path (git issues/patches/prs/thread): - **Reads ignored the repo's own relays** (correctness). They queried only the account outbox/bootstrap (general relays); NIP-34 events live on the repo announcement's advertised relays (often GRASP/git-specific), which general relays don't mirror — so `amy git issues ` with no --relay could return empty. Now fetch the announcement once and read from queryTargets ∪ its advertised `relays`. Verified live: `git issues`/`git prs` on the amethyst repo now return real events (and derive `closed`) with NO --relay. - **O(items × statuses) status rescan** with un-memoized `rootEventId()` reparse → pre-group statuses by root id once (O(1) lookup per item). - **Status query could truncate / exceed relay caps**: statuses are now paged (`drainAllPages`) and the `#e` id set is chunked to 50 (under the common ~100-value relay filter cap). - **Latency regression**: capped the list `drainAllPages` idle timeout to 12s (was the 30s default; `drain` had been 8s). - **Nondeterministic status on same-second ties** → deterministic id tie-break. - Reuse the fetched repo for the maintainer set (removes a redundant round-trip). Write path: - **`git init` silently reported success when the 30618 state publish failed** — its ack was dropped. Now surfaced as `state_published_to`/`state_rejected_by` with a stderr warning on total rejection. - **`git apply`** feeds stdin as UTF-8 (was JVM default charset — corrupted non-ASCII patches) and joins the stdin thread in `finally` (no leak on error). - **`normalizeCloneUrl`** drops the port from `ssh://git@host:port/…` (it was carried into the https URL, making it unreachable). - **Delivery fallback** to the account outbox (repo unresolved / no advertised relays) now warns to stderr instead of reporting silent success. Known limitation (documented, not fixed): patch-revision-chain status derivation follows only the root item, and `git thread` shows first-level replies only (nested trees and 1619 PR-updates are out of scope). 38/38 harness green. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr --- .../amethyst/cli/commands/GitApplyCommand.kt | 20 ++-- .../amethyst/cli/commands/GitInitCommand.kt | 18 ++- .../amethyst/cli/commands/GitReadCommands.kt | 109 ++++++++++++------ .../amethyst/cli/commands/GitSupport.kt | 33 +++++- 4 files changed, 132 insertions(+), 48 deletions(-) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitApplyCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitApplyCommand.kt index 4ab44369b6..fb8ab81219 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitApplyCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitApplyCommand.kt @@ -99,8 +99,9 @@ object GitApplyCommand { repoDir: File, input: String?, vararg gitArgs: String, - ): Pair = - try { + ): Pair { + var writer: Thread? = null + return try { val proc = ProcessBuilder(listOf("git", *gitArgs)) .directory(repoDir) @@ -109,18 +110,23 @@ object GitApplyCommand { // Feed stdin on a separate thread while we drain stdout on this one: a // large patch (bigger than the OS pipe buffer) would otherwise deadlock // — git blocks writing output we haven't read, we block writing stdin. - val writer = + // The patch was decoded as UTF-8, so write it back as UTF-8 (not the JVM + // default charset, which would corrupt non-ASCII filenames/messages). + writer = if (input != null) { - thread(name = "git-stdin") { runCatching { proc.outputStream.use { it.write(input.toByteArray()) } } } + thread(name = "git-stdin") { runCatching { proc.outputStream.use { it.write(input.toByteArray(Charsets.UTF_8)) } } } } else { proc.outputStream.close() null } val out = proc.inputStream.readBytes().decodeToString() - val code = proc.waitFor() - writer?.join() - code to out + proc.waitFor() to out } catch (e: Exception) { 1 to (e.message ?: "could not run git (is it installed and is this a git repo?)") + } finally { + // Always join so a non-daemon stdin thread can't outlive the call (e.g. + // under the in-process test harness, which doesn't exitProcess). + writer?.join() } + } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitInitCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitInitCommand.kt index c6d7877fd2..2b7d9f3664 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitInitCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitInitCommand.kt @@ -119,11 +119,17 @@ object GitInitCommand { if (refs.isNotEmpty() || head != null) { val stateTemplate = GitRepositoryStateEvent.build(dTag = identifier, refs = refs, head = head) val signedState = ctx.signer.sign(stateTemplate) - ctx.publish(signedState, targets) + // Surface the state publish result separately (state_*) rather than + // dropping it — otherwise a fully-rejected 30618 reads as success. + val stateAck = ctx.publish(signedState, targets) result["state_event_id"] = signedState.id result["branches"] = refs.count { it.kind == RefTag.Kind.BRANCH } result["tags"] = refs.count { it.kind == RefTag.Kind.TAG } result["head"] = head + result.putAll(RawEventSupport.ackFields(stateAck).mapKeys { "state_${it.key}" }) + if (stateAck.isNotEmpty() && stateAck.none { it.value.accepted }) { + System.err.println("[git init] warning: no relay accepted the repository-state (30618) event — branches/tags/HEAD were not delivered.") + } } } Output.emit(result + RawEventSupport.ackFields(ackAnnounce)) @@ -180,7 +186,15 @@ object GitInitCommand { val path = rest.substringAfter(':') "https://$host/$path" } - url.startsWith("ssh://git@") -> "https://" + url.removePrefix("ssh://git@") + url.startsWith("ssh://git@") -> { + // ssh://git@host[:port]/owner/repo.git → https://host/owner/repo.git + // (drop the SSH port; carrying it into the https URL makes it unreachable). + val rest = url.removePrefix("ssh://git@") + val slash = rest.indexOf('/') + val hostPort = if (slash >= 0) rest.take(slash) else rest + val path = if (slash >= 0) rest.substring(slash) else "" + "https://${hostPort.substringBefore(':')}$path" + } else -> url } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitReadCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitReadCommands.kt index e8bc689b10..02e5a6655c 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitReadCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitReadCommands.kt @@ -26,12 +26,15 @@ import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent import com.vitorpamplona.quartz.nip34Git.status.GitStatusEvent /** @@ -48,6 +51,12 @@ object GitReadCommands { GitStatusEvent.KIND_DRAFT, ) + /** Idle timeout for the list reads — tighter than `drainAllPages`' 30s default so a dead relay can't stall a list. */ + private const val READ_TIMEOUT_MS = 12_000L + + /** Max event ids per `#e` status filter — many relays cap tag-filter values around 100, so stay well under. */ + private const val STATUS_ID_CHUNK = 50 + suspend fun issues( dataDir: DataDir, rest: Array, @@ -81,16 +90,21 @@ object GitReadCommands { Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val repoAddress = GitSupport.repoCoordinate(addr) - val relays = RawEventSupport.queryTargets(ctx, args) + // Fetch the announcement once: it supplies both the maintainer set + // (status authority) AND the advertised relays the events actually live + // on. Reading only from general relays misses GRASP-hosted repos. + val repo = GitSupport.fetchRepo(ctx, addr, args) + val relays = GitSupport.readTargets(ctx, repo, args) - // Two queries so item volume and status volume never starve each other - // (a busy repo can have far more status events than items). First page - // the items to the limit; then fetch exactly the status events that - // `e`-reference those items, so derived status is never truncated away. + // Two queries so item volume and status volume never starve each other. + // Items are paged to the limit; statuses are fetched separately by the + // items' ids so derived status is never truncated by item volume. val itemEvents = ctx - .drainAllPages(relays.associateWith { listOf(Filter(kinds = listOf(itemKind), tags = mapOf("a" to listOf(repoAddress)), limit = limit)) }) - .asSequence() + .drainAllPages( + relays.associateWith { listOf(Filter(kinds = listOf(itemKind), tags = mapOf("a" to listOf(repoAddress)), limit = limit)) }, + timeoutMs = READ_TIMEOUT_MS, + ).asSequence() .map { it.second } .filter { it.kind == itemKind } .distinctBy { it.id } @@ -98,22 +112,12 @@ object GitReadCommands { .take(limit) .toList() - val itemIds = itemEvents.map { it.id } - val statuses = - if (itemIds.isEmpty()) { - emptyList() - } else { - ctx - .drain(relays.associateWith { listOf(Filter(kinds = STATUS_KINDS, tags = mapOf("e" to itemIds))) }) - .map { it.second } - .filterIsInstance() - .distinctBy { it.id } - } - val authorities = repoAuthorities(ctx, addr, args) + val statusesByRoot = fetchStatusesFor(ctx, relays, itemEvents.map { it.id }).groupBy { it.rootEventId() } + val authorities = repoAuthorities(repo, addr) val items = itemEvents - .map { item -> GitSupport.targetSummary(item) + mapOf("status" to latestStatus(item, statuses, authorities)) } + .map { item -> GitSupport.targetSummary(item) + mapOf("status" to latestStatus(item, statusesByRoot, authorities)) } .filter { wanted == null || it["status"] in wanted } Output.emit(mapOf("repository" to repoAddress, "count" to items.size, "items" to items)) @@ -124,6 +128,10 @@ object GitReadCommands { /** * `amy git thread TARGET` — the target event plus its status timeline and * NIP-22 comments (and legacy kind:1622 replies). + * + * Scope: first-level replies/statuses that `e`-reference the target. Nested + * comment trees and PR-update (1619) events (which use NIP-22 uppercase `E`) + * are out of scope here. */ suspend fun thread( dataDir: DataDir, @@ -141,7 +149,9 @@ object GitReadCommands { val target = GitSupport.fetchEvent(ctx, id, args) ?: return Output.error("not_found", "no event found for $ref") - val relays = RawEventSupport.queryTargets(ctx, args) + val repoATag = GitSupport.repositoryOf(target) + val repo = repoATag?.let { GitSupport.fetchRepo(ctx, Address(it.kind, it.pubKeyHex, it.dTag), args) } + val relays = GitSupport.readTargets(ctx, repo, args) // Everything that `e`-references the target: statuses, comments, replies. val related = ctx @@ -149,12 +159,13 @@ object GitReadCommands { relays.associateWith { listOf(Filter(kinds = STATUS_KINDS + listOf(CommentEvent.KIND, GitReplyEvent.KIND), tags = mapOf("e" to listOf(id)))) }, + timeoutMs = READ_TIMEOUT_MS, ).map { it.second } .distinctBy { it.id } - val repoATag = GitSupport.repositoryOf(target) - val authorities = repoATag?.let { repoAuthorities(ctx, Address(it.kind, it.pubKeyHex, it.dTag), args) } ?: setOf(target.pubKey) + val authorities = repoATag?.let { repoAuthorities(repo, Address(it.kind, it.pubKeyHex, it.dTag)) } ?: setOf(target.pubKey) val statuses = related.filterIsInstance().filter { it.rootEventId() == id } + val statusesByRoot = statuses.groupBy { it.rootEventId() } val comments = related .filter { it.kind == CommentEvent.KIND || it.kind == GitReplyEvent.KIND } @@ -165,7 +176,7 @@ object GitReadCommands { GitSupport.targetSummary(target) + mapOf( "content" to target.content, - "status" to latestStatus(target, statuses, authorities), + "status" to latestStatus(target, statusesByRoot, authorities), "status_events" to statuses.sortedBy { it.createdAt }.map { mapOf("event_id" to it.id, "status" to GitSupport.statusLabel(it.kind), "author" to it.pubKey, "created_at" to it.createdAt) @@ -179,34 +190,56 @@ object GitReadCommands { // ------------------------------------------------------------------ - /** The set of pubkeys whose status is authoritative for a repo: the owner + declared maintainers. */ - private suspend fun repoAuthorities( + /** + * Fetch the status events that `e`-reference [itemIds]. Chunked to stay under + * relay tag-filter value caps, and paged (`drainAllPages`) so a heavily + * reopened/closed item's older status can't fall off a single page. + */ + private suspend fun fetchStatusesFor( ctx: Context, + relays: Set, + itemIds: List, + ): List { + if (itemIds.isEmpty()) return emptyList() + return itemIds + .chunked(STATUS_ID_CHUNK) + .flatMap { chunk -> + ctx + .drainAllPages( + relays.associateWith { listOf(Filter(kinds = STATUS_KINDS, tags = mapOf("e" to chunk))) }, + timeoutMs = READ_TIMEOUT_MS, + ).map { it.second } + }.filterIsInstance() + .distinctBy { it.id } + } + + /** The set of pubkeys whose status is authoritative for a repo: the owner + declared maintainers. */ + private fun repoAuthorities( + repo: GitRepositoryEvent?, addr: Address, - args: Args, - ): Set { - val repo = GitSupport.fetchRepo(ctx, addr, args) - return buildSet { + ): Set = + buildSet { add(addr.pubKeyHex) repo?.maintainers()?.let { addAll(it) } } - } /** * The authoritative status label for [item]: the newest status event (by - * `created_at`) that `e`-roots this item and is signed by the item author, - * the repo owner, or a maintainer. Defaults to `open` when none exists. + * `created_at`, id as a deterministic tie-break) that `e`-roots this item and + * is signed by the item author, the repo owner, or a maintainer. Defaults to + * `open` when none exists. [statusesByRoot] is pre-grouped by root event id so + * this is an O(1) lookup instead of an O(items × statuses) rescan. */ private fun latestStatus( item: Event, - statuses: List, - authorities: Set, + statusesByRoot: Map>, + authorities: Set, ): String { val allowed = authorities + item.pubKey val newest = - statuses - .filter { it.rootEventId() == item.id && it.pubKey in allowed } - .maxByOrNull { it.createdAt } + statusesByRoot[item.id] + ?.filter { it.pubKey in allowed } + ?.maxWithOrNull(compareBy({ it.createdAt }, { it.id })) return GitSupport.statusLabel(newest?.kind) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitSupport.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitSupport.kt index 7156de0c09..c5df48caf3 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitSupport.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitSupport.kt @@ -129,13 +129,44 @@ object GitSupport { repo: GitRepositoryEvent?, args: Args, ): Set { + val flag = RawEventSupport.relayFlag(args) + if (flag.isNotEmpty()) return flag val advertised = repo ?.relays() ?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } ?.toSet() .orEmpty() - return RawEventSupport.relayFlag(args).ifEmpty { advertised }.ifEmpty { ctx.outboxRelays() } + if (advertised.isNotEmpty()) return advertised + // Falling back to the account outbox: the repo couldn't be resolved or + // advertises no relays, so a maintainer watching only the repo's NIP-34 + // relays may never see this event. Say so instead of reporting silent success. + System.err.println( + "[git] warning: no repository relays known — delivering to your outbox. " + + "A maintainer watching only the repo's relays may not see this; pass --relay to target them.", + ) + return ctx.outboxRelays() + } + + /** + * Where to READ a repo's collaboration events from: the account's query + * relays (explicit `--relay`, else outbox, else bootstrap) UNION the repo + * announcement's own advertised `relays` — the NIP-34 monitored set where + * patches/issues/statuses actually live. Without the union, a repo hosted on + * GRASP/git-specific relays (which general relays don't mirror) reads empty. + */ + suspend fun readTargets( + ctx: Context, + repo: GitRepositoryEvent?, + args: Args, + ): Set { + val advertised = + repo + ?.relays() + ?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + ?.toSet() + .orEmpty() + return RawEventSupport.queryTargets(ctx, args) + advertised } /** Parse a `--flag a,b,c` CSV flag into a trimmed, non-empty list. */ From 5f5356c0fe4bc9295b5fae8f22c02192ddec140f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 02:56:35 +0000 Subject: [PATCH 10/11] fix: complete PR clone multi-value, git-status auth spoofing, CLI robustness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review findings from a merge-time audit. - **Complete the headline multi-value `clone` fix for PRs** (was applied only to kind:30617). GitPullRequestEvent (1618) and GitPullRequestUpdateEvent (1619) carry `clone` with the same spec shape but still emitted repeated single-value tags and read only the first value — so the exact interop bug this branch set out to kill was still live for PRs, both directions (ngit keeps only the last repeated tag; we lost every URL after the first from ngit's multi-value tag). Now both emit one multi-value `["clone", …]` tag and read both forms. Verified on the wire + GitNip34InteropTest + CLI harness (40 checks). - **Android git-status spoofing (GitStatusIndex)**: newest-status-wins with no author check meant anyone could publish a kind-1632 and make someone else's issue render closed. Now filter statuses to the repository owner (from the status's own `a` tag), declared maintainers (from the cached announcement), or the target item's author — matching NIP-34 and the CLI's derivation. Pre-existing on main; this branch made the CLI/Android divergence visible. - **CLI robustness**: `git comment`/`git patch` no longer block forever reading stdin on an interactive TTY (amy is non-interactive — error instead). The local `git` subprocesses in `git init`/`git apply` now drain stdout on a side thread under a bounded `waitFor` + `destroyForcibly`, so a wedged git can't hang the CLI. Left as a follow-up (cosmetic): GitBrowseCommands.candidateUrls duplicates GitRepositoryBrowserViewModel's — worth lifting to shared code, not worth the cross-module coupling here. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr --- .../amethyst/model/GitStatusIndex.kt | 25 +++++++++++++++ .../amethyst/cli/commands/GitApplyCommand.kt | 31 +++++++++++++------ .../cli/commands/GitCommentCommand.kt | 8 ++++- .../amethyst/cli/commands/GitInitCommand.kt | 19 ++++++++++-- .../amethyst/cli/commands/GitPatchCommands.kt | 2 ++ cli/tests/git/git-nip34-headless.sh | 5 +++ .../quartz/nip34Git/pr/GitPullRequestEvent.kt | 6 ++-- .../nip34Git/pr/GitPullRequestUpdateEvent.kt | 5 +-- .../quartz/nip34Git/pr/TagArrayBuilderExt.kt | 3 ++ .../nip34Git/pr/UpdateTagArrayBuilderExt.kt | 3 ++ .../quartz/nip34Git/GitNip34InteropTest.kt | 23 ++++++++++++++ 11 files changed, 114 insertions(+), 16 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitStatusIndex.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitStatusIndex.kt index a19dce396f..67c4935592 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitStatusIndex.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitStatusIndex.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model import com.vitorpamplona.amethyst.model.LocalCache.observeEvents import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent import com.vitorpamplona.quartz.nip34Git.status.GitStatusEvent @@ -71,6 +72,7 @@ object GitStatusIndex { val latest = HashMap() for (event in events) { val target = event.rootEventId() ?: continue + if (!isAuthoritative(event, target)) continue val current = latest[target] if (current == null || event.createdAt > current.createdAt) { latest[target] = event @@ -79,6 +81,29 @@ object GitStatusIndex { return latest } + /** + * NIP-34: only the repository owner, a declared maintainer, or the target + * item's own author may set its status — "the newest Status from the root + * author or a maintainer is valid". Without this filter any pubkey could + * publish a kind-1632 and make someone else's issue render closed. + * + * The owner is read from the status's own `a` tag (so it holds even before + * the announcement is cached); the maintainer set needs the cached kind-30617 + * (falling back to owner/author-only until it arrives — the safe default is to + * treat an unverifiable status as absent, i.e. the item stays open). + */ + private fun isAuthoritative( + status: GitStatusEvent, + targetId: HexKey, + ): Boolean { + status.repositoryAddress()?.let { repoAddress -> + if (status.pubKey == repoAddress.pubKeyHex) return true + val repo = LocalCache.getAddressableNoteIfExists(repoAddress)?.event as? GitRepositoryEvent + if (repo != null && status.pubKey in repo.maintainers()) return true + } + return status.pubKey == LocalCache.getNoteIfExists(targetId)?.event?.pubKey + } + /** * Whether the latest status for [targetId] marks it as closed (kind 1632) * or applied/resolved/merged (kind 1631). Items with no status event, or diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitApplyCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitApplyCommand.kt index fb8ab81219..99a0f2acc9 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitApplyCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitApplyCommand.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent import java.io.File +import java.util.concurrent.TimeUnit import kotlin.concurrent.thread /** @@ -38,6 +39,9 @@ import kotlin.concurrent.thread * This shells out to `git`, like `git init` — it operates on the local checkout. */ object GitApplyCommand { + /** Safety ceiling for the local `git am`/`git apply` invocation so a wedged git can't hang the CLI. */ + private const val GIT_TIMEOUT_SEC = 60L + suspend fun apply( dataDir: DataDir, rest: Array, @@ -101,17 +105,18 @@ object GitApplyCommand { vararg gitArgs: String, ): Pair { var writer: Thread? = null + var reader: Thread? = null return try { val proc = ProcessBuilder(listOf("git", *gitArgs)) .directory(repoDir) .redirectErrorStream(true) .start() - // Feed stdin on a separate thread while we drain stdout on this one: a - // large patch (bigger than the OS pipe buffer) would otherwise deadlock - // — git blocks writing output we haven't read, we block writing stdin. - // The patch was decoded as UTF-8, so write it back as UTF-8 (not the JVM - // default charset, which would corrupt non-ASCII filenames/messages). + // Feed stdin AND drain stdout on side threads: a large patch (bigger than + // the OS pipe buffer) would otherwise deadlock, and reading on this thread + // would block an unbounded wait. The patch was decoded as UTF-8, so write + // it back as UTF-8 (not the JVM default charset, which would corrupt + // non-ASCII filenames/messages). writer = if (input != null) { thread(name = "git-stdin") { runCatching { proc.outputStream.use { it.write(input.toByteArray(Charsets.UTF_8)) } } } @@ -119,14 +124,22 @@ object GitApplyCommand { proc.outputStream.close() null } - val out = proc.inputStream.readBytes().decodeToString() - proc.waitFor() to out + val sb = StringBuilder() + reader = thread(name = "git-out") { runCatching { sb.append(proc.inputStream.readBytes().decodeToString()) } } + if (!proc.waitFor(GIT_TIMEOUT_SEC, TimeUnit.SECONDS)) { + proc.destroyForcibly() + 124 to "git timed out after ${GIT_TIMEOUT_SEC}s" + } else { + reader.join() + proc.exitValue() to sb.toString() + } } catch (e: Exception) { 1 to (e.message ?: "could not run git (is it installed and is this a git repo?)") } finally { - // Always join so a non-daemon stdin thread can't outlive the call (e.g. + // Always join so the non-daemon side threads can't outlive the call (e.g. // under the in-process test harness, which doesn't exitProcess). - writer?.join() + writer?.join(1_000) + reader?.join(1_000) } } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommentCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommentCommand.kt index 48ac8de712..79194c946e 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommentCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommentCommand.kt @@ -44,7 +44,13 @@ object GitCommentCommand { ): Int { val args = Args(rest) val targetRef = args.positional(0, "target-event-or-repo") - val body = (args.positionalOrNull(1) ?: System.`in`.readBytes().decodeToString()).trim() + val bodyArg = args.positionalOrNull(1) + // Never block on an interactive TTY: amy is non-interactive, so require the + // body as an argument unless it's actually being piped in. + if (bodyArg == null && System.console() != null) { + return Output.error("bad_args", "comment body required as an argument (or piped on stdin)") + } + val body = (bodyArg ?: System.`in`.readBytes().decodeToString()).trim() if (body.isBlank()) return Output.error("bad_args", "empty comment (pass BODY as an argument or on stdin)") args.rejectUnknown("relay") diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitInitCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitInitCommand.kt index 2b7d9f3664..f0c528b3ae 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitInitCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitInitCommand.kt @@ -29,6 +29,8 @@ import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent import com.vitorpamplona.quartz.nip34Git.state.GitRepositoryStateEvent import com.vitorpamplona.quartz.nip34Git.state.tags.RefTag import java.io.File +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread /** * `amy git init` — bootstrap a NIP-34 repository from the local git checkout, @@ -42,6 +44,9 @@ import java.io.File * about the local working tree, exactly like the `ngit`/`nak git` `init`. */ object GitInitCommand { + /** Safety ceiling for a single local `git` invocation; a normal read-only op finishes in milliseconds. */ + private const val GIT_TIMEOUT_SEC = 60L + suspend fun init( dataDir: DataDir, rest: Array, @@ -171,8 +176,18 @@ object GitInitCommand { .directory(repoDir) .redirectError(ProcessBuilder.Redirect.DISCARD) .start() - val out = proc.inputStream.readBytes().decodeToString() - if (proc.waitFor() == 0) out.trim().ifEmpty { null } else null + // Drain stdout on a side thread and bound the wait: a wedged git (a + // hung filter/hook, a credential prompt) must not hang the CLI forever. + val sb = StringBuilder() + val reader = thread(name = "git-out") { runCatching { sb.append(proc.inputStream.readBytes().decodeToString()) } } + if (!proc.waitFor(GIT_TIMEOUT_SEC, TimeUnit.SECONDS)) { + proc.destroyForcibly() + reader.join(1_000) + null + } else { + reader.join() + if (proc.exitValue() == 0) sb.toString().trim().ifEmpty { null } else null + } } catch (_: Exception) { null } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitPatchCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitPatchCommands.kt index 8f5a8f5d3a..11ac0dd2aa 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitPatchCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitPatchCommands.kt @@ -126,6 +126,8 @@ object GitPatchCommands { File(file).takeIf { it.isFile }?.readText() ?: throw IllegalArgumentException("--file not found: $file") } else { + // Non-interactive: don't block waiting for a human to type a patch. + require(System.console() == null) { "no patch given: pass --file PATH or pipe `git format-patch` to stdin" } System.`in`.readBytes().decodeToString() }.trim() } diff --git a/cli/tests/git/git-nip34-headless.sh b/cli/tests/git/git-nip34-headless.sh index ca72bb154a..e97b04930e 100755 --- a/cli/tests/git/git-nip34-headless.sh +++ b/cli/tests/git/git-nip34-headless.sh @@ -177,6 +177,11 @@ assert_eq "$(M git show "$IADDR" --relay "$RELAY_URL" | jq -r '.clone | length') 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" +# A PR (1618) also carries clone URLs as ONE multi-value tag (same fix as 30617). +IPR="$(M git pr "$IADDR" --commit tip1 --clone https://c1.git,https://c2.git --subject s --relay "$RELAY_URL" | jq -r '.event_id')" +IPRTAGS="$(M fetch --id "$IPR" --relay "$RELAY_URL")" +assert_eq "$(echo "$IPRTAGS" | jq -r '[.events[0].tags[] | select(.[0]=="clone")] | length')" "1" interop.pr_clone_single "PR clone is one tag" +assert_eq "$(echo "$IPRTAGS" | jq -r '.events[0].tags[] | select(.[0]=="clone") | length')" "3" interop.pr_clone_multivalue "PR clone tag carries both URLs" # GRASP server list (10317) round-trip. GRASP="$(M git grasp set "wss://grasp.example.com,wss://grasp2.example.com" --relay "$RELAY_URL")" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/pr/GitPullRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/pr/GitPullRequestEvent.kt index c9d67b6398..2b2696415b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/pr/GitPullRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/pr/GitPullRequestEvent.kt @@ -87,7 +87,9 @@ class GitPullRequestEvent( fun currentCommit(): String? = tags.firstNotNullOfOrNull(CurrentCommitTag::parse) - fun cloneUrls(): List = tags.mapNotNull(CloneTag::parse) + // Tolerant of both the NIP-34 spec form (one multi-value `["clone", a, b]` tag, + // which ngit emits) and the legacy repeated form; deduped. + fun cloneUrls(): List = tags.flatMap(CloneTag::parseAll).distinct() fun subject(): String? = tags.firstNotNullOfOrNull(SubjectTag::parse) @@ -138,7 +140,7 @@ class GitPullRequestEvent( pTag(repository.event.pubKey, repository.authorHomeRelay) if (notify.isNotEmpty()) pTags(notify) currentCommit(currentCommit) - cloneUrls.forEach { cloneUrl(it) } + if (cloneUrls.isNotEmpty()) cloneUrls(cloneUrls) subject?.let { subject(it) } if (labels.isNotEmpty()) hashtags(labels) branchName?.let { branchName(it) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/pr/GitPullRequestUpdateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/pr/GitPullRequestUpdateEvent.kt index 898187ae25..4df780f32a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/pr/GitPullRequestUpdateEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/pr/GitPullRequestUpdateEvent.kt @@ -81,7 +81,8 @@ class GitPullRequestUpdateEvent( fun currentCommit(): String? = tags.firstNotNullOfOrNull(CurrentCommitTag::parse) - fun cloneUrls(): List = tags.mapNotNull(CloneTag::parse) + // Tolerant of both the multi-value and legacy repeated `clone` forms; deduped. + fun cloneUrls(): List = tags.flatMap(CloneTag::parseAll).distinct() fun earliestUniqueCommit(): String? = tags.firstOrNull { it.size > 1 && it[0] == "r" && it[1].isNotEmpty() }?.get(1) @@ -119,7 +120,7 @@ class GitPullRequestUpdateEvent( pTag(repository.event.pubKey, repository.authorHomeRelay) if (notify.isNotEmpty()) pTags(notify) currentCommit(currentCommit) - cloneUrls.forEach { cloneUrl(it) } + if (cloneUrls.isNotEmpty()) cloneUrls(cloneUrls) mergeBase?.let { mergeBase(it) } initializer() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/pr/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/pr/TagArrayBuilderExt.kt index ca384e8108..cc50024081 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/pr/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/pr/TagArrayBuilderExt.kt @@ -46,6 +46,9 @@ fun TagArrayBuilder.currentCommit(commit: String) = addUniq fun TagArrayBuilder.cloneUrl(url: String) = add(CloneTag.assemble(url)) +/** Emit all clone URLs as one NIP-34 multi-value `["clone", url1, url2, …]` tag (the spec/ngit form). */ +fun TagArrayBuilder.cloneUrls(urls: List) = addUnique(CloneTag.assemble(urls)) + fun TagArrayBuilder.subject(subject: String) = addUnique(SubjectTag.assemble(subject)) fun TagArrayBuilder.branchName(name: String) = addUnique(BranchNameTag.assemble(name)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/pr/UpdateTagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/pr/UpdateTagArrayBuilderExt.kt index db9c1e84e9..4de994479f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/pr/UpdateTagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/pr/UpdateTagArrayBuilderExt.kt @@ -43,6 +43,9 @@ fun TagArrayBuilder.currentCommit(commit: String) = a fun TagArrayBuilder.cloneUrl(url: String) = add(CloneTag.assemble(url)) +/** Emit all clone URLs as one NIP-34 multi-value `["clone", url1, url2, …]` tag (the spec/ngit form). */ +fun TagArrayBuilder.cloneUrls(urls: List) = addUnique(CloneTag.assemble(urls)) + fun TagArrayBuilder.mergeBase(commit: String) = addUnique(MergeBaseTag.assemble(commit)) /** Adds the NIP-22 `E` tag pointing at the parent Pull Request. */ diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip34Git/GitNip34InteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip34Git/GitNip34InteropTest.kt index f2fd10155e..0c4249e63e 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip34Git/GitNip34InteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip34Git/GitNip34InteropTest.kt @@ -23,6 +23,7 @@ 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.pr.GitPullRequestEvent import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent import kotlin.test.Test import kotlin.test.assertEquals @@ -102,6 +103,28 @@ class GitNip34InteropTest { assertTrue(owner in pTags, "issue must p-tag the repository owner for maintainer routing") } + @Test + fun pullRequestEmitsAndReadsMultiValueClone() { + val repoEvent = repo(arrayOf(arrayOf("d", "x"), arrayOf("r", "root", "euc"))) + val tmpl = + GitPullRequestEvent.build( + description = "desc", + repository = EventHintBundle(repoEvent), + earliestUniqueCommit = "root", + currentCommit = "tip", + cloneUrls = listOf("https://a.git", "https://b.git"), + ) + // One multi-value clone tag (ngit/spec form), not repeated single-value tags. + assertEquals(1, tmpl.tags.count { it[0] == "clone" }, "PR clone must be a single tag") + assertEquals(listOf("clone", "https://a.git", "https://b.git"), tmpl.tags.first { it[0] == "clone" }.toList()) + + // Reader flattens both the multi-value and the legacy repeated forms. + val multi = GitPullRequestEvent("00", owner, 0, arrayOf(arrayOf("clone", "https://a.git", "https://b.git")), "", "00") + val repeated = GitPullRequestEvent("00", owner, 0, arrayOf(arrayOf("clone", "https://a.git"), arrayOf("clone", "https://b.git")), "", "00") + assertEquals(listOf("https://a.git", "https://b.git"), multi.cloneUrls()) + assertEquals(listOf("https://a.git", "https://b.git"), repeated.cloneUrls()) + } + @Test fun patchRTagIsPlainWithoutEucMarker() { val repoEvent = repo(arrayOf(arrayOf("d", "x"), arrayOf("r", "rootcommit", "euc"))) From 062ab0f012ce1799d3261af2331f4c39e9fa9c3e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 03:38:20 +0000 Subject: [PATCH 11/11] revert(amethyst): drop GitStatusIndex auth change; defer to separate proposal The isAuthoritative guard added to GitStatusIndex read the repository owner from the status event's own `a` tag (GitStatusEvent.repositoryAddress() -> first a tag, no kind filter, no cross-check against the target's repo). That value is attacker-controlled: a forged kind:1632 carrying `["a", "30617::anything"]` makes `status.pubKey == repoAddress.pubKeyHex` pass, so the spoof it meant to block still succeeds. It also regressed reads: reduceLatestByTarget only re-runs on a new kind 1630-1633, so a status dropped while the 30617 was uncached stayed dropped, leaving genuinely-closed items in the Open tab with wrong counts. Keep this series focused on the quartz + cli NIP-34 parity work. The Android status-authorization hardening (resolve the repo from the target item, load the cached 30617, authorize against repo.pubKey + maintainers() + the target author, and re-reduce when a 30617 arrives) will land as its own proposal. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr --- .../amethyst/model/GitStatusIndex.kt | 25 ------------------- 1 file changed, 25 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitStatusIndex.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitStatusIndex.kt index 67c4935592..a19dce396f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitStatusIndex.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitStatusIndex.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.model import com.vitorpamplona.amethyst.model.LocalCache.observeEvents import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent import com.vitorpamplona.quartz.nip34Git.status.GitStatusEvent @@ -72,7 +71,6 @@ object GitStatusIndex { val latest = HashMap() for (event in events) { val target = event.rootEventId() ?: continue - if (!isAuthoritative(event, target)) continue val current = latest[target] if (current == null || event.createdAt > current.createdAt) { latest[target] = event @@ -81,29 +79,6 @@ object GitStatusIndex { return latest } - /** - * NIP-34: only the repository owner, a declared maintainer, or the target - * item's own author may set its status — "the newest Status from the root - * author or a maintainer is valid". Without this filter any pubkey could - * publish a kind-1632 and make someone else's issue render closed. - * - * The owner is read from the status's own `a` tag (so it holds even before - * the announcement is cached); the maintainer set needs the cached kind-30617 - * (falling back to owner/author-only until it arrives — the safe default is to - * treat an unverifiable status as absent, i.e. the item stays open). - */ - private fun isAuthoritative( - status: GitStatusEvent, - targetId: HexKey, - ): Boolean { - status.repositoryAddress()?.let { repoAddress -> - if (status.pubKey == repoAddress.pubKeyHex) return true - val repo = LocalCache.getAddressableNoteIfExists(repoAddress)?.event as? GitRepositoryEvent - if (repo != null && status.pubKey in repo.maintainers()) return true - } - return status.pubKey == LocalCache.getNoteIfExists(targetId)?.event?.pubKey - } - /** * Whether the latest status for [targetId] marks it as closed (kind 1632) * or applied/resolved/merged (kind 1631). Items with no status event, or