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
+2 -1
View File
@@ -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). |
+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: 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`. |
@@ -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 }
}
+3 -1
View File
@@ -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
+11
View File
@@ -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)
# =============================================================================