mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user