feat(cli): add amy git label (NIP-32) and amy git apply (patch → working tree)

Close two more ngit/nak parity gaps:

- `git label TARGET LABEL[,LABEL]` — attach NIP-32 kind:1985 labels to an
  issue/patch/PR (the `ngit pr label` / `issue label` surface), over quartz's
  existing `LabelEvent`. Namespace defaults to `ugc`; `--namespace` overrides.
- `git apply PATCH_ID` — fetch a kind:1617 patch and apply it to the local
  working tree via `git am` (the `nak git patch apply` / `ngit pr apply`
  surface); `--check` dry-runs `git apply --check`, `--print` emits the patch.
  Shells out to `git` like `git init`, since it operates on the local checkout.

Verified end-to-end: a patch published to a relay, fetched, and `git am`'d as a
real commit into a scratch repo; labels land as kind 1985. The harness gains 5
assertions (label + a full publish→apply round-trip), now 33 offline.

Remaining out-of-scope items are documented: git-packfile push (needs a git
write layer quartz lacks) and NIP-34 cover notes (kind 1624, no quartz builder).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
This commit is contained in:
Claude
2026-07-21 03:37:58 +00:00
parent b06184ac49
commit d3e208322c
8 changed files with 248 additions and 7 deletions
+2
View File
@@ -348,6 +348,7 @@ URL) **or** a raw `http(s)` clone URL. This is the git-object read side of
|---|---|
| `amy git issue REPO --subject S [BODY] [--hashtag T[,T]]` | Publish a kind:1621 issue. BODY from arg or stdin. |
| `amy git patch REPO [--file PATH] [--root\|--root-revision] [--commit C] [--parent-commit P] [--in-reply-to ID]` | Publish a kind:1617 patch. Body is `git format-patch` output from `--file` or stdin. |
| `amy git apply PATCH_ID [--check\|--print] [--repo PATH]` | Fetch a kind:1617 patch and apply it to the local working tree (`git am`); `--check` dry-runs, `--print` emits the patch. |
| `amy git pr REPO --commit TIP --clone URL[,URL] [--subject S] [--branch-name N] [--merge-base C] [--label L[,L]] [DESC]` | Publish a kind:1618 pull request (references a pushed branch tip by clone URL + commit). |
| `amy git pr-update PR --commit TIP --clone URL[,URL] [--merge-base C]` | Publish a kind:1619 update to a pull request's tip. |
| `amy git issues\|patches\|prs REPO [--open\|--applied\|--closed\|--draft\|--status a,b] [--limit N]` | List a repo's issues / patches / PRs with their derived status. |
@@ -358,6 +359,7 @@ URL) **or** a raw `http(s)` clone URL. This is the git-object read side of
| Command | What it does |
|---|---|
| `amy git comment TARGET [BODY]` | Reply to an issue/patch/PR/repo with a NIP-22 kind:1111 comment. BODY from arg or stdin. |
| `amy git label TARGET LABEL[,LABEL] [--namespace N]` | Attach NIP-32 kind:1985 labels to an issue/patch/PR (namespace defaults to `ugc`). |
| `amy git open TARGET [MSG]` | Publish a kind:1630 status (open / reopen / ready-for-review). |
| `amy git applied TARGET [MSG] [--merge-commit C] [--commit C[,C]] [--patch ID[,ID]]` | Publish a kind:1631 status (applied / merged / resolved). Aliases: `merged`, `resolved`. |
| `amy git close TARGET [MSG]` | Publish a kind:1632 status (closed). |
+1 -1
View File
@@ -109,7 +109,7 @@ vs streaming `subscribe`). Stateless verbs run with no account or network.
| `nip` | `amy nip` | ✅ | repo-first lookup + Nostr fallback (NipText kind:30817, wiki:30818, long-form:30023); `nip list`. |
| `kind` | `amy kind` | ✅ | quartz `KindNames` registry (kind → English label + NIP) covering **every** event kind quartz defines (280 entries); number lookup + name search. |
| `sync` | `amy sync` | ✅ | NIP-77 Negentropy reconcile with the local store (down/up/both). |
| `git` | `amy git` | ✅ (events + read) | NIP-34: `init` bootstraps a repo from the local `git` checkout (announce + state, like `ngit init`); repo announce (30617) + state (30618), patches (1617), pull requests (1618/1619), issues (1621), NIP-22 comments (1111), status open/applied/closed/draft (1630-1633), GRASP server list (10317); `issues`/`patches`/`prs`/`thread` reads derive status; `browse`/`cat`/`log` read git objects over smart-HTTP v2 (quartz `GitHttpClient`, the same shallow-clone path the Android browser uses). Only git-packfile **push** (writing objects to clone/GRASP servers) is out of scope. |
| `git` | `amy git` | ✅ (events + read) | NIP-34: `init` bootstraps a repo from the local `git` checkout (announce + state, like `ngit init`); repo announce (30617) + state (30618), patches (1617), pull requests (1618/1619), issues (1621), NIP-22 comments (1111), NIP-32 labels (1985), status open/applied/closed/draft (1630-1633), GRASP server list (10317); `issues`/`patches`/`prs`/`thread` reads derive status; `apply` applies a fetched patch to the local tree (`git am`); `browse`/`cat`/`log` read git objects over smart-HTTP v2 (quartz `GitHttpClient`, the same shallow-clone path the Android browser uses). Only git-packfile **push** (writing objects to clone/GRASP servers) and NIP-34 cover notes (1624, no quartz builder yet) are out of scope. |
| `podcast` | `amy podcast` | ✅ | NIP-F4 show metadata (10154) + episode publish (54) + list. |
| `bunker` | `amy bunker[ connect]` + `amy login bunker://`/`--nostrconnect` | ✅ | NIP-46 remote signer + login, both the `bunker://` and `nostrconnect://` flows, each direction, plus `auth_url` challenge handling (client surfaces the URL + keeps waiting). Interop-verified vs real `nak`. |
| `admin` | `amy admin RELAY METHOD` | ✅ | NIP-86 Relay Management over NIP-98 HTTP auth — full method set (ban/allow pubkey + event, kinds, IP block, change name/desc/icon, list-*). Reuses quartz `Nip86Client` + shared `commons` `Nip86Retriever`. Interop-verified against `amy serve`. |
@@ -645,6 +645,8 @@ private fun printUsage() {
| [--subject S] [--branch-name N] [--merge-base C] [--label L[,L]]
| git pr-update PR --commit TIP --clone URL publish a kind:1619 pull-request update
| git comment TARGET [BODY] NIP-22 kind:1111 comment on issue/patch/PR/repo
| git label TARGET LABEL[,LABEL] NIP-32 kind:1985 labels on an issue/patch/PR
| git apply PATCH_ID [--check|--print] apply a fetched kind:1617 patch to the local tree
| git open|applied|close|draft TARGET [MSG] publish a kind:1630/1631/1632/1633 status
| git issues|patches|prs REPO list a repo's issues/patches/PRs + status
| [--open|--applied|--closed|--draft] [--limit N]
@@ -0,0 +1,114 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import java.io.File
/**
* `amy git apply PATCH_ID` fetch a NIP-34 kind:1617 patch and apply it to the
* local git working tree (the `nak git patch apply` / `ngit pr apply` surface).
* The patch content is `git format-patch` output, so by default it is fed to
* `git am` (applied as a commit); `--check` dry-runs `git apply --check` and
* `--print` just emits the patch without touching the tree.
*
* This shells out to `git`, like `git init` it operates on the local checkout.
*/
object GitApplyCommand {
suspend fun apply(
dataDir: DataDir,
rest: Array<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> =
try {
val proc =
ProcessBuilder(listOf("git", *gitArgs))
.directory(repoDir)
.redirectErrorStream(true)
.start()
if (input != null) proc.outputStream.use { it.write(input.toByteArray()) } else proc.outputStream.close()
val out = proc.inputStream.readBytes().decodeToString()
proc.waitFor() to out
} catch (e: Exception) {
1 to (e.message ?: "could not run git (is it installed and is this a git repo?)")
}
}
@@ -75,6 +75,8 @@ object GitCommands {
| git patch REPO [--file PATH] publish a kind:1617 patch (git format-patch
| [--root|--root-revision] [--commit C] from --file or stdin)
| [--parent-commit P] [--in-reply-to ID]
| git apply PATCH_ID [--check|--print] apply a fetched kind:1617 patch to the local tree
| [--repo PATH] (default: `git am`; --check dry-runs; --print emits it)
| git pr REPO --commit TIP --clone URL[,URL] publish a kind:1618 pull request [DESC arg]
| [--subject S] [--branch-name N] [--merge-base C] [--label L[,L]]
| git pr-update PR --commit TIP --clone URL[,URL] publish a kind:1619 pull-request update
@@ -82,8 +84,9 @@ object GitCommands {
| [--open|--applied|--closed|--draft|--status a,b] [--limit N]
| git thread EVENT_ID print one item + its status timeline + comments
|
|Comments & status:
|Comments, labels & status:
| git comment TARGET [BODY] NIP-22 kind:1111 comment (BODY arg or stdin)
| git label TARGET LABEL[,LABEL] [--namespace N] NIP-32 kind:1985 labels on an issue/patch/PR
| git open|applied|close|draft TARGET [MESSAGE] publish a kind:1630/1631/1632/1633 status
| applied: [--merge-commit C] [--commit C[,C]] [--patch ID[,ID]]
|
@@ -98,7 +101,7 @@ object GitCommands {
route(
"git",
tail,
"git <init|announce|state|list|show|grasp|browse|cat|log|issue|patch|pr|comment|open|applied|close|draft|issues|patches|prs|thread>",
"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) },
@@ -118,6 +121,8 @@ object GitCommands {
"prs" to { rest -> GitReadCommands.prs(dataDir, rest) },
"thread" to { rest -> GitReadCommands.thread(dataDir, rest) },
"comment" to { rest -> GitCommentCommand.comment(dataDir, rest) },
"label" to { rest -> GitLabelCommand.label(dataDir, rest) },
"apply" to { rest -> GitApplyCommand.apply(dataDir, rest) },
"open" to { rest -> GitStatusCommands.open(dataDir, rest) },
"applied" to { rest -> GitStatusCommands.applied(dataDir, rest) },
"merged" to { rest -> GitStatusCommands.applied(dataDir, rest) },
@@ -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
}
}
}
+5 -4
View File
@@ -72,10 +72,11 @@ Suite notes:
git checkout (announce + state derived via `git`), announce (30617) + state
(30618) + GRASP list (10317),
issue (1621), patch (1617), pull request (1618) + update (1619), NIP-22
comment (1111), and status events (1630-1633), then asserts the
`issues`/`patches`/`prs`/`thread` reads derive the right status (a closed
issue reads `closed`, an applied PR reads `applied`) and that
`--open`/`--closed` filter correctly. Pass `--live` to additionally exercise
comment (1111), NIP-32 label (1985), and status events (1630-1633). It also
publishes a real `git format-patch` and `git apply`s it back into a scratch
working tree, and asserts the `issues`/`patches`/`prs`/`thread` reads derive
the right status (a closed issue reads `closed`, an applied PR reads
`applied`) and that `--open`/`--closed` filter correctly. Pass `--live` to additionally exercise
the git smart-HTTP reads (`git browse`/`cat`/`log`) against a real public
repo (`$LIVE_REPO`, default octocat/Hello-World) — skipped by default since
it needs a reachable git host.
+29
View File
@@ -182,12 +182,41 @@ banner "comment + status"
CMT="$(M git comment "$ISSID" "thanks for reporting" --relay "$RELAY_URL")"
assert_eq "$(echo "$CMT" | jq -r '.kind')" "1111" comment.kind "comment is NIP-22 kind 1111"
LABEL="$(M git label "$ISSID" "bug,help-wanted" --relay "$RELAY_URL")"
assert_eq "$(echo "$LABEL" | jq -r '.kind')" "1985" label.kind "label is NIP-32 kind 1985"
assert_eq "$(echo "$LABEL" | jq -r '.labels | length')" "2" label.count "two labels attached"
CLOSE="$(M git close "$ISSID" "wontfix" --relay "$RELAY_URL")"
assert_eq "$(echo "$CLOSE" | jq -r '.kind')" "1632" close.kind "close status is kind 1632"
APPLIED="$(M git applied "$PRID" "merged it" --merge-commit feed99 --commit feed02 --relay "$RELAY_URL")"
assert_eq "$(echo "$APPLIED" | jq -r '.kind')" "1631" applied.kind "applied status is kind 1631"
# =============================================================================
# git apply — publish a real patch and apply it to a local working tree
# =============================================================================
banner "git apply (nostr patch → local git am)"
SCRATCH="$(mk_home)/scratch"
git init -q "$SCRATCH"
git -C "$SCRATCH" config user.email a@b.c
git -C "$SCRATCH" config user.name t
echo "line1" >"$SCRATCH/f.txt"
git -C "$SCRATCH" add f.txt
git -C "$SCRATCH" commit -qm "init"
# Make a real commit, capture its format-patch, then roll it back so `apply` can re-add it.
echo "line2" >>"$SCRATCH/f.txt"
git -C "$SCRATCH" commit -qam "add line2"
PATCHTXT="$(git -C "$SCRATCH" format-patch -1 --stdout)"
git -C "$SCRATCH" reset -q --hard HEAD~1
APATCH="$(printf '%s' "$PATCHTXT" | M git patch "$ADDR" --root --relay "$RELAY_URL")"
APID="$(echo "$APATCH" | jq -r '.event_id')"
CHECK="$(M git apply "$APID" --check --repo "$SCRATCH")"
assert_eq "$(echo "$CHECK" | jq -r '.mode')" "check" apply.check "apply --check dry-runs cleanly"
APPLY="$(M git apply "$APID" --repo "$SCRATCH")"
info "apply: $APPLY"
assert_eq "$(echo "$APPLY" | jq -r '.applied')" "true" apply.applied "patch applied via git am"
assert_eq "$(git -C "$SCRATCH" log --oneline | head -1 | sed 's/^[0-9a-f]* //')" "add line2" apply.commit "commit landed in the working tree"
# =============================================================================
# Status-deriving reads
# =============================================================================