diff --git a/cli/README.md b/cli/README.md index 4024f787aa..5e27abf780 100644 --- a/cli/README.md +++ b/cli/README.md @@ -329,6 +329,18 @@ the default is the repo's advertised relays, else your outbox. | `amy git grasp list [USER]` | List a user's kind:10317 GRASP hosting-server list (defaults to self). | | `amy git grasp set URL[,URL]` | Publish your GRASP hosting-server list (preference order — where PR tips get pushed). | +**Read repository content** (git smart-HTTP v2, read-only — needs a reachable git host) + +`REPO` here is a repo coordinate/naddr (whose announcement supplies the clone +URL) **or** a raw `http(s)` clone URL. This is the git-object read side of +`nak git download` / a shallow clone; pushing objects back is out of scope. + +| Command | What it does | +|---|---| +| `amy git browse REPO [PATH] [--ref R] [--clone URL]` | List a repo's tree entries at PATH (default: root). | +| `amy git cat REPO PATH [--ref R] [--out FILE]` | Print a file's contents at a ref (or write raw bytes to `--out`). | +| `amy git log REPO [--ref R] [--depth N] [--clone URL]` | Recent commit history, most recent first. | + **Issues, patches & pull requests** | Command | What it does | diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index df1b2db5ae..02f45a13ea 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) | 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. clone/push (git-packfile transport) out of scope. | +| `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. | | `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`. | @@ -130,7 +130,8 @@ nak has 34 functional commands (introspected from `nak --help`). Coverage: `admin`) are interop-verified against the real `nak` binary or `amy serve`. - **Partial / adapted (3):** `key` (no `expand`/`combine`(MuSig2)/`default`), `git` (full NIP-34 event surface — announce/state/patch/PR/issue/comment/status - + status-deriving reads — but no git-packfile clone/push transport), `outbox` + + status-deriving reads + GRASP list, plus `browse`/`cat`/`log` reading git + objects over smart-HTTP; only packfile **push** is out of scope), `outbox` (NIP-65 model vs nak's local hints DB). - **Missing (6):** `dekey` (NIP-4E), `mcp`, `curl` (NIP-98), `fs` (FUSE), `spell` (MuSig2/FROST), and `validate` (event-schema validation). 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 ff8e5a3c09..2b802e9a53 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -633,6 +633,8 @@ private fun printUsage() { | git list [USER] list a user's repo announcements (default self) | git show NADDR|kind:pubkey:id print one repo announcement | git grasp list [USER] | set URL[,URL] GRASP hosting-server list (kind 10317) + | git browse REPO [PATH] | cat REPO PATH read repo tree/file over git smart-HTTP + | git log REPO [--depth N] recent commit history (read-only) | git issue REPO --subject S [BODY] publish a kind:1621 issue against a repo | [--hashtag T[,T]] (BODY from arg or stdin) | git patch REPO [--file P] [--root] publish a kind:1617 patch (format-patch/stdin) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitBrowseCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitBrowseCommands.kt new file mode 100644 index 0000000000..e56a2e4309 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitBrowseCommands.kt @@ -0,0 +1,249 @@ +/* + * 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.nip34Git.git.GitHttpClient +import com.vitorpamplona.quartz.nip34Git.git.GitRepoSnapshot +import com.vitorpamplona.quartz.nip34Git.git.GitTreeEntry +import java.io.File + +/** + * `amy git browse|cat|log` — read a repository's actual git content over the + * git smart-HTTP v2 protocol (the same [GitHttpClient] the Android repo browser + * uses). Resolves the http(s) clone URL from the kind:30617 announcement and + * does a shallow fetch, so this is read-only and needs a reachable git host. + * This is the git-object read side of `nak git download` / a shallow `clone`; + * pushing objects back is still out of scope (see cli/ROADMAP.md). + */ +object GitBrowseCommands { + /** `git browse REPO [PATH]` — list the tree entries at PATH (default: repo root). */ + suspend fun browse( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val coord = args.positional(0, "repo-naddr-or-coordinates") + val path = args.positionalOrNull(1).orEmpty() + val ref = args.flag("ref") + val cloneOverride = args.flag("clone") + args.rejectUnknown("relay") + + return withSnapshot(dataDir, coord, ref, cloneOverride, args) { _, snapshot -> + val segments = splitPath(path) + val entries = + if (segments.isEmpty()) { + snapshot.rootEntries() + } else { + snapshot.entriesAt(segments) + ?: return@withSnapshot Output.error("not_found", "no directory at path '$path'") + } + Output.emit( + mapOf( + "clone_url" to snapshot.cloneUrl, + "branch" to snapshot.branch, + "head_commit" to snapshot.headCommit, + "path" to path, + "count" to entries.size, + "entries" to entries.map(::entrySummary), + ), + ) + 0 + } + } + + /** `git cat REPO PATH` — print (or `--out FILE`) a file's contents at a ref. */ + suspend fun cat( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val coord = args.positional(0, "repo-naddr-or-coordinates") + val path = args.positional(1, "file-path") + val ref = args.flag("ref") + val cloneOverride = args.flag("clone") + val out = args.flag("out") + args.rejectUnknown("relay") + + return withSnapshot(dataDir, coord, ref, cloneOverride, args) { _, snapshot -> + val entry = + snapshot.entryAt(splitPath(path)) + ?: return@withSnapshot Output.error("not_found", "no file at path '$path'") + if (entry.isFolder) return@withSnapshot Output.error("bad_args", "'$path' is a directory (use `git browse`)") + val bytes = snapshot.readBlob(entry.oid) + val binary = isBinary(bytes) + if (out != null) { + File(out).writeBytes(bytes) + } + Output.emit( + mapOf( + "clone_url" to snapshot.cloneUrl, + "path" to path, + "oid" to entry.oid, + "size_bytes" to bytes.size, + "binary" to binary, + "written_to" to out, + // Text content is inlined only when it isn't binary and wasn't written to a file. + "content" to if (!binary && out == null) bytes.decodeToString() else null, + ), + ) + 0 + } + } + + /** `git log REPO` — recent commit history (most recent first). */ + suspend fun log( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val coord = args.positional(0, "repo-naddr-or-coordinates") + val ref = args.flag("ref") + val cloneOverride = args.flag("clone") + val depth = args.intFlag("depth", 50) + args.rejectUnknown("relay") + + return withSnapshot(dataDir, coord, ref, cloneOverride, args) { http, snapshot -> + val commits = http.loadHistory(snapshot.cloneUrl, snapshot.headCommit, depth) + Output.emit( + mapOf( + "clone_url" to snapshot.cloneUrl, + "branch" to snapshot.branch, + "count" to commits.size, + "commits" to + commits.map { + mapOf( + "oid" to it.oid, + "short_oid" to it.shortOid, + "summary" to it.summary, + "author" to it.authorName, + "author_email" to it.authorEmail, + "author_time" to it.authorTimeSec, + "parents" to it.parents, + ) + }, + ), + ) + 0 + } + } + + // ------------------------------------------------------------------ + + /** + * Resolve the repo announcement, pick its http(s) clone URLs, open a shallow + * snapshot (trying each candidate URL), and hand the client + snapshot to + * [block]. Emits a `not_found` / `unreachable` error when the repo or a + * working clone URL can't be resolved. + */ + private suspend fun withSnapshot( + dataDir: DataDir, + coord: String, + ref: String?, + cloneOverride: String?, + args: Args, + block: suspend (GitHttpClient, GitRepoSnapshot) -> Int, + ): Int { + // REPO may be a raw http(s) clone URL, an naddr, or `kind:pubkey:id`. + val directUrl = coord.takeIf { it.startsWith("http://") || it.startsWith("https://") } + val addr = + if (directUrl == null) { + GitSupport.resolveAddress(coord) + ?: return Output.error("bad_args", "expected a clone URL, an naddr, or kind:pubkey:identifier") + } else { + null + } + + Context.openOrAnonymous(dataDir).use { ctx -> + ctx.prepare() + val cloneUrls = + when { + cloneOverride != null -> listOf(cloneOverride) + directUrl != null -> listOf(directUrl) + else -> { + val repo = + GitSupport.fetchRepo(ctx, addr!!, args) + ?: return Output.error("not_found", "no repository announcement found for $coord") + repo.clones() + } + } + val candidates = candidateUrls(cloneUrls) + if (candidates.isEmpty()) { + return Output.error("bad_args", "repository has no http(s) clone URL (pass --clone URL)") + } + val http = GitHttpClient { ctx.okhttp } + val errors = StringBuilder() + for (url in candidates) { + val snapshot = + try { + http.open(url, ref) + } catch (e: Exception) { + errors + .append(url) + .append(" → ") + .append(e.message ?: e.toString()) + .append('\n') + null + } + if (snapshot != null) return block(http, snapshot) + } + return Output.error("unreachable", "could not clone any candidate URL:\n${errors.toString().trim()}") + } + } + + /** http(s) clone URLs to try, in order, adding a `.git` variant when missing. */ + private fun candidateUrls(cloneUrls: List): List { + val out = LinkedHashSet() + for (raw in cloneUrls) { + val url = raw.trim() + if (!url.startsWith("http://") && !url.startsWith("https://")) continue + out.add(url) + if (!url.removeSuffix("/").endsWith(".git")) out.add(url.removeSuffix("/") + ".git") + } + return out.toList() + } + + private fun splitPath(path: String): List = + path + .trim() + .trim('/') + .split('/') + .filter { it.isNotEmpty() } + + private fun entrySummary(entry: GitTreeEntry): Map = + mapOf( + "name" to entry.name, + "type" to + when { + entry.isFolder -> "dir" + entry.isSubmodule -> "submodule" + entry.isSymlink -> "symlink" + else -> "file" + }, + "oid" to entry.oid, + ) + + /** A blob is treated as binary when it contains a NUL byte in its head. */ + private fun isBinary(bytes: ByteArray): Boolean = bytes.take(8000).any { it.toInt() == 0 } +} 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 f30008c9a9..f78812c5a9 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 @@ -61,6 +61,11 @@ object GitCommands { | git show NADDR|kind:pubkey:id print one repo announcement | git grasp list [USER] | set URL[,URL] a user's GRASP hosting-server list (kind 10317) | + |Read repo content (git smart-HTTP, read-only — needs a reachable git host): + | git browse REPO [PATH] [--ref R] [--clone URL] list a repo's tree at PATH (default root) + | git cat REPO PATH [--ref R] [--out FILE] print (or write) a file's contents at a ref + | git log REPO [--ref R] [--depth N] recent commit history (most recent first) + | |Issues / patches / pull requests: | git issue REPO --subject S [BODY] publish a kind:1621 issue (BODY arg or stdin) | [--hashtag T[,T]] @@ -90,13 +95,16 @@ object GitCommands { route( "git", tail, - "git ", + "git ", mapOf( "announce" to { rest -> announce(dataDir, rest) }, "state" to { rest -> state(dataDir, rest) }, "list" to { rest -> list(dataDir, rest) }, "show" to { rest -> show(dataDir, rest) }, "grasp" to { rest -> GitGraspCommands.dispatch(dataDir, rest) }, + "browse" to { rest -> GitBrowseCommands.browse(dataDir, rest) }, + "cat" to { rest -> GitBrowseCommands.cat(dataDir, rest) }, + "log" to { rest -> GitBrowseCommands.log(dataDir, rest) }, "issue" to { rest -> issue(dataDir, rest) }, "issues" to { rest -> GitReadCommands.issues(dataDir, rest) }, "patch" to { rest -> GitPatchCommands.patch(dataDir, rest) }, diff --git a/cli/tests/README.md b/cli/tests/README.md index 26f8ab5432..5dc5ae5373 100644 --- a/cli/tests/README.md +++ b/cli/tests/README.md @@ -68,11 +68,15 @@ 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), 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 issue reads `closed`, an applied PR - reads `applied`) and that `--open`/`--closed` filter correctly. + against `amy serve`: 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 + issue reads `closed`, an applied PR reads `applied`) and that + `--open`/`--closed` filter correctly. Pass `--live` to additionally exercise + the git smart-HTTP reads (`git browse`/`cat`/`log`) against a real public + repo (`$LIVE_REPO`, default octocat/Hello-World) — skipped by default since + it needs a reachable git host. The Marmot harnesses come in two flavours, same scenarios: diff --git a/cli/tests/git/git-nip34-headless.sh b/cli/tests/git/git-nip34-headless.sh index 400ebe1248..48b59ac58e 100755 --- a/cli/tests/git/git-nip34-headless.sh +++ b/cli/tests/git/git-nip34-headless.sh @@ -32,11 +32,14 @@ RELAY_HOST="127.0.0.1" RELAY_PORT="${RELAY_PORT:-7793}" RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT" NO_BUILD=0 +LIVE=0 +LIVE_REPO="${LIVE_REPO:-https://github.com/octocat/Hello-World.git}" while [[ $# -gt 0 ]]; do case "$1" in --port) RELAY_PORT="$2"; RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"; shift ;; --no-build) NO_BUILD=1 ;; + --live) LIVE=1 ;; *) echo "unknown arg: $1" >&2; exit 2 ;; esac shift @@ -204,5 +207,25 @@ assert_eq "$(echo "$THREAD" | jq -r '.status')" "closed" thread.status "thread s assert_eq "$(echo "$THREAD" | jq -r '.status_events | length')" "1" thread.status_events "one status event in timeline" assert_eq "$(echo "$THREAD" | jq -r '.comments | length')" "1" thread.comments "one comment in thread" +# ============================================================================= +# Read repo content over git smart-HTTP (--live only — needs a reachable host). +# ============================================================================= +if [[ "$LIVE" -eq 1 ]]; then + banner "live: git browse / cat / log ($LIVE_REPO)" + BROWSE="$(M git browse "$LIVE_REPO" --json)" + info "browse: $BROWSE" + assert_nonempty "$(echo "$BROWSE" | jq -r '.head_commit')" live.browse "browse resolves a head commit" + FIRST_FILE="$(echo "$BROWSE" | jq -r '.entries[] | select(.type=="file") | .name' | head -1)" + if [[ -n "$FIRST_FILE" ]]; then + CAT="$(M git cat "$LIVE_REPO" "$FIRST_FILE" --json)" + assert_eq "$(echo "$CAT" | jq -r '.path')" "$FIRST_FILE" live.cat "cat returns the requested file" + assert_nonempty "$(echo "$CAT" | jq -r '.oid')" live.cat_oid "cat resolves a blob oid" + fi + LOG="$(M git log "$LIVE_REPO" --depth 2 --json)" + assert_nonempty "$(echo "$LOG" | jq -r '.commits[0].oid')" live.log "log returns at least one commit" +else + skip_msg "live git smart-HTTP reads (browse/cat/log) — pass --live to run" +fi + grep -q $'\tfail\t' "$RESULTS_FILE" && exit 1 exit 0