feat(cli): add amy git init — bootstrap a repo from the local git checkout

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 <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 45d33c016e
commit b06184ac49
7 changed files with 197 additions and 4 deletions
@@ -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[,]]
@@ -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 <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|pr|comment|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) },
@@ -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<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 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<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)
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<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). */
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 }
}