mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +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:
+2
-1
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
# =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user