Merge PR: NIP-34 git parity for amy + quartz interop fixes

Merges nostr proposal 2ca4f8ae into main:
- feat(cli): full NIP-34 git collaboration parity for `amy git`
- feat(cli): `amy git grasp list|set` (NIP-34 GRASP server list, kind 10317)
- feat(cli): `amy git browse|cat|log` — read git objects over smart-HTTP
- feat(cli): `amy git init` — bootstrap a repo from the local git checkout
- feat(cli): `amy git label` (NIP-32) and `amy git apply` (patch -> working tree)
- fix(quartz): NIP-34 wire-format interop with ngit (clone/web, issue p, plain r)
- test(cli): live interop check against the real ngit-published amethyst repo
- fix(cli): shallow-clone euc, read truncation, process deadlock
- fix(cli): read routing, status perf, publish-ack + robustness
- fix: complete PR clone multi-value (kinds 1618/1620) + CLI robustness
- revert(amethyst): drop GitStatusIndex auth change; defer to separate proposal

clone/web now serialize as single multi-value tags per NIP-34 (ngit drops
repeated ones) for kinds 30617, 1618 and 1620; readers stay tolerant of the
legacy repeated form.

The GitStatusIndex status-authority guard was reverted before merge: it
authorized against the status event's own `a` tag, which the author controls,
so a forged ["a","30617:<attacker>:x"] defeated it — and unverifiable statuses
were dropped permanently (no re-reduce when the 30617 announcement arrives),
rendering closed issues as open. Refiling as its own proposal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-07-20 23:54:37 -04:00
29 changed files with 2672 additions and 120 deletions

View File

@@ -307,14 +307,63 @@ 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 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). |
| `amy git issue NADDR\|coords --subject S [BODY] [--hashtag T[,T]]` | Publish a kind:1621 issue against a repo. BODY from arg or stdin. |
| `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 |
|---|---|
| `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. |
| `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 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). |
| `amy git draft TARGET [MSG]` | Publish a kind:1633 status (draft). |
### Podcasts (NIP-F4)

View File

@@ -109,7 +109,7 @@ vs streaming `subscribe`). Stateless verbs run with no account or network.
| `nip` | `amy nip` | ✅ | repo-first lookup + Nostr fallback (NipText kind:30817, wiki:30818, long-form:30023); `nip list`. |
| `kind` | `amy kind` | ✅ | quartz `KindNames` registry (kind → English label + NIP) covering **every** event kind quartz defines (280 entries); number lookup + name search. |
| `sync` | `amy sync` | ✅ | NIP-77 Negentropy reconcile with the local store (down/up/both). |
| `git` | `amy git` | ✅ in part | NIP-34 repo announce/list/show/issue. clone/push (packfile transport) 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`. |
@@ -129,8 +129,10 @@ 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 + 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).
@@ -173,10 +175,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,

View File

@@ -624,14 +624,34 @@ 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[,]]
| [--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 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)
| [--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 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]
| 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

View File

@@ -0,0 +1,145 @@
/*
* 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
import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread
/**
* `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 {
/** 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<String>,
): 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 <args>` in [repoDir], optionally feeding [input] on stdin; returns (exitCode, merged stdout+stderr). */
private fun runGit(
repoDir: File,
input: String?,
vararg gitArgs: String,
): Pair<Int, String> {
var writer: Thread? = null
var reader: Thread? = null
return try {
val proc =
ProcessBuilder(listOf("git", *gitArgs))
.directory(repoDir)
.redirectErrorStream(true)
.start()
// 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)) } } }
} else {
proc.outputStream.close()
null
}
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 the non-daemon side threads can't outlive the call (e.g.
// under the in-process test harness, which doesn't exitProcess).
writer?.join(1_000)
reader?.join(1_000)
}
}
}

View File

