From b06184ac497e85dd5b32cbf5bc1de45bd2d22fc6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 23:37:07 +0000 Subject: [PATCH] =?UTF-8?q?feat(cli):=20add=20`amy=20git=20init`=20?= =?UTF-8?q?=E2=80=94=20bootstrap=20a=20repo=20from=20the=20local=20git=20c?= =?UTF-8?q?heckout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match `ngit init` / `nak git init`: read the local git repository and publish a NIP-34 repository announcement, deriving the fields instead of making the user type them. Shells out to `git` to determine the name (top-level dir), clone URL (origin remote, ssh→https normalized), earliest-unique-commit (root commit), and — for the accompanying kind:30618 state — the branch/tag tips and HEAD. Publishes the 30617 announcement and (unless `--no-state`) the 30618 state in one shot. Every derived value is overridable with a flag; outside a git repo the derivation is skipped and `--name`/`--clone` are supplied manually. This is the one `amy git` verb that shells out to `git`, since it is inherently about the local working tree — exactly like the tools it mirrors. Verified against the amethyst checkout itself (derives name=amethyst, the origin clone URL, the root commit as EUC, and a 30618 with the live branches + HEAD). The harness gains 4 assertions driving `git init` against its own checkout. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr --- cli/README.md | 3 +- cli/ROADMAP.md | 2 +- .../com/vitorpamplona/amethyst/cli/Main.kt | 2 + .../amethyst/cli/commands/GitCommands.kt | 6 +- .../amethyst/cli/commands/GitInitCommand.kt | 173 ++++++++++++++++++ cli/tests/README.md | 4 +- cli/tests/git/git-nip34-headless.sh | 11 ++ 7 files changed, 197 insertions(+), 4 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitInitCommand.kt diff --git a/cli/README.md b/cli/README.md index 5e27abf780..90b55a5cef 100644 --- a/cli/README.md +++ b/cli/README.md @@ -322,7 +322,8 @@ the default is the repo's advertised relays, else your outbox. | Command | What it does | |---|---| -| `amy git announce --name N [--description D] [--clone URL[,URL]] [--web URL[,URL]] [--relay URL[,URL]] [--maintainer HEX[,HEX]] [--hashtag T[,T]] [--earliest-commit C] [--personal-fork] [--d ID]` | Publish a kind:30617 repository announcement. | +| `amy git init [--name N] [--description D] [--clone URL[,URL]] [--relay URL[,URL]] [--no-state] [--repo PATH] [--d ID]` | Bootstrap a repo from the local git checkout (like `ngit init`): derive name, clone URL, earliest-unique-commit, and branch/tag state via `git`, then publish the kind:30617 announcement and (unless `--no-state`) the kind:30618 state. Any flag overrides a derived value. | +| `amy git announce --name N [--description D] [--clone URL[,URL]] [--web URL[,URL]] [--relay URL[,URL]] [--maintainer HEX[,HEX]] [--hashtag T[,T]] [--earliest-commit C] [--personal-fork] [--d ID]` | Publish a kind:30617 repository announcement (manual, no local repo needed). | | `amy git state REPO\|IDENTIFIER [--head BRANCH] [--branch name=commit[,…]] [--tag name=commit[,…]]` | Publish a kind:30618 repository state (branch/tag tips + HEAD). | | `amy git list [USER]` | List a user's repo announcements (defaults to self). | | `amy git show NADDR\|kind:pubkey:id` | Print one repo announcement (cache-first). | diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 02f45a13ea..7fb1d48690 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -109,7 +109,7 @@ vs streaming `subscribe`). Stateless verbs run with no account or network. | `nip` | `amy nip` | ✅ | repo-first lookup + Nostr fallback (NipText kind:30817, wiki:30818, long-form:30023); `nip list`. | | `kind` | `amy kind` | ✅ | quartz `KindNames` registry (kind → English label + NIP) covering **every** event kind quartz defines (280 entries); number lookup + name search. | | `sync` | `amy sync` | ✅ | NIP-77 Negentropy reconcile with the local store (down/up/both). | -| `git` | `amy git` | ✅ (events + read) | NIP-34: repo announce (30617) + state (30618), patches (1617), pull requests (1618/1619), issues (1621), NIP-22 comments (1111), status open/applied/closed/draft (1630-1633), GRASP server list (10317); `issues`/`patches`/`prs`/`thread` reads derive status; `browse`/`cat`/`log` read git objects over smart-HTTP v2 (quartz `GitHttpClient`, the same shallow-clone path the Android browser uses). Only git-packfile **push** (writing objects to clone/GRASP servers) is out of scope. | +| `git` | `amy git` | ✅ (events + read) | NIP-34: `init` bootstraps a repo from the local `git` checkout (announce + state, like `ngit init`); repo announce (30617) + state (30618), patches (1617), pull requests (1618/1619), issues (1621), NIP-22 comments (1111), status open/applied/closed/draft (1630-1633), GRASP server list (10317); `issues`/`patches`/`prs`/`thread` reads derive status; `browse`/`cat`/`log` read git objects over smart-HTTP v2 (quartz `GitHttpClient`, the same shallow-clone path the Android browser uses). Only git-packfile **push** (writing objects to clone/GRASP servers) is out of scope. | | `podcast` | `amy podcast` | ✅ | NIP-F4 show metadata (10154) + episode publish (54) + list. | | `bunker` | `amy bunker[ connect]` + `amy login bunker://`/`--nostrconnect` | ✅ | NIP-46 remote signer + login, both the `bunker://` and `nostrconnect://` flows, each direction, plus `auth_url` challenge handling (client surfaces the URL + keeps waiting). Interop-verified vs real `nak`. | | `admin` | `amy admin RELAY METHOD` | ✅ | NIP-86 Relay Management over NIP-98 HTTP auth — full method set (ban/allow pubkey + event, kinds, IP block, change name/desc/icon, list-*). Reuses quartz `Nip86Client` + shared `commons` `Nip86Retriever`. Interop-verified against `amy serve`. | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 2b802e9a53..0a89fd9823 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -624,6 +624,8 @@ private fun printUsage() { | blossom mirror --server URL SOURCE-URL ask the server to mirror a blob (BUD-04) | |Git (NIP-34): + | git init [--name N] [--clone URL] bootstrap a repo from the local git checkout + | [--no-state] [--repo PATH] (derives fields via `git`; publishes 30617+30618) | git announce --name N [--description D] publish a kind:30617 repo announcement | [--clone URL[,URL]] [--web URL[,URL]] (--d sets the identifier; defaults to name) | [--relay URL[,URL]] [--maintainer HEX[,]] diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt index f78812c5a9..9391fd73bc 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt @@ -49,6 +49,9 @@ object GitCommands { |amy git — NIP-34 Nostr-native git collaboration | |Repository: + | git init [--name N] [--description D] bootstrap a repo from the local git checkout + | [--clone URL[,URL]] [--relay URL[,URL]] (derives name/clone/earliest-commit/state via + | [--no-state] [--repo PATH] [--d ID] `git`; flags override; publishes 30617 + 30618) | git announce --name N [--description D] publish a kind:30617 repo announcement | [--clone URL[,URL]] [--web URL[,URL]] (--d / --identifier sets the identifier; | [--relay URL[,URL]] [--maintainer HEX[,]] defaults to name) @@ -95,8 +98,9 @@ object GitCommands { route( "git", tail, - "git ", + "git ", mapOf( + "init" to { rest -> GitInitCommand.init(dataDir, rest) }, "announce" to { rest -> announce(dataDir, rest) }, "state" to { rest -> state(dataDir, rest) }, "list" to { rest -> list(dataDir, rest) }, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitInitCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitInitCommand.kt new file mode 100644 index 0000000000..1a9b68f131 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitInitCommand.kt @@ -0,0 +1,173 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent +import com.vitorpamplona.quartz.nip34Git.state.GitRepositoryStateEvent +import com.vitorpamplona.quartz.nip34Git.state.tags.RefTag +import java.io.File + +/** + * `amy git init` — bootstrap a NIP-34 repository from the local git checkout, + * the way `ngit init` does: derive the name, clone URL, earliest-unique-commit, + * and branch/tag state from `git`, then publish the kind:30617 announcement and + * (unless `--no-state`) the kind:30618 state in one shot. Every field can be + * overridden with a flag; when the directory isn't a git repo, the derivation + * is skipped and you supply `--name` / `--clone` yourself. + * + * This is the one `amy git` verb that shells out to `git` — it's inherently + * about the local working tree, exactly like the `ngit`/`nak git` `init`. + */ +object GitInitCommand { + suspend fun init( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val repoDir = File(args.flag("repo") ?: ".").absoluteFile + val noState = args.bool("no-state") + + val toplevel = git(repoDir, "rev-parse", "--show-toplevel")?.let { File(it) } + val derivedName = toplevel?.name + val originUrl = git(repoDir, "remote", "get-url", "origin")?.let(::normalizeCloneUrl) + // The earliest unique commit is the root commit; `rev-list` prints newest + // first, so the last line is the initial commit. + val euc = git(repoDir, "rev-list", "--max-parents=0", "HEAD")?.lineSequence()?.lastOrNull { it.isNotBlank() } + + val name = + args.flag("name") ?: derivedName + ?: return Output.error("bad_args", "not a git repo and no --name given (run inside a repo, or pass --name)") + val identifier = args.flag("d") ?: args.flag("identifier") ?: kebab(name) + val cloneUrls = GitSupport.csv(args, "clone").ifEmpty { listOfNotNull(originUrl) } + val earliestCommit = args.flag("earliest-commit") ?: euc + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val announce = + GitRepositoryEvent.build( + name = name, + description = args.flag("description"), + webUrls = GitSupport.csv(args, "web"), + cloneUrls = cloneUrls, + relays = GitSupport.csv(args, "relay"), + maintainers = GitSupport.csv(args, "maintainer"), + hashtags = GitSupport.csv(args, "hashtag"), + earliestUniqueCommit = earliestCommit, + personalFork = args.bool("personal-fork"), + dTag = identifier, + ) + val signedAnnounce = ctx.signer.sign(announce) + val targets = RawEventSupport.publishTargets(ctx, args) + args.rejectUnknown() + val ackAnnounce = ctx.publish(signedAnnounce, targets) + RawEventSupport.publishGuard(ackAnnounce, signedAnnounce.id)?.let { return it } + + val result = + mutableMapOf( + "event_id" to signedAnnounce.id, + "address" to Address.assemble(signedAnnounce.kind, signedAnnounce.pubKey, identifier), + "name" to name, + "clone" to cloneUrls, + "earliest_commit" to earliestCommit, + "from_git_repo" to (toplevel != null), + ) + + if (!noState && toplevel != null) { + val refs = readRefs(repoDir) + val head = git(repoDir, "symbolic-ref", "--short", "HEAD") + if (refs.isNotEmpty() || head != null) { + val stateTemplate = GitRepositoryStateEvent.build(dTag = identifier, refs = refs, head = head) + val signedState = ctx.signer.sign(stateTemplate) + ctx.publish(signedState, targets) + result["state_event_id"] = signedState.id + result["branches"] = refs.count { it.kind == RefTag.Kind.BRANCH } + result["tags"] = refs.count { it.kind == RefTag.Kind.TAG } + result["head"] = head + } + } + Output.emit(result + RawEventSupport.ackFields(ackAnnounce)) + return 0 + } + } + + /** Read local branch + tag refs as NIP-34 [RefTag]s via `git for-each-ref`. */ + private fun readRefs(repoDir: File): List { + fun parse( + output: String?, + builder: (String, String) -> RefTag, + ): List = + output + ?.lineSequence() + ?.mapNotNull { line -> + val parts = line.trim().split(' ') + if (parts.size == 2 && parts[0].isNotEmpty() && parts[1].isNotEmpty()) builder(parts[0], parts[1]) else null + }?.toList() + .orEmpty() + + val branches = parse(git(repoDir, "for-each-ref", "--format=%(refname:short) %(objectname)", "refs/heads")) { n, c -> RefTag.branch(n, c) } + val tags = parse(git(repoDir, "for-each-ref", "--format=%(refname:short) %(objectname)", "refs/tags")) { n, c -> RefTag.tag(n, c) } + return branches + tags + } + + /** Run `git ` in [repoDir]; returns trimmed stdout on exit 0, else null (git missing / not a repo). */ + private fun git( + repoDir: File, + vararg gitArgs: String, + ): String? = + try { + val proc = + ProcessBuilder(listOf("git", *gitArgs)) + .directory(repoDir) + .redirectErrorStream(false) + .start() + val out = proc.inputStream.readBytes().decodeToString() + proc.errorStream.readBytes() + if (proc.waitFor() == 0) out.trim().ifEmpty { null } else null + } catch (_: Exception) { + null + } + + /** Convert an ssh remote (`git@host:owner/repo.git`, `ssh://…`) to a browsable https URL; pass others through. */ + private fun normalizeCloneUrl(url: String): String = + when { + url.startsWith("git@") -> { + val rest = url.removePrefix("git@") + val host = rest.substringBefore(':') + val path = rest.substringAfter(':') + "https://$host/$path" + } + url.startsWith("ssh://git@") -> "https://" + url.removePrefix("ssh://git@") + else -> url + } + + /** kebab-case a repo name for the `d` identifier: lowercase, non-alphanumerics collapse to single hyphens. */ + private fun kebab(name: String): String = + name + .lowercase() + .replace(Regex("[^a-z0-9]+"), "-") + .trim('-') + .ifEmpty { name } +} diff --git a/cli/tests/README.md b/cli/tests/README.md index 5dc5ae5373..d6e36efc37 100644 --- a/cli/tests/README.md +++ b/cli/tests/README.md @@ -68,7 +68,9 @@ Suite notes: both directions (plus the `--no-sync-deletions` opt-out) against `amy serve`, with one `$HOME` per account so stores don't share. - **`git/git-nip34-headless.sh`** drives the full NIP-34 collaboration surface - against `amy serve`: announce (30617) + state (30618) + GRASP list (10317), + against `amy serve`: `git init` bootstrapping a repo from the harness's own + git checkout (announce + state derived via `git`), announce (30617) + state + (30618) + GRASP list (10317), issue (1621), patch (1617), pull request (1618) + update (1619), NIP-22 comment (1111), and status events (1630-1633), then asserts the `issues`/`patches`/`prs`/`thread` reads derive the right status (a closed diff --git a/cli/tests/git/git-nip34-headless.sh b/cli/tests/git/git-nip34-headless.sh index 48b59ac58e..46d3fb284a 100755 --- a/cli/tests/git/git-nip34-headless.sh +++ b/cli/tests/git/git-nip34-headless.sh @@ -118,6 +118,17 @@ for _ in $(seq 1 60); do done grep -q "relay up at" "$LOG_FILE" || { fail_msg "relay did not come up"; exit 1; } +# ============================================================================= +# git init — bootstrap from the local git checkout (this repo) +# ============================================================================= +banner "git init (from the amethyst checkout)" +INIT="$(M git init --repo "$REPO_ROOT" --relay "$RELAY_URL")" +info "init: $INIT" +assert_eq "$(echo "$INIT" | jq -r '.from_git_repo')" "true" init.from_git "init derived fields from the git repo" +assert_nonempty "$(echo "$INIT" | jq -r '.name')" init.name "repo name derived" +assert_nonempty "$(echo "$INIT" | jq -r '.earliest_commit')" init.euc "earliest-unique-commit derived from git" +assert_nonempty "$(echo "$INIT" | jq -r '.state_event_id')" init.state "init also published a 30618 state event" + # ============================================================================= # Repository announcement (30617) + state (30618) # =============================================================================