@@ -0,0 +1,253 @@
/*
* 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<String>,
): 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<String>,
): 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<String>,
): 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<String>): List<String> {
val out = LinkedHashSet<String>()
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<String> =
path
.trim()
.trim('/')
.split('/')
.filter { it.isNotEmpty() }
private fun entrySummary(entry: GitTreeEntry): Map<String, Any?> =
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 {
val end = minOf(8000, bytes.size)
for (i in 0 until end) if (bytes[i].toInt() == 0) return true
return false
}
}

View File

@@ -25,43 +25,73 @@ 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 <announce|list|show|issue>` — NIP-34 Nostr-native git
* repositories (nak's `git`, adapted to amy's event-publish model).
* `amy git <verb>` — 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 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)
| [--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
| 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]]
| 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
| 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, 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]]
|
|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 +101,34 @@ object GitCommands {
route(
"git",
tail,
"git <announce|list|show|issue>",
"git <init|announce|state|list|show|grasp|browse|cat|log|issue|patch|apply|pr|comment|label|open|applied|close|draft|issues|patches|prs|thread>",
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) },
"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) },
"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) },
"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) },
"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 +143,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 +150,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 +174,49 @@ object GitCommands {
}
}
private suspend fun state(
dataDir: DataDir,
rest: Array<String>,
): 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<String>,
@@ -165,14 +252,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 +272,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 +290,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 +305,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<Event>(filter)
.firstOrNull()
?.let { return it as? GitRepositoryEvent }
val relays = RawEventSupport.queryTargets(ctx, args)
ctx.drain(relays.associateWith { listOf(filter) })
return ctx.store.query<Event>(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<String, Any?> =
mapOf(
"identifier" to repo.dTag(),

View File

@@ -0,0 +1,98 @@
/*
* 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<String>,
): Int {
val args = Args(rest)
val targetRef = args.positional(0, "target-event-or-repo")
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")
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<Event>(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
}
}

View File

@@ -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 <list|set>` — 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/<pr-id>`) 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<String>,
): Int =
route(
"git grasp",
tail,
"git grasp <list|set>",
mapOf(
"list" to { rest -> list(dataDir, rest) },
"set" to { rest -> set(dataDir, rest) },
),
help = USAGE,
)
private suspend fun list(
dataDir: DataDir,
rest: Array<String>,
): 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<Event>(filter).firstOrNull() as? UserGraspListEvent
if (event == null) {
val relays = RawEventSupport.queryTargets(ctx, args)
ctx.drain(relays.associateWith { listOf(filter) })
event = ctx.store.query<Event>(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<String>,
): 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
}
}
}

View File

@@ -0,0 +1,223 @@
/*
* 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
import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread
/**
* `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 {
/** 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<String>,
): 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 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
?: 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
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 <root-commit-id> so the repo keeps a stable cross-fork identity.",
)
}
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<String, Any?>(
"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)
// 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))
return 0
}
}
/** Read local branch + tag refs as NIP-34 [RefTag]s via `git for-each-ref`. */
private fun readRefs(repoDir: File): List<RefTag> {
fun parse(
output: String?,
builder: (String, String) -> RefTag,
): List<RefTag> =
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 <args>` 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,
): String? =
try {
val proc =
ProcessBuilder(listOf("git", *gitArgs))
.directory(repoDir)
.redirectError(ProcessBuilder.Redirect.DISCARD)
.start()
// 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
}
/** 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@") -> {
// 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
}
/** 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 }
}

View File

@@ -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<String>,
): 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
}
}
}

View File

@@ -0,0 +1,133 @@
/*
* 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<String>,
): 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 <id>",
)
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 {
// 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()
}

View File

@@ -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<String>,
): 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 <tip-commit-id>")
val cloneUrls = GitSupport.csv(args, "clone")
if (cloneUrls.isEmpty()) return Output.error("bad_args", "git pr requires --clone <url>[,<url>] 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 <id>")
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<String>,
): 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 <new-tip-commit-id>")
val cloneUrls = GitSupport.csv(args, "clone")
if (cloneUrls.isEmpty()) return Output.error("bad_args", "git pr-update requires --clone <url>[,<url>]")
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 <id>")
val template =
GitPullRequestUpdateEvent.build(
parentPullRequest = EventHintBundle(parent),
repository = EventHintBundle<GitRepositoryEvent>(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
}
}
}

View File

@@ -0,0 +1,256 @@
/*
* 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.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
/**
* 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,
)
/** 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<String>,
): Int = listItems(dataDir, rest, GitIssueEvent.KIND)
suspend fun patches(
dataDir: DataDir,
rest: Array<String>,
): Int = listItems(dataDir, rest, GitPatchEvent.KIND)
suspend fun prs(
dataDir: DataDir,
rest: Array<String>,
): 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<String>,
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)
// 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.
// 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)) },
timeoutMs = READ_TIMEOUT_MS,
).asSequence()
.map { it.second }
.filter { it.kind == itemKind }
.distinctBy { it.id }
.sortedByDescending { it.createdAt }
.take(limit)
.toList()
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, statusesByRoot, authorities)) }
.filter { wanted == null || it["status"] in wanted }
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).
*
* 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,
rest: Array<String>,
): 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 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
.drain(
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 authorities = repoATag?.let { repoAuthorities(repo, Address(it.kind, it.pubKeyHex, it.dTag)) } ?: setOf(target.pubKey)
val statuses = related.filterIsInstance<GitStatusEvent>().filter { it.rootEventId() == id }
val statusesByRoot = statuses.groupBy { it.rootEventId() }
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, 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)
},
"comments" to comments,
),
)
return 0
}
}
// ------------------------------------------------------------------
/**
* 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<NormalizedRelayUrl>,
itemIds: List<HexKey>,
): List<GitStatusEvent> {
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<GitStatusEvent>()
.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,
): Set<HexKey> =
buildSet {
add(addr.pubKeyHex)
repo?.maintainers()?.let { addAll(it) }
}
/**
* The authoritative status label for [item]: the newest status event (by
* `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,
statusesByRoot: Map<HexKey?, List<GitStatusEvent>>,
authorities: Set<HexKey>,
): String {
val allowed = authorities + item.pubKey
val newest =
statusesByRoot[item.id]
?.filter { it.pubKey in allowed }
?.maxWithOrNull(compareBy({ it.createdAt }, { it.id }))
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<String>? {
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() }
}
}

View File

@@ -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<String>,
): Int = simpleStatus(dataDir, rest, GitStatusOpenEvent.KIND)
suspend fun close(
dataDir: DataDir,
rest: Array<String>,
): Int = simpleStatus(dataDir, rest, GitStatusClosedEvent.KIND)
suspend fun draft(
dataDir: DataDir,
rest: Array<String>,
): Int = simpleStatus(dataDir, rest, GitStatusDraftEvent.KIND)
/** open / close / draft share one shape: `TARGET [MESSAGE]`. */
private suspend fun simpleStatus(
dataDir: DataDir,
rest: Array<String>,
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<String>,
): 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<String, String>? {
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 <T : Event> TagArrayBuilder<T>.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<out Event>,
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
}
}

View File

@@ -0,0 +1,247 @@
/*
* 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<Event>(filter)
.firstOrNull()
?.let { return it as? GitRepositoryEvent }
val relays = RawEventSupport.queryTargets(ctx, args)
ctx.drain(relays.associateWith { listOf(filter) })
return ctx.store.query<Event>(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<Event>(filter)
.firstOrNull()
?.let { return it }
val relays = RawEventSupport.queryTargets(ctx, args)
ctx.drain(relays.associateWith { listOf(filter) })
return ctx.store.query<Event>(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<NormalizedRelayUrl> {
val flag = RawEventSupport.relayFlag(args)
if (flag.isNotEmpty()) return flag
val advertised =
repo
?.relays()
?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
?.toSet()
.orEmpty()
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<NormalizedRelayUrl> {
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. */
fun csv(
args: Args,
key: String,
): List<String> =
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<Pair<String, String>> =
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<String, Any?> {
val base =
mutableMapOf<String, Any?>(
"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
}
}

View File

@@ -6,3 +6,4 @@ clink/state-clink-headless/
relaygroup/state-relaygroup-headless/
sync/state-sync-deletions/
blossom/state-blossom-live/
git/state-git-nip34/

View File

@@ -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,19 @@ 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`: `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), 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.
The Marmot harnesses come in two flavours, same scenarios:

View File

@@ -0,0 +1,331 @@
#!/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
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
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 <home> <account> 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; }
# =============================================================================
# git init — bootstrap from a local (full, non-shallow) git checkout
# =============================================================================
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_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)
# =============================================================================
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"
# --- 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"
# 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")"
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)
# =============================================================================
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"
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
# =============================================================================
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"
# =============================================================================
# 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"
# 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) + real ngit repo — pass --live to run"
fi
grep -q $'\tfail\t' "$RESULTS_FILE" && exit 1
exit 0

View File

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

View File

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

View File

@@ -87,7 +87,9 @@ class GitPullRequestEvent(
fun currentCommit(): String? = tags.firstNotNullOfOrNull(CurrentCommitTag::parse)
fun cloneUrls(): List<String> = 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<String> = 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) }

View File

@@ -81,7 +81,8 @@ class GitPullRequestUpdateEvent(
fun currentCommit(): String? = tags.firstNotNullOfOrNull(CurrentCommitTag::parse)
fun cloneUrls(): List<String> = tags.mapNotNull(CloneTag::parse)
// Tolerant of both the multi-value and legacy repeated `clone` forms; deduped.
fun cloneUrls(): List<String> = 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()
}

View File

@@ -38,12 +38,17 @@ fun TagArrayBuilder<GitPullRequestEvent>.repository(rep: ATag) = addUnique(rep.t
fun TagArrayBuilder<GitPullRequestEvent>.repository(rep: EventHintBundle<GitRepositoryEvent>) = addUnique(rep.toATag().toATagArray())
fun TagArrayBuilder<GitPullRequestEvent>.euc(commit: String) = addUnique(EucTag.assemble(commit))
// NIP-34 pull requests use the plain `["r", <commit>]` shape; the `"euc"` marker
// is only on the kind-30617 announcement (see the patch builder for the rationale).
fun TagArrayBuilder<GitPullRequestEvent>.euc(commit: String) = addUnique(arrayOf(EucTag.TAG_NAME, commit))
fun TagArrayBuilder<GitPullRequestEvent>.currentCommit(commit: String) = addUnique(CurrentCommitTag.assemble(commit))
fun TagArrayBuilder<GitPullRequestEvent>.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<GitPullRequestEvent>.cloneUrls(urls: List<String>) = addUnique(CloneTag.assemble(urls))
fun TagArrayBuilder<GitPullRequestEvent>.subject(subject: String) = addUnique(SubjectTag.assemble(subject))
fun TagArrayBuilder<GitPullRequestEvent>.branchName(name: String) = addUnique(BranchNameTag.assemble(name))

View File

@@ -36,12 +36,16 @@ fun TagArrayBuilder<GitPullRequestUpdateEvent>.repository(rep: ATag) = addUnique
fun TagArrayBuilder<GitPullRequestUpdateEvent>.repository(rep: EventHintBundle<GitRepositoryEvent>) = addUnique(rep.toATag().toATagArray())
fun TagArrayBuilder<GitPullRequestUpdateEvent>.euc(commit: String) = addUnique(EucTag.assemble(commit))
// Plain `["r", <commit>]` — the `"euc"` marker is only on the 30617 announcement.
fun TagArrayBuilder<GitPullRequestUpdateEvent>.euc(commit: String) = addUnique(arrayOf(EucTag.TAG_NAME, commit))
fun TagArrayBuilder<GitPullRequestUpdateEvent>.currentCommit(commit: String) = addUnique(CurrentCommitTag.assemble(commit))
fun TagArrayBuilder<GitPullRequestUpdateEvent>.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<GitPullRequestUpdateEvent>.cloneUrls(urls: List<String>) = addUnique(CloneTag.assemble(urls))
fun TagArrayBuilder<GitPullRequestUpdateEvent>.mergeBase(commit: String) = addUnique(MergeBaseTag.assemble(commit))
/** Adds the NIP-22 `E` tag pointing at the parent Pull Request. */

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,140 @@
/*
* 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.pr.GitPullRequestEvent
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Locks in byte-level interoperability with ngit (github.com/DanConwayDev/ngit-cli)
* and the NIP-34 spec for the tag shapes that previously diverged:
*
* 1. `clone` / `web` are ONE multi-value tag, not repeated single-value tags —
* ngit's parser keeps only the last of repeated known tags, so the repeated
* form silently dropped URLs across implementations.
* 2. Issues carry the repository owner as a `p` tag (maintainer routing).
* 3. Patch/PR `r` tags are plain `["r", <commit>]`; the `"euc"` marker lives
* only on the kind-30617 announcement.
*/
class GitNip34InteropTest {
private val owner = "aa".repeat(32)
private fun repo(tags: Array<Array<String>>) = GitRepositoryEvent("00", owner, 0, tags, "", "00")
@Test
fun announcementEmitsSingleMultiValueCloneAndWeb() {
val tmpl =
GitRepositoryEvent.build(
name = "demo",
description = null,
webUrls = listOf("https://a.com", "https://b.com"),
cloneUrls = listOf("https://a.git", "https://b.git"),
relays = emptyList(),
maintainers = emptyList(),
hashtags = emptyList(),
earliestUniqueCommit = null,
dTag = "demo",
)
assertEquals(1, tmpl.tags.count { it[0] == "clone" }, "clone must be a single tag")
assertEquals(1, tmpl.tags.count { it[0] == "web" }, "web must be a single tag")
assertEquals(listOf("clone", "https://a.git", "https://b.git"), tmpl.tags.first { it[0] == "clone" }.toList())
assertEquals(listOf("web", "https://a.com", "https://b.com"), tmpl.tags.first { it[0] == "web" }.toList())
}
@Test
fun readsSpecMultiValueForm() {
val r =
repo(
arrayOf(
arrayOf("d", "x"),
arrayOf("clone", "https://a.git", "https://b.git"),
arrayOf("web", "https://a.com", "https://b.com"),
),
)
assertEquals(listOf("https://a.git", "https://b.git"), r.clones())
assertEquals(listOf("https://a.com", "https://b.com"), r.webs())
}
@Test
fun readsLegacyRepeatedForm() {
val r =
repo(
arrayOf(
arrayOf("d", "x"),
arrayOf("clone", "https://a.git"),
arrayOf("clone", "https://b.git"),
arrayOf("web", "https://a.com"),
arrayOf("web", "https://b.com"),
),
)
assertEquals(listOf("https://a.git", "https://b.git"), r.clones())
assertEquals(listOf("https://a.com", "https://b.com"), r.webs())
}
@Test
fun issueCarriesRepositoryOwnerPTag() {
val repoEvent = repo(arrayOf(arrayOf("d", "x"), arrayOf("name", "x")))
val tmpl = GitIssueEvent.build("subject", "body", EventHintBundle(repoEvent), emptyList(), emptyList())
val pTags = tmpl.tags.filter { it[0] == "p" }.map { it[1] }
assertTrue(owner in pTags, "issue must p-tag the repository owner for maintainer routing")
}
@Test
fun 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")))
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)")
}
}