Compare commits

...

24 Commits

Author SHA1 Message Date
Claude
078758888a perf(relay): remove live-path allocations in LiveEventStore + FilterIndex
The remaining SmallReqFloorBenchmark waste, on the per-row replay, the
per-event live fanout, and the per-accepted-event index probe:

- LiveEventStore replay dedupe: a SeenIds holder with an inline lock
  replaces the local-fn-plus-lambda that allocated one closure per
  streamed row (and again per live delivery). Its HashSet is created
  empty so the JVM defers the backing table to the first add — a 0-row
  replay no longer allocates a 1024-slot table (was ~4 MB across the
  benchmark's 1000 idle subs).
- Live fanout serializes the event body once and passes it through
  onEachLive(event, body); RelaySession splices it into the per-sub
  frame prefix. An event matching N live subscriptions paid N identical
  Jackson passes before; now one. queryRaw's onEachLive signature gains
  the body arg (EventSourceBackend default serializes inline, no
  cross-sub memo, no regression). Measured: fanout 1->200 live subs
  0.50 ms (2.5 us/sub).
- FilterIndex holds subscribers in one persistent map per dimension, so
  candidatesFor (once per accepted ingest event) probes with the event's
  own fields and allocates no IdKey/AuthorKey/KindKey/TagKey wrappers;
  BucketKey now lives only in the rare register/unregister bookkeeping.

SmallReqFloorBenchmark grows a fanout stage (200 live subs, one submit)
to anchor the fanout number; it drives `live` directly and guards the
await with withTimeout so a future fanout regression fails fast.

Verified: quartz relay.server + FilterIndex suites (110 tests),
SmallReqFloorBenchmark, geode suite (126 tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
2026-07-21 19:23:38 +00:00
Claude
387bfe99ee perf(relay): drop per-op allocations on the frame + search paths
Three hot-path allocation cuts the SmallReqFloorBenchmark stages flagged:

- strippingSearchExtensions: index-loop guard returns the same list with
  zero allocation when no filter carries a search term (every non-search
  REQ/COUNT/snapshot, the overwhelming majority).
- EoseMessage/OkMessage: direct-buildString wire form on the escape-free
  fast path (EOSE per REQ, OK per publish), skipping the generic
  serializer's node tree; exotic subIds/reasons fall back. Shared
  isEscapeFreeAscii helper in WireJson.kt, mirroring NegMsgMessage.

Verified: quartz relay.server + message-frame suites (110 tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
2026-07-21 19:23:21 +00:00
Claude
6033957b1c perf(relay): persistent-map FilterIndex snapshots, benchmark the sub population
FilterIndex registration runs on every REQ open/close but built each
new snapshot by copying both full maps — O(S) work and allocation per
REQ with S live subscriptions. Persistent (HAMT) maps keep the
wait-free single-load reads and CAS write loop while making a write
O(keys x log S) with structural sharing.

SmallReqFloorBenchmark grows a B@1k stage (1000 idle parked
subscriptions) to make the population cost visible, and its B stage
now enters queryRaw undispatched like production does: @1000 subs the
per-REQ cost drops 0.225 -> 0.151 ms and the measured population
penalty falls below run noise (was +0.011 ms per REQ).

With this and the undispatched replay, the in-process floor above the
raw store query is ~0.11 ms (was ~0.66 ms as first measured): A 0.120,
B 0.203, C 0.239 ms on a quiet machine.

Verified: FilterIndex tests, quartz relay.server suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
2026-07-21 16:22:13 +00:00
Claude
37923d9101 perf(relay): run the stored REQ replay undispatched
SmallReqFloorBenchmark showed the per-REQ floor on small results is
dominated by pipeline, not the store (raw query 0.125 ms vs 0.785 ms
session REQ->EOSE in-process). Half of the dispatch slice was the
scheduler hop between handleReq's launch and the query coroutine:
starting the job with CoroutineStart.UNDISPATCHED runs the stored
replay and EOSE inline on the receiving coroutine (the reader-pool
acquire doesn't suspend when a connection is free), parking only at
the live tail. Measured: dispatch+frames slice 0.397 -> 0.207 ms.

Commands on a connection are processed sequentially, so nothing can
target the subscription before the job lands in the registry at the
first suspension point.

Verified: quartz relay.server suite, SmallReqFloorBenchmark, geode
full test suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
2026-07-21 16:14:12 +00:00
Claude
57ffb3386d feat(geode): enable the tag+kind+pubkey index, refresh measured docs
TagAuthorIndexBenchmark at 1M events settles the flag: the DM-room
shape (kinds + authors + #p, 65 client assembler call sites) drops
14.2 ms -> 0.66 ms (~21x, growing with corpus size) while batch-insert
cost stays inside run noise (49.0 vs 47.4 us/event). Existing relay
DBs build the index on next open via ensureOptionalIndexes.

Also refreshes the docs the numbers made stale: IndexingStrategy KDoc
now records the 200k and 1M measurements instead of a TODO,
MergeQueryExecutor's tag-merge note points at the new relayBench
reactions-watch scenario, FsQueryPlanner/FsDriverSelectionBenchmark
reflect the landed cost-based pick (149 ms -> 4.0 ms at 30k events),
and RELAY.md documents that strategy flag flips materialize indexes on
the next open.

Verified: quartz jvmTest store suites, geode test (126), desktopApp
LocalRelayStore tests (5, incl. reopening a default-strategy DB with
the new pubkey-alone flag), relayBench compiles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
2026-07-21 15:07:12 +00:00
Claude
a3e239d33f feat(store): cost-based FS driver pick, runtime index materialization, new relayBench shapes
Acts on the measured gaps from TagAuthorIndexBenchmark and
FsDriverSelectionBenchmark:

- FsQueryPlanner: replace the fixed tags -> kinds -> authors driver
  order with a cost-based pick. Every legal driver (each tagsAll value,
  each tags key's value union, the kind set, the author set) opens a
  lazy directory iterator; all are drained in lockstep and the first to
  exhaust (the smallest listing) drives, so a giant idx/kind tree is
  never read past ~the smallest candidate's size. Fixes the 149 ms vs
  3.4 ms (~44x at 30k events) authors+kinds+limit regression.

- EventIndexesModule.ensureOptionalIndexes + SQLiteEventStore: flag-
  gated indexes are runtime config, not schema. An idempotent
  CREATE INDEX IF NOT EXISTS pass now runs on every open, so flipping
  an IndexingStrategy flag on an existing DB builds the index without
  a user_version bump.

- Desktop LocalRelayStore: enable indexEventsByPubkeyAlone. Shared
  ViewModels (Nip65RelayList, PrivateOutboxRelayList, VanishRequests)
  replay authors-only filters that full-scanned without
  (pubkey, created_at); existing DBs pick the index up on next open.

- relayBench Scenarios: add "conversation" (tag ∩ author ∩ kind, the
  DM-room shape, 65 client assembler call sites) and "reactions-watch"
  (kind 7 + #e IN 150 hottest notes) so the uncovered archetypes get
  head-to-head numbers vs strfry.

- quartz build: forward tagBenchScale/fsBenchScale to the test JVM.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
2026-07-21 14:54:48 +00:00
Claude
41240eeab9 docs(store): record measured index/planner gaps as TODOs in both stores
Real numbers from the two new benchmarks, so the trade-offs are on the
decision points instead of in a chat log:

- IndexingStrategy.indexTagsWithKindAndPubkey: the KDoc called the
  kinds+authors+tags shape "rarely used", but the client assembler
  survey found 65 call sites. TagAuthorIndexBenchmark @ 200k events:
  DM-room query 9.4 ms -> 0.6 ms (~15x) with the flag on, insert cost
  +14% (41.5 -> 47.3 us/event). TODO: re-evaluate defaults (geode).

- MergeQueryExecutor: tag-path analogue of the follow-feed collect-all
  sort (kinds + #e IN [hundreds] + limit never merges). Measured
  12.8 ms cold / 6.0 ms since-bounded at 200k events; revisit if
  relayBench shows it at relay scale.

- FsQueryPlanner: fixed driver order sends authors+kinds+limit (the
  most common CLI shape) through the kind tree. FsDriverSelection-
  Benchmark @ 30k events: 149 ms -> 3.4 ms (~44x) driving from the
  author tree; TODO: cost-based pick by directory entry counts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
2026-07-21 14:39:21 +00:00
Claude
5988db010d test(store): add tag∩author and FS driver-selection benchmarks
The 2026-07 client filter-assembler survey mapped 551 Filter
constructions to ~12 query archetypes. Two hot shapes had no benchmark
coverage in prodbench or relayBench, and the FS store had none at all:

- TagAuthorIndexBenchmark: the DM-room shape (kinds + authors + #p,
  65 assembler call sites) with indexTagsWithKindAndPubkey off vs on,
  including the insert-cost delta of the extra index; plus the
  reactions watcher (kinds=[7], #e IN 300, limit) cold and
  since-bounded, which has no tag-side k-way merge today.

- FsDriverSelectionBenchmark: FsQueryPlanner's fixed driver order
  (tags → kinds → authors) on authors+kinds+limit — the most common
  CLI shape — comparing the current kind-tree driver against an
  author-tree driver with kind post-filter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
2026-07-21 14:34:57 +00:00
Vitor Pamplona
72e27a3530 Merge pull request #3654 from davotoula/feat/share-note-as-qr
Share a note (link) as a QR code
2026-07-21 09:57:06 -04:00
davotoula
556ef9f880 fix(share-qr): stop the mode toggle labels clipping 2026-07-21 14:30:04 +01:00
davotoula
52c863a9c6 feat(share): add QR payload selection for notes
test(share): pin naddr encoding for addressable notes in QR payloads
feat(share): add strings for the QR share screen
feat(share): add fixed-height note card for the QR screen
feat(share): add display-only QR screen for notes
feat(share): register the ShareNoteAsQr destination
feat(share): add Share as QR to the note share sheet
fix(share): gate NSFW thumbnail with ContentWarningGate, prefer article title
fix(share): size the QR from available width, add a11y description
fix(share): add QR a11y strings, document QR row in ShareActionRows KDoc
fix(share): compact permanently-covered NSFW thumbnail, fix inert QR width cap
fix(share-qr): close sensitivity gate leaks and text/thumbnail bugs on the share-as-QR card
fix(share-qr): fix stale payload, unreachable controls, missing back button, and screen-wake handling
fix(share-qr): show image thumbnail for image-only notes, hide raw media URL
2026-07-21 14:29:49 +01:00
Vitor Pamplona
5e5ecdab8b Merge PR: NIP-34 git parity for amy + quartz interop fixes
Merges nostr proposal 2ca4f8ae into main:
- feat(cli): full NIP-34 git collaboration parity for `amy git`
- feat(cli): `amy git grasp list|set` (NIP-34 GRASP server list, kind 10317)
- feat(cli): `amy git browse|cat|log` — read git objects over smart-HTTP
- feat(cli): `amy git init` — bootstrap a repo from the local git checkout
- feat(cli): `amy git label` (NIP-32) and `amy git apply` (patch -> working tree)
- fix(quartz): NIP-34 wire-format interop with ngit (clone/web, issue p, plain r)
- test(cli): live interop check against the real ngit-published amethyst repo
- fix(cli): shallow-clone euc, read truncation, process deadlock
- fix(cli): read routing, status perf, publish-ack + robustness
- fix: complete PR clone multi-value (kinds 1618/1620) + CLI robustness
- revert(amethyst): drop GitStatusIndex auth change; defer to separate proposal

clone/web now serialize as single multi-value tags per NIP-34 (ngit drops
repeated ones) for kinds 30617, 1618 and 1620; readers stay tolerant of the
legacy repeated form.

The GitStatusIndex status-authority guard was reverted before merge: it
authorized against the status event's own `a` tag, which the author controls,
so a forged ["a","30617:<attacker>:x"] defeated it — and unverifiable statuses
were dropped permanently (no re-reduce when the 30617 announcement arrives),
rendering closed issues as open. Refiling as its own proposal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 23:54:49 -04:00
Claude
062ab0f012 revert(amethyst): drop GitStatusIndex auth change; defer to separate proposal
The isAuthoritative guard added to GitStatusIndex read the repository owner
from the status event's own `a` tag (GitStatusEvent.repositoryAddress() ->
first a tag, no kind filter, no cross-check against the target's repo). That
value is attacker-controlled: a forged kind:1632 carrying
`["a", "30617:<attacker-pubkey>:anything"]` makes `status.pubKey ==
repoAddress.pubKeyHex` pass, so the spoof it meant to block still succeeds.

It also regressed reads: reduceLatestByTarget only re-runs on a new
kind 1630-1633, so a status dropped while the 30617 was uncached stayed
dropped, leaving genuinely-closed items in the Open tab with wrong counts.

Keep this series focused on the quartz + cli NIP-34 parity work. The Android
status-authorization hardening (resolve the repo from the target item, load
the cached 30617, authorize against repo.pubKey + maintainers() + the target
author, and re-reduce when a 30617 arrives) will land as its own proposal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:38:20 +00:00
Claude
5f5356c0fe fix: complete PR clone multi-value, git-status auth spoofing, CLI robustness
Addresses review findings from a merge-time audit.

- **Complete the headline multi-value `clone` fix for PRs** (was applied only to
  kind:30617). GitPullRequestEvent (1618) and GitPullRequestUpdateEvent (1619)
  carry `clone` with the same spec shape but still emitted repeated single-value
  tags and read only the first value — so the exact interop bug this branch set
  out to kill was still live for PRs, both directions (ngit keeps only the last
  repeated tag; we lost every URL after the first from ngit's multi-value tag).
  Now both emit one multi-value `["clone", …]` tag and read both forms. Verified
  on the wire + GitNip34InteropTest + CLI harness (40 checks).

- **Android git-status spoofing (GitStatusIndex)**: newest-status-wins with no
  author check meant anyone could publish a kind-1632 and make someone else's
  issue render closed. Now filter statuses to the repository owner (from the
  status's own `a` tag), declared maintainers (from the cached announcement), or
  the target item's author — matching NIP-34 and the CLI's derivation. Pre-existing
  on main; this branch made the CLI/Android divergence visible.

- **CLI robustness**: `git comment`/`git patch` no longer block forever reading
  stdin on an interactive TTY (amy is non-interactive — error instead). The local
  `git` subprocesses in `git init`/`git apply` now drain stdout on a side thread
  under a bounded `waitFor` + `destroyForcibly`, so a wedged git can't hang the
  CLI.

Left as a follow-up (cosmetic): GitBrowseCommands.candidateUrls duplicates
GitRepositoryBrowserViewModel's — worth lifting to shared code, not worth the
cross-module coupling here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:58 +00:00
Claude
4e0ac212ee fix(cli): second audit pass — read routing, status perf, publish-ack + robustness
Findings from a second review round (two independent reviewers), with fixes:

Read path (git issues/patches/prs/thread):
- **Reads ignored the repo's own relays** (correctness). They queried only the
  account outbox/bootstrap (general relays); NIP-34 events live on the repo
  announcement's advertised relays (often GRASP/git-specific), which general
  relays don't mirror — so `amy git issues <repo>` with no --relay could return
  empty. Now fetch the announcement once and read from queryTargets ∪ its
  advertised `relays`. Verified live: `git issues`/`git prs` on the amethyst
  repo now return real events (and derive `closed`) with NO --relay.
- **O(items × statuses) status rescan** with un-memoized `rootEventId()` reparse
  → pre-group statuses by root id once (O(1) lookup per item).
- **Status query could truncate / exceed relay caps**: statuses are now paged
  (`drainAllPages`) and the `#e` id set is chunked to 50 (under the common
  ~100-value relay filter cap).
- **Latency regression**: capped the list `drainAllPages` idle timeout to 12s
  (was the 30s default; `drain` had been 8s).
- **Nondeterministic status on same-second ties** → deterministic id tie-break.
- Reuse the fetched repo for the maintainer set (removes a redundant round-trip).

Write path:
- **`git init` silently reported success when the 30618 state publish failed**
  — its ack was dropped. Now surfaced as `state_published_to`/`state_rejected_by`
  with a stderr warning on total rejection.
- **`git apply`** feeds stdin as UTF-8 (was JVM default charset — corrupted
  non-ASCII patches) and joins the stdin thread in `finally` (no leak on error).
- **`normalizeCloneUrl`** drops the port from `ssh://git@host:port/…` (it was
  carried into the https URL, making it unreachable).
- **Delivery fallback** to the account outbox (repo unresolved / no advertised
  relays) now warns to stderr instead of reporting silent success.

Known limitation (documented, not fixed): patch-revision-chain status derivation
follows only the root item, and `git thread` shows first-level replies only
(nested trees and 1619 PR-updates are out of scope). 38/38 harness green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:58 +00:00
Claude
63d01c9287 fix(cli): audit fixes — shallow-clone euc, read truncation, process deadlock
Findings from a review pass over the git-parity branch, with fixes:

- **`git init` announced a WRONG earliest-unique-commit on shallow clones**
  (interop-critical). `git rev-list --max-parents=0 HEAD` returns the shallow
  boundary commits, not the true root, so the repo would be announced under a
  different cross-fork identity than ngit computes. Now: detect shallow clones
  and omit the euc with a warning to pass `--earliest-commit`; on full clones
  derive the deterministic `--first-parent` mainline root instead of an
  arbitrary `tail -1`.

- **`git issues|patches|prs` silently truncated and mis-derived status** on
  active repos: one single-page `drain` pulled items AND status events under a
  shared cap, so status events (newer, more numerous) could crowd items out of
  the window and the close-status that determines an item's state could fall
  outside it → a closed item read as open. Now paginate the items
  (`drainAllPages`) and fetch exactly the statuses that `e`-reference them.
  Verified on the live amethyst repo: 51 PRs paginated, 19 correctly closed.

- **Pipe-buffer deadlocks** (latent): `GitInitCommand.git()` discards stderr to
  the OS (a chatty command can no longer fill its stderr pipe and hang the
  stdout read); `GitApplyCommand.runGit()` writes stdin on a background thread
  while draining stdout, so a patch larger than the pipe buffer can't deadlock.

- Minor: `git cat` binary detection uses an index loop instead of boxing 8000
  bytes; `GitRepositoryEvent.clones()/webs()` dedupe.

The harness `git init` test now runs against a fresh full checkout (this repo's
CI checkout is shallow) and adds a shallow-clone case asserting the euc is
omitted. 38/38.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:58 +00:00
Claude
fa32ea50a3 test(cli): live interop check against the real ngit-published amethyst repo
Adds a `--live` block that reads the actual amethyst repository ngit publishes
to relay.ngit.dev and asserts our reader parses ngit's real multi-value `clone`
tag (currently 4 URLs) plus its published issues. This is the real-world proof
of the multi-value interop fix: the pre-fix reader would have surfaced only the
first clone URL. Opt-in (needs network + the live relay), skipped by default.

Verified manually end-to-end against the live repo: repo announcement (4 clone
URLs), issues (1621), patches (1617), pull requests (1618, with a real `closed`
status derived from ngit's status event), and a NIP-22 comment via `git thread`
all read correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:58 +00:00
Claude
1a77c95531 fix(quartz): NIP-34 wire-format interop with ngit (clone/web, issue p, plain r)
Verified Amethyst's NIP-34 events byte-for-byte against the ngit reference
implementation (DanConwayDev/ngit-cli) and the spec, and fixed three real
interop divergences in quartz — so ngit/gitworkshop and Amethyst read each
other's git repos, issues, patches, and PRs without losing data.

- Repository announcement `clone`/`web` were emitted as REPEATED single-value
  tags (`["clone", a]`, `["clone", b]`). The spec and ngit use ONE multi-value
  tag (`["clone", a, b]`), and ngit's parser keeps only the LAST of repeated
  known tags — so multi-URL repos silently lost every URL but one in both
  directions. Now emitted as a single multi-value tag; `clones()`/`webs()` read
  BOTH the spec form and the legacy repeated form, so old events still parse.
  (`relays`/`maintainers` were already correct multi-value tags.)

- Issues (kind 1621) were missing the `["p", <repo-owner>]` tag that patches and
  PRs already include — a maintainer watching `#p` wouldn't see them. The
  builder now adds it (fixes both the CLI and the Android issue-creation path,
  which both passed an empty notify list).

- Patch / PR / PR-update `r` tags carried the `"euc"` marker
  (`["r", commit, "euc"]`). Per the spec and ngit that marker belongs only on
  the kind-30617 announcement; other `r` tags are plain `["r", commit]`. A `#r`
  filter matches either shape, so this is a spec-compliance/byte-parity fix.

`alt` (NIP-31) tags are intentionally still omitted — quartz treats the generic
alt client-hint as deprecated, and ngit/gitworkshop parse the structured tags,
so it isn't required for interop.

Adds `GitNip34InteropTest` (5 cases: multi-value write, tolerant read of both
forms, issue p-tag, plain patch r-tag) and 4 wire-format assertions to the CLI
git harness (37 offline). No regressions in the nip34 or Search suites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:58 +00:00
Claude
d3e208322c feat(cli): add amy git label (NIP-32) and amy git apply (patch → working tree)
Close two more ngit/nak parity gaps:

- `git label TARGET LABEL[,LABEL]` — attach NIP-32 kind:1985 labels to an
  issue/patch/PR (the `ngit pr label` / `issue label` surface), over quartz's
  existing `LabelEvent`. Namespace defaults to `ugc`; `--namespace` overrides.
- `git apply PATCH_ID` — fetch a kind:1617 patch and apply it to the local
  working tree via `git am` (the `nak git patch apply` / `ngit pr apply`
  surface); `--check` dry-runs `git apply --check`, `--print` emits the patch.
  Shells out to `git` like `git init`, since it operates on the local checkout.

Verified end-to-end: a patch published to a relay, fetched, and `git am`'d as a
real commit into a scratch repo; labels land as kind 1985. The harness gains 5
assertions (label + a full publish→apply round-trip), now 33 offline.

Remaining out-of-scope items are documented: git-packfile push (needs a git
write layer quartz lacks) and NIP-34 cover notes (kind 1624, no quartz builder).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:58 +00:00
Claude
b06184ac49 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
2026-07-21 03:37:58 +00:00
Claude
45d33c016e feat(cli): add amy git browse|cat|log — read git objects over smart-HTTP
Give `amy git` the git-object read side of `nak git download` / a shallow
clone. `browse` lists a repo's tree, `cat` prints (or `--out` writes) a file at
a ref, and `log` shows recent commit history — all over the git smart-HTTP v2
protocol via quartz's `GitHttpClient` (the same shallow-clone path the Android
repo browser uses). REPO may be a NIP-34 coordinate/naddr (whose announcement
supplies the clone URL) or a raw http(s) clone URL; `--clone` and `--ref`
override the URL and branch/tag.

Read-only: pushing git objects back to clone/GRASP servers stays out of scope.

Verified live against a public repo (octocat/Hello-World) — browse/cat/log all
return correct trees, blobs, and history. The harness gains a `--live` block
(28 assertions with `--live`, 24 in the default offline run) exercising these
against `$LIVE_REPO`, skipped by default since it needs a reachable git host.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:57 +00:00
Claude
9922eef9f7 feat(cli): add amy git grasp list|set (NIP-34 GRASP server list, kind 10317)
Declare/read a user's preferred GRASP (Git-over-Nostr hosting) servers in
preference order — the NIP-65-style list `ngit`/`nak git` consult to decide
where PR tip branches (`refs/nostr/<pr-id>`) get pushed. `set` publishes a
kind:10317 to the outbox; `list` reads it back cache-first (anonymous-capable).
Thin assembly over quartz `UserGraspListEvent`. The git push itself stays out
of scope, as with the rest of the packfile transport.

Extends the git NIP-34 harness with a grasp round-trip (24 assertions) and
updates the README/ROADMAP/help tables.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:57 +00:00
Claude
ad66e6c224 feat(cli): full NIP-34 git collaboration parity for amy git
Extend `amy git` from repo announce/list/show/issue to the complete
pure-Nostr surface of `ngit` and `nak git`, so every NIP-34 collaboration
flow is scriptable without a GUI.

New sub-verbs (all thin assembly over quartz's `nip34Git` builders):

- `git state`   — kind:30618 repository state (branch/tag tips + HEAD)
- `git patch`   — kind:1617 patch from `git format-patch` (--file or stdin),
                  with --root/--root-revision, --commit, --parent-commit,
                  and --in-reply-to for revision chains
- `git pr` / `git pr-update` — kind:1618 pull request + kind:1619 tip update
- `git comment` — NIP-22 kind:1111 reply on an issue/patch/PR/repo (the
                  modern replacement for the deprecated kind:1622 git reply)
- `git open|applied|close|draft` — kind:1630/1631/1632/1633 status events
                  (aliases `merged`/`resolved` for applied); applied carries
                  --merge-commit / --commit / --patch
- `git issues|patches|prs` — list a repo's items with status derived from the
                  newest authoritative (owner/maintainer/author) status event,
                  with --open/--applied/--closed/--draft/--status filters
- `git thread`  — one item plus its status timeline and comments

Shared parsing/fetch/routing glue lives in `GitSupport`; the existing
announce/list/show/issue verbs now reuse it. The git *packfile* transport
(clone/fetch/push of real objects to clone/GRASP servers) stays out of
scope — it needs a git plumbing layer, not an event builder — and is
documented as such.

Adds `cli/tests/git/git-nip34-headless.sh` (21 assertions, drives the whole
flow against `amy serve` and checks the status-deriving reads) and updates
the README/ROADMAP command tables.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:57 +00:00
Vitor Pamplona
da451145af Merge pull request #3653 from vitorpamplona/claude/notifications-pagination-n0qahe
Add infinite-scroll backward pagination for notification history
2026-07-20 20:10:05 -04:00
60 changed files with 4568 additions and 267 deletions

View File

@@ -567,6 +567,7 @@ dependencies {
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.secp256k1.kmp.jni.jvm)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.junit)

View File

@@ -79,6 +79,7 @@ import com.vitorpamplona.amethyst.ui.note.UpdateReactionTypeScreen
import com.vitorpamplona.amethyst.ui.note.nip22Comments.ReplyCommentPostScreen
import com.vitorpamplona.amethyst.ui.note.share.ShareNoteAsImageFileScreen
import com.vitorpamplona.amethyst.ui.note.share.ShareNoteAsImageScreen
import com.vitorpamplona.amethyst.ui.note.share.ShareNoteAsQrScreen
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawerLayout
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -583,6 +584,7 @@ fun BuildNavigation(
composableFromEndArgs<Route.Note> { ThreadScreen(it.id, accountViewModel, nav) }
composableFromEndArgs<Route.ShareNoteAsImage> { ShareNoteAsImageScreen(it.id, accountViewModel, nav) }
composableFromEndArgs<Route.ShareNoteAsImageFile> { ShareNoteAsImageFileScreen(it.id, accountViewModel, nav) }
composableFromEndArgs<Route.ShareNoteAsQr> { ShareNoteAsQrScreen(it.id, accountViewModel, nav) }
composableFromEndArgs<Route.ContactListUsers> { ContactListUsersScreen(it.noteId, accountViewModel, nav) }
composableFromEndArgs<Route.Hashtag> { HashtagScreen(it, accountViewModel, nav) }
composableFromEndArgs<Route.Geohash> { GeoHashScreen(it, accountViewModel, nav) }

View File

@@ -507,6 +507,10 @@ sealed class Route {
val id: String,
) : Route()
@Serializable data class ShareNoteAsQr(
val id: String,
) : Route()
@Serializable data class ContactListUsers(
val noteId: String,
) : Route()

View File

@@ -38,8 +38,9 @@ import com.vitorpamplona.amethyst.ui.stringRes
* both from the reaction-row Share button and from the note's 3-dot menu).
*
* Only the true "send it somewhere" options live here — browser link, image
* file, image URL. The copy-to-clipboard options stay in the 3-dot menu, so
* they are intentionally NOT part of this shared element.
* file, image URL, and the display-only QR code. The copy-to-clipboard
* options stay in the 3-dot menu, so they are intentionally NOT part of this
* shared element.
*
* Callers only render these for non-private notes: every option exposes the
* note publicly (a shareable web link, or an image of it), which must never
@@ -76,4 +77,8 @@ fun ShareActionRows(
nav.nav(Route.ShareNoteAsImage(shareId))
onDismiss()
}
M3ActionRow(icon = MaterialSymbols.QrCode2, text = stringRes(R.string.share_as_qr)) {
nav.nav(Route.ShareNoteAsQr(shareId))
onDismiss()
}
}

View File

@@ -0,0 +1,54 @@
/*
* 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.ui.note.share
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.ui.note.externalLinkForNote
/** Which of the two payloads the QR code currently encodes. */
enum class QrPayloadMode {
/**
* An `https://njump.to/…` link. The default, because a stock phone camera will offer to
* open an http(s) URL but may treat a bare custom scheme as inert text.
*/
Web,
/**
* A `nostr:nevent1…` / `nostr:naddr1…` URI. Resolves without a web round-trip and is what
* in-app scanners expect.
*/
Nostr,
}
/**
* The string encoded into the QR code for [note] in [mode].
*
* Both payloads carry the note's relay hint: [externalLinkForNote] builds its URL from
* `toNAddr()` / `toNEvent()`, which already call `relayHintUrl()`.
*/
fun qrPayloadFor(
note: Note,
mode: QrPayloadMode,
): String =
when (mode) {
QrPayloadMode.Web -> externalLinkForNote(note)
QrPayloadMode.Nostr -> note.toNostrUri()
}

View File

@@ -0,0 +1,258 @@
/*
* 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.ui.note.share
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.components.getActivityWindow
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.amethyst.ui.stringRes
// A cap, not a fixed size: QrCodeDrawer's own quiet zone (QR_MARGIN_PX in QrCodeDrawer.kt) is a
// fixed pixel count subtracted from raw size.width, so its share of the tile grows as density
// falls. Hard-sizing this call to a small dp value starved long-form naddr payloads of scannable
// resolution on low-density screens. Deriving the size from the available column width keeps
// enough real pixels per module; this only bounds it from growing unreasonably large on tablets.
private val QrMaxSize = 320.dp
/**
* Display-only screen presenting [id]'s note as a scannable QR code.
*
* There is no export or save action by design — the screen exists to be held up and
* photographed by another device.
*
* F6: the Scaffold (and its back button) lives in this id-based wrapper, OUTSIDE LoadNote's
* null check, so an id that never resolves to a note still leaves the user a way back — only
* the body inside is empty in that case. Rendering nothing else for an unresolved id is
* deliberate, matching ShareNoteAsImageScreen; only the missing chrome was the bug.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ShareNoteAsQrScreen(
id: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
Scaffold(
topBar = { TopBarWithBackButton(stringRes(R.string.share_as_qr), nav) },
) { pad ->
LoadNote(id, accountViewModel) { note ->
if (note != null) {
ShareNoteAsQrScreenContent(note, accountViewModel, nav, pad)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ShareNoteAsQrScreen(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
Scaffold(
topBar = { TopBarWithBackButton(stringRes(R.string.share_as_qr), nav) },
) { pad ->
ShareNoteAsQrScreenContent(note, accountViewModel, nav, pad)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ShareNoteAsQrScreenContent(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
pad: PaddingValues,
) {
var mode by remember { mutableStateOf(QrPayloadMode.Web) }
// F4: keyed on the observed note state, not on `note` alone (a stable object identity that
// does not change while the event and the author's relay list are still loading). A payload
// computed before then would omit the relay hint (Note.relayHintUrl()) and never recompute.
// Keying on `noteState` re-derives the payload once the event arrives — the same observation
// SharedNoteCard uses for its own sensitivity gate (F1).
val noteState by observeNote(note, accountViewModel)
val payload = remember(noteState, mode) { qrPayloadFor(note, mode) }
KeepScreenBrightAndAwake()
// F5: scrollable so the toggle and hint — the screen's only controls — stay reachable on a
// short viewport (landscape, split screen, large font scale) where the square QR plus the
// card above it can otherwise exceed the available height. A plain fillMaxSize() Column would
// silently place that overflow outside its bounds instead of clipping or scrolling to it.
Column(
modifier =
Modifier
.padding(pad)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
verticalArrangement = Arrangement.spacedBy(20.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
SharedNoteCard(note, accountViewModel, nav)
val qrContentDescription =
when (mode) {
QrPayloadMode.Web -> stringRes(R.string.share_as_qr_code_description_web)
QrPayloadMode.Nostr -> stringRes(R.string.share_as_qr_code_description_nostr)
}
QrCodeDrawer(
contents = payload,
modifier =
Modifier
.widthIn(max = QrMaxSize)
.fillMaxWidth()
.semantics { contentDescription = qrContentDescription },
)
// Fill the width so each button gets an equal, generous half (a bare
// SingleChoiceSegmentedButtonRow shrinks to content and clips longer labels), and pass an
// empty `icon` so the selected-state checkmark never steals horizontal room from the
// label — selection is already signalled by the fill colour. Both matter for translated
// labels, which are often longer than the English "Web link" / "Nostr link".
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
val modes = listOf(QrPayloadMode.Web, QrPayloadMode.Nostr)
modes.forEachIndexed { index, candidate ->
SegmentedButton(
selected = mode == candidate,
onClick = { mode = candidate },
shape = SegmentedButtonDefaults.itemShape(index = index, count = modes.size),
icon = {},
) {
Text(
text =
when (candidate) {
QrPayloadMode.Web -> stringRes(R.string.share_as_qr_mode_web)
QrPayloadMode.Nostr -> stringRes(R.string.share_as_qr_mode_nostr)
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
Text(
text =
when (mode) {
QrPayloadMode.Web -> stringRes(R.string.share_as_qr_hint_web)
QrPayloadMode.Nostr -> stringRes(R.string.share_as_qr_hint_nostr)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
/**
* Raises the screen to full brightness and prevents it sleeping while the QR is displayed,
* restoring both on exit.
*
* This is functional, not polish: the screen exists to be photographed, and a dark-theme phone
* on auto-brightness in a dim room is exactly the case that fails.
*
* Residual hazard, not reachable today: [LocalView.current] is the Activity's single shared root
* `ComposeView`, not a view scoped to this screen. During a nav transition two compositions can
* briefly coexist on that same root view, so this screen's `onDispose` could in theory clobber
* brightness/`keepScreenOn` state an incoming screen has already set. Nothing in the current nav
* graph triggers that overlap, so this is left as a comment rather than code.
*/
@Composable
private fun KeepScreenBrightAndAwake() {
val view = LocalView.current
// NOT `(view.context as? Activity)`: under Compose the context is routinely a
// ContextThemeWrapper, so that cast silently yields null and brightness never changes —
// no crash, no log, just a dead feature. getActivityWindow() unwraps the ContextWrapper
// chain (WindowUtils.kt:39-46).
val window = getActivityWindow()
DisposableEffect(window, view) {
// Capture the RAW attribute, not a computed fraction. When no override is set this is
// BRIGHTNESS_OVERRIDE_NONE (-1f), and restoring that value returns the device to auto
// brightness. Restoring a *computed* fraction would install an override where none
// existed and silently disable auto-brightness for the rest of the session.
val previousBrightness = window?.attributes?.screenBrightness
// F8: same capture/replay discipline as brightness above, and for the same reason.
// `view` is the Activity's single shared root ComposeView, and PlayerEventListener
// (ControlWhenPlayerIsActive.kt:150-165) owns this exact flag while media plays.
// Hard-setting `false` on dispose — instead of restoring what was here before this
// screen took it over — would clobber that ownership: navigating back from the QR
// screen while audio or video is still playing would let the screen sleep mid-playback.
val previousKeepScreenOn = view.keepScreenOn
window?.let {
it.attributes = it.attributes.apply { screenBrightness = 1f }
}
view.keepScreenOn = true
onDispose {
// Restore the captured value rather than calling a release helper: resetting to
// BRIGHTNESS_OVERRIDE_NONE unconditionally would clobber an override the user
// already had, e.g. one left by the fullscreen video controls.
window?.let { w ->
previousBrightness?.let { prev ->
w.attributes = w.attributes.apply { screenBrightness = prev }
}
}
view.keepScreenOn = previousKeepScreenOn
}
}
}

View File

@@ -0,0 +1,320 @@
/*
* 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.ui.note.share
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.ui.components.collectContentWarningReasons
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.GalleryThumbnail
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.UrlImageView
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoEvent
import com.vitorpamplona.quartz.nip71Video.alt
import com.vitorpamplona.quartz.nip71Video.blurhash
import com.vitorpamplona.quartz.nip71Video.mimeType
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
import com.vitorpamplona.quartz.nip92IMeta.imetas
private val CardHeight = 72.dp
private val ThumbSize = 56.dp
private val ThumbShape = RoundedCornerShape(9.dp)
/**
* A fixed-height preview of the note being shared, shown above the QR code.
*
* The height is fixed on purpose: it keeps the QR code in the same screen position for every
* note, so the screen is predictable to hold up to a scanner. That is why this does not use
* [com.vitorpamplona.amethyst.ui.note.NoteCompose] — see the design spec for the full reasoning,
* but in short, `isQuotedNote` never reaches the media renderer and note images render at their
* natural aspect ratio with no height ceiling.
*/
@Composable
fun SharedNoteCard(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.fillMaxWidth().height(CardHeight).padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
// Plain SensitivityWarning is NOT enough here: it gates on event.isSensitiveOrNSFW(),
// which only reads the note-level content-warning tag / nsfw hashtag. GalleryThumbnail's
// inner gate (GalleryThumb.kt:236) reads each media entry's per-imeta `contentWarning`,
// but none of GalleryThumb.kt's four media-construction sites ever set that field, so it
// is always null and that inner gate is permanently dead on this path. A note whose only
// warning lives inside an `imeta` tag (e.g. a kind 20 PictureEvent) would then render
// completely unblurred. `hasImetaContentWarning` below checks every imeta for the mere
// PRESENCE of a `content-warning` key, not for non-blank reason text: an imeta warning
// with an empty reason string (`["content-warning"]` with no second element) still counts
// — collectContentWarningReasons()'s `takeIf { it.isNotBlank() }` would silently drop that
// exact case, which is what let it slip the gate before. collectContentWarningReasons()
// is still called for the human-readable *reason text*, shown in the covered box's
// accessibility label when one exists — it never drives the show/hide decision.
//
// `note.event` is a plain field read that never recomposes if this composable is
// rendered before the note's event has arrived over the relay (id-only reference, e.g.
// straight off a deep link). observeNote() subscribes both the relay finder and the
// LocalCache flow, so `event` below updates — and this whole gate recomputes — the
// moment the event loads or changes, the same idiom GalleryThumbnail itself already uses
// (GalleryThumb.kt:78).
//
// This deliberately does NOT use the shared ContentWarningGate: its overlay
// (ContentWarningOverlayBody, SensitivityWarning.kt:254+) opens with an 80.dp icon box
// that consumes this card's entire 56.dp thumbnail height, pushing the title, reasons,
// and "Show anyway" button below the clipped, tappable area. Reshaping that shared
// composable was rejected — six other screens depend on its current layout — so this
// call site renders its own compact, permanently-covered state instead: no reveal
// affordance, because this screen is held up in public and pointed at someone else's
// camera. `accountViewModel.showSensitiveContent()` is still honoured exactly as
// ContentWarningGate honours it (SensitivityWarning.kt:138-140), so a user who has
// opted into seeing sensitive content globally sees the real thumbnail here too. Either
// way the thumbnail box stays a fixed 56.dp, keeping this Row's height fixed at 72.dp.
//
// `nav` is passed because GalleryThumbnail's signature demands it, but it is unused
// there (GalleryThumb.kt:76) — navigation comes from ClickableNote at its other call
// site. This card is not tappable.
val noteState by observeNote(note, accountViewModel)
val event = noteState.note.event
val reasons = remember(event) { event?.let { collectContentWarningReasons(it) } ?: emptySet() }
val hasImetaContentWarning =
remember(event) {
event?.imetas()?.any { it.properties.containsKey(ContentWarningTag.TAG_NAME) } ?: false
}
val isSensitive =
remember(event, hasImetaContentWarning) {
event != null && (event.isSensitiveOrNSFW() || hasImetaContentWarning)
}
val showSensitiveContent by accountViewModel.showSensitiveContent().collectAsStateWithLifecycle()
val isGated = isSensitive && showSensitiveContent != true
// Thumbnail source, in priority order:
// 1. structured media event (kind 20/21/22, gallery, live clip) -> GalleryThumbnail;
// 2. an image carried in an `imeta` tag of an otherwise-unstructured note (a kind 1
// image post — content is typically just the media URL) -> render that image;
// 3. no media at all -> the author's round AVATAR, which (unlike GalleryThumbnail's own
// DisplayGalleryAuthorBanner fallback, a banner crop) cannot be mistaken for the
// note's own picture (F7).
// hasStructuredMedia() mirrors GalleryThumbnail's own per-kind checks (GalleryThumb.kt:
// 82-186); contentImage covers the case GalleryThumbnail does NOT handle — a bare image
// URL in an imeta on a kind 1 — so those posts show the picture instead of avatar + URL.
val hasStructuredMedia = remember(event) { event != null && hasStructuredMedia(event) }
val contentImage = remember(event) { event?.let { firstContentImage(it) } }
Box(Modifier.size(ThumbSize).clip(ThumbShape)) {
if (isGated) {
Box(
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
Icon(
symbol = MaterialSymbols.Warning,
contentDescription =
reasons.firstOrNull()?.let { stringRes(R.string.content_warning_with_reason, it) }
?: stringRes(R.string.share_as_qr_thumbnail_hidden_sensitive),
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else if (hasStructuredMedia) {
GalleryThumbnail(note, accountViewModel, nav)
} else if (contentImage != null) {
// UrlImageView crops to fill (ContentScale.Crop) and honours the account's
// show-images setting and blossom bridge on its own; the enclosing 56.dp Box
// bounds it so the card height stays fixed. No extra SensitivityWarning: the
// isGated branch above already covered the sensitive case.
UrlImageView(contentImage, accountViewModel)
} else {
NoteAuthorPicture(note, ThumbSize, accountViewModel)
}
}
Column(Modifier.weight(1f)) {
Text(
text = note.author?.toBestDisplayName() ?: "",
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = secondaryLineFor(event, isGated, contentImage != null),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
/**
* Whether [event] carries the kind of structured media [GalleryThumbnail] would render (a
* profile-gallery entry, kind 20 picture, kind 21/22 video, or live-activity clip with a video
* URL) — used only to pick between the media thumbnail and the author-avatar fallback (F7).
* Mirrors GalleryThumbnail's own when-branch conditions (GalleryThumb.kt:82-186) rather than
* duplicating its full [com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent] construction.
*/
private fun hasStructuredMedia(event: Event): Boolean =
when (event) {
is ProfileGalleryEntryEvent -> event.urls().isNotEmpty()
is PictureEvent -> event.imetaTags().isNotEmpty()
is VideoEvent -> event.imetaTags().isNotEmpty()
is LiveActivitiesClipEvent -> event.videoUrl() != null
else -> false
}
/** True when this `imeta` describes an image — by declared mime type, or failing that its URL. */
internal fun IMetaTag.isImage(): Boolean {
val mime = mimeType()?.firstOrNull()
return if (mime != null) mime.startsWith("image/") else RichTextParser.isImageUrl(url)
}
/**
* The first image carried in an [event]'s `imeta` tags, as a renderable [MediaUrlImage], or null
* if the note has none. Covers the common kind-1 image post whose content is just a media URL —
* a case [GalleryThumbnail] does not handle (its when-branches fall through to the author banner).
* Only images are returned; a video-only imeta yields null and the card falls back to the avatar
* rather than feeding a video URL to the image loader.
*/
internal fun firstContentImage(event: Event): MediaUrlImage? {
val imeta = event.imetas().firstOrNull { it.isImage() } ?: return null
return MediaUrlImage(
url = imeta.url,
description = imeta.alt()?.firstOrNull(),
blurhash = imeta.blurhash()?.firstOrNull(),
mimeType = imeta.mimeType()?.firstOrNull(),
)
}
/**
* The one-or-two-line description under the author name: an article's title, else the note's own
* text (with any embedded media URLs stripped), else the image's alt text, else a kind label.
*
* F3: when [isGated] (the same sensitive-and-not-opted-in decision computed for the thumbnail,
* passed in rather than recomputed) title, content and alt text are all skipped in favor of the
* neutral kind label — otherwise this line would render the sensitive note's own text unblurred
* right next to the covered thumbnail, which is exactly what `Text.kt`'s `SensitivityWarning`
* wrapping exists to prevent for note bodies elsewhere in the app. The author name stays visible
* either way; only this line is affected.
*
* [hasContentImage] lets an image-only post whose text and alt are both empty fall back to a
* "Picture" label rather than a blank line.
*/
@Composable
private fun secondaryLineFor(
event: Event?,
isGated: Boolean,
hasContentImage: Boolean,
): String {
if (event == null) return ""
// F10: remembered against (event, isGated) so a long article body is not re-trimmed on every
// recomposition — only when the underlying event or the gate decision actually changes.
val bodyText = remember(event, isGated) { secondaryBodyTextFor(event, isGated) }
if (bodyText != null) return bodyText
return when {
event is PictureEvent -> stringRes(R.string.share_as_qr_kind_picture)
event is VideoEvent -> stringRes(R.string.kind_video)
event is LongTextNoteEvent -> stringRes(R.string.article)
hasContentImage -> stringRes(R.string.share_as_qr_kind_picture)
else -> ""
}
}
// Plain (non-@Composable) so it can be wrapped in `remember` — `stringRes` calls, which the
// kind-label fallback needs, are not allowed inside a remember calculation lambda.
internal fun secondaryBodyTextFor(
event: Event,
isGated: Boolean,
): String? {
// Gated: never surface the note's own title, content or alt text, only the kind label above.
if (isGated) return null
if (event is LongTextNoteEvent) {
val title = event.title()
if (!title.isNullOrBlank()) return title
}
// An image-only post's content is typically just the media URL(s). Strip any that appear
// verbatim (only when the note actually has imeta media, so a plain article — no imeta — is
// never scanned) so the line does not show a bare CDN URL. If nothing meaningful remains,
// fall through to the alt text, then the kind label.
val mediaUrls = event.imetas().map { it.url }
val stripped =
if (mediaUrls.isEmpty()) {
event.content
} else {
mediaUrls.fold(event.content) { acc, url -> acc.replace(url, "") }
}
// F10: bounded prefix — this line only ever shows two lines of bodySmall text, so there is
// no need to trim() a full long-form article body (potentially tens of KB) to get there.
val content = stripped.take(MAX_SECONDARY_LINE_CHARS).trim()
if (content.isNotEmpty()) return content
return event
.imetas()
.firstOrNull { it.isImage() }
?.alt()
?.firstOrNull()
?.takeIf { it.isNotBlank() }
}
private const val MAX_SECONDARY_LINE_CHARS = 280

View File

@@ -531,6 +531,15 @@
<string name="share_as_image_url">Share as Image Url</string>
<string name="share_as_image_generating">Generating preview…</string>
<string name="share_as_image_watermark">Shared via Amethyst</string>
<string name="share_as_qr">Share as QR</string>
<string name="share_as_qr_mode_web">Web link</string>
<string name="share_as_qr_mode_nostr">Nostr link</string>
<string name="share_as_qr_hint_web">Scan with any phone camera</string>
<string name="share_as_qr_hint_nostr">Scan with a Nostr app</string>
<string name="share_as_qr_kind_picture">Picture</string>
<string name="share_as_qr_code_description_web">QR code containing a web link to this note</string>
<string name="share_as_qr_code_description_nostr">QR code containing a Nostr link to this note</string>
<string name="share_as_qr_thumbnail_hidden_sensitive">Thumbnail hidden for sensitive content</string>
<string name="quick_action_copy_user_id">Author ID</string>
<string name="quick_action_copy_note_id">Note ID</string>
<string name="quick_action_copy_text">Copy Text</string>

View File

@@ -0,0 +1,97 @@
/*
* 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.ui.note.share
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class QrPayloadTest {
private val alicePriv = "0000000000000000000000000000000000000000000000000000000000000007"
private val aliceSigner = NostrSignerInternal(KeyPair(alicePriv.hexToByteArray()))
private suspend fun textNote(): Note {
val event = TextNoteEvent.build("hello qr") {}.let { aliceSigner.sign(it) }
val note = Note(event.id)
note.event = event
return note
}
@Test
fun webMode_returnsAnHttpsNjumpLink() =
runTest {
val payload = qrPayloadFor(textNote(), QrPayloadMode.Web)
assertTrue(
"web mode must produce an https URL so a stock phone camera can action it, got: $payload",
payload.startsWith("https://"),
)
assertTrue(payload.contains("nevent1"))
}
@Test
fun nostrMode_returnsANostrUriWithNevent() =
runTest {
val payload = qrPayloadFor(textNote(), QrPayloadMode.Nostr)
assertTrue(payload.startsWith("nostr:nevent1"))
}
@Test
fun bothModes_encodeTheSameNote() =
runTest {
val note = textNote()
val web = qrPayloadFor(note, QrPayloadMode.Web)
val nostr = qrPayloadFor(note, QrPayloadMode.Nostr)
// The bech32 body must be identical; only the wrapper differs.
assertEquals(nostr.removePrefix("nostr:"), web.substringAfterLast('/'))
}
@Test
fun addressableNote_nostrMode_yieldsNaddrNotNevent() =
runTest {
// build(description, title, summary, image, publishedAt, dTag, createdAt, init)
// — `title` is a required positional; `dTag` must be named.
// LongTextNoteEvent.kt:148-157.
val event =
LongTextNoteEvent
.build("body", "My Article", dTag = "my-article") {}
.let { aliceSigner.sign(it) }
val note = AddressableNote(event.address())
note.event = event
val payload = qrPayloadFor(note, QrPayloadMode.Nostr)
assertTrue(
"AddressableNote must encode as naddr via the toNEvent() override, got: $payload",
payload.startsWith("nostr:naddr1"),
)
}
}

View File

@@ -0,0 +1,160 @@
/*
* 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.ui.note.share
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder
import com.vitorpamplona.quartz.nip92IMeta.imetas
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class SharedNoteCardTest {
private val alicePriv = "0000000000000000000000000000000000000000000000000000000000000007"
private val aliceSigner = NostrSignerInternal(KeyPair(alicePriv.hexToByteArray()))
private val blossomUrl = "https://blossom.primal.net/66332885d206714439e39e441b6539622a07c108"
private suspend fun kind1(
content: String,
imetaUrl: String? = null,
mime: String? = null,
alt: String? = null,
): Event =
TextNoteEvent
.build(content) {
if (imetaUrl != null) {
imetas(
listOf(
IMetaTagBuilder(imetaUrl)
.apply {
mime?.let { add("m", it) }
alt?.let { add("alt", it) }
}.build(),
),
)
}
}.let { aliceSigner.sign(it) }
// ---- firstContentImage ----
@Test
fun imageImeta_isPickedAsTheThumbnailImage() =
runTest {
val note = kind1(content = blossomUrl, imetaUrl = blossomUrl, mime = "image/jpeg")
val image = firstContentImage(note)
assertEquals(blossomUrl, image?.url)
}
@Test
fun plainTextNote_hasNoContentImage() =
runTest {
val note = kind1(content = "just some words, no media")
assertNull(firstContentImage(note))
}
@Test
fun videoImeta_isNotRenderedAsAnImage() =
runTest {
// A video-only imeta must not be fed to the image loader; the card falls back to the avatar.
val note = kind1(content = "clip", imetaUrl = "https://cdn.example/v", mime = "video/mp4")
assertNull(firstContentImage(note))
}
// ---- secondaryBodyTextFor ----
@Test
fun imageOnlyPost_stripsTheBareUrlToNothing() =
runTest {
// content is exactly the media URL — the whole point of the fix: never show the raw CDN URL.
val note = kind1(content = blossomUrl, imetaUrl = blossomUrl, mime = "image/jpeg")
assertNull(secondaryBodyTextFor(note, isGated = false))
}
@Test
fun imageOnlyPostWithAlt_fallsBackToAltText() =
runTest {
val note = kind1(content = blossomUrl, imetaUrl = blossomUrl, mime = "image/jpeg", alt = "A sunset")
assertEquals("A sunset", secondaryBodyTextFor(note, isGated = false))
}
@Test
fun textPlusImage_keepsTheTextWithoutTheUrl() =
runTest {
val note = kind1(content = "check this out $blossomUrl", imetaUrl = blossomUrl, mime = "image/jpeg")
assertEquals("check this out", secondaryBodyTextFor(note, isGated = false))
}
@Test
fun gatedNote_yieldsNoBodyText() =
runTest {
val note = kind1(content = "something sensitive", imetaUrl = blossomUrl, mime = "image/jpeg", alt = "nsfw")
assertNull(secondaryBodyTextFor(note, isGated = true))
}
@Test
fun article_prefersItsTitle() =
runTest {
val note =
LongTextNoteEvent
.build("the body", "My Article", dTag = "a1") {}
.let { aliceSigner.sign(it) }
assertEquals("My Article", secondaryBodyTextFor(note, isGated = false))
}
// ---- IMetaTag.isImage ----
@Test
fun isImage_trueForImageMime_falseForVideoMime() =
runTest {
val img = IMetaTagBuilder("https://x/y").add("m", "image/png").build()
val vid = IMetaTagBuilder("https://x/y").add("m", "video/mp4").build()
assertTrue(img.isImage())
assertTrue(!vid.isImage())
}
@Test
fun isImage_fallsBackToUrlExtensionWhenNoMime() =
runTest {
val withExt = IMetaTagBuilder("https://x/photo.jpg").build()
val extensionless = IMetaTagBuilder("https://blossom.example/deadbeef").build()
assertTrue(withExt.isImage())
assertTrue(!extensionless.isImage())
}
}

View File

@@ -307,14 +307,63 @@ default search relays.
### Git (NIP-34)
nak's `clone`/`push`/`pull` (git-packfile transport over relays/GRASP) are out of scope — these are the metadata + collaboration events.
`amy git` mirrors the pure-Nostr surface of [`ngit`](https://github.com/DanConwayDev/ngit-cli)
and `nak git`: repository announcements + state, patches, pull requests, issues,
threaded NIP-22 comments, and status updates. The git **packfile** transport
(`clone`/`fetch`/`push` of real git objects to clone/GRASP servers) is out of
scope — that needs a git plumbing layer, not an event builder — so `amy git`
publishes and reads the collaboration events, and you clone/push with `git`
itself (or `ngit`).
Every write verb accepts `[--relay URL[,URL]]` to override the target relays;
the default is the repo's advertised relays, else your outbox.
**Repository**
| 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] [--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). |
| `amy git issue NADDR\|coords --subject S [BODY] [--hashtag T[,T]]` | Publish a kind:1621 issue against a repo. BODY from arg or stdin. |
| `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 |
|---|---|
| `amy git issue REPO --subject S [BODY] [--hashtag T[,T]]` | Publish a kind:1621 issue. BODY from arg or stdin. |
| `amy git patch REPO [--file PATH] [--root\|--root-revision] [--commit C] [--parent-commit P] [--in-reply-to ID]` | Publish a kind:1617 patch. Body is `git format-patch` output from `--file` or stdin. |
| `amy git apply PATCH_ID [--check\|--print] [--repo PATH]` | Fetch a kind:1617 patch and apply it to the local working tree (`git am`); `--check` dry-runs, `--print` emits the patch. |
| `amy git pr REPO --commit TIP --clone URL[,URL] [--subject S] [--branch-name N] [--merge-base C] [--label L[,L]] [DESC]` | Publish a kind:1618 pull request (references a pushed branch tip by clone URL + commit). |
| `amy git pr-update PR --commit TIP --clone URL[,URL] [--merge-base C]` | Publish a kind:1619 update to a pull request's tip. |
| `amy git issues\|patches\|prs REPO [--open\|--applied\|--closed\|--draft\|--status a,b] [--limit N]` | List a repo's issues / patches / PRs with their derived status. |
| `amy git thread EVENT_ID` | Print one item plus its status timeline and comments. |
**Comments & status**
| Command | What it does |
|---|---|
| `amy git comment TARGET [BODY]` | Reply to an issue/patch/PR/repo with a NIP-22 kind:1111 comment. BODY from arg or stdin. |
| `amy git label TARGET LABEL[,LABEL] [--namespace N]` | Attach NIP-32 kind:1985 labels to an issue/patch/PR (namespace defaults to `ugc`). |
| `amy git open TARGET [MSG]` | Publish a kind:1630 status (open / reopen / ready-for-review). |
| `amy git applied TARGET [MSG] [--merge-commit C] [--commit C[,C]] [--patch ID[,ID]]` | Publish a kind:1631 status (applied / merged / resolved). Aliases: `merged`, `resolved`. |
| `amy git close TARGET [MSG]` | Publish a kind:1632 status (closed). |
| `amy git draft TARGET [MSG]` | Publish a kind:1633 status (draft). |
### Podcasts (NIP-F4)

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` | ✅ in part | NIP-34 repo announce/list/show/issue. clone/push (packfile transport) 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), NIP-32 labels (1985), status open/applied/closed/draft (1630-1633), GRASP server list (10317); `issues`/`patches`/`prs`/`thread` reads derive status; `apply` applies a fetched patch to the local tree (`git am`); `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) and NIP-34 cover notes (1624, no quartz builder yet) are out of scope. Event tag shapes were verified byte-for-byte against the ngit reference implementation and the NIP-34 spec (`clone`/`web` as single multi-value tags, issue `p`-tag for maintainer routing, plain patch/PR `r` tags); the quartz readers stay tolerant of the legacy repeated form. See `quartz/…/nip34Git/GitNip34InteropTest`. |
| `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`. |
@@ -129,8 +129,10 @@ nak has 34 functional commands (introspected from `nak --help`). Coverage:
Protocol-sensitive ones (`bunker`, `sync`, `key` NIP-49, `encode`/`decode`,
`admin`) are interop-verified against the real `nak` binary or `amy serve`.
- **Partial / adapted (3):** `key` (no `expand`/`combine`(MuSig2)/`default`),
`git` (NIP-34 events only — no packfile transport), `outbox` (NIP-65 model vs
nak's local hints DB).
`git` (full NIP-34 event surface — announce/state/patch/PR/issue/comment/status
+ 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).
@@ -173,10 +175,11 @@ move anything, re-audit — you're probably duplicating logic.
8. **Distribution** — Homebrew + Scoop + `.deb` in the same release
pipeline as desktop. Plan: `cli/plans/2026-04-21-cli-distribution.md`.
9. **Test suite** — largely in place, two layers:
- **Shell harnesses** under `cli/tests/`nine suites: `blossom`
(live servers), `cache`, `clink`, `dm`, `marmot` (vs whitenoise-rs),
`nests` (manual audio-rooms matrix), `pow`, `relaygroup`, `sync`,
plus the shared `headless/` helpers. See `cli/tests/README.md`.
- **Shell harnesses** under `cli/tests/`ten suites: `blossom`
(live servers), `cache`, `clink`, `dm`, `git` (NIP-34 vs `amy serve`),
`marmot` (vs whitenoise-rs), `nests` (manual audio-rooms matrix), `pow`,
`relaygroup`, `sync`, plus the shared `headless/` helpers. See
`cli/tests/README.md`.
None run in CI yet (the relay-backed ones need Rust + a ~3 min
cold `nostr-rs-relay` build).
- **JVM unit suite** at `cli/src/test/kotlin/``Args` parsing,

View File

@@ -624,14 +624,34 @@ 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[,]]
| [--hashtag T[,T]] [--earliest-commit C] [--d ID]
| git state REPO [--head B] [--branch n=c[,…]] publish a kind:30618 repository state
| [--tag n=c[,…]]
| git list [USER] list a user's repo announcements (default self)
| git show NADDR|kind:pubkey:id print one repo announcement
| git issue NADDR|coords --subject S [BODY] publish a kind:1621 issue against a repo
| [--hashtag T[,T]] [--relay URL[,URL]] (BODY from arg or stdin)
| 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)
| [--commit C] [--parent-commit P] [--in-reply-to ID]
| git pr REPO --commit TIP --clone URL[,URL] publish a kind:1618 pull request [DESC]
| [--subject S] [--branch-name N] [--merge-base C] [--label L[,L]]
| git pr-update PR --commit TIP --clone URL publish a kind:1619 pull-request update
| git comment TARGET [BODY] NIP-22 kind:1111 comment on issue/patch/PR/repo
| git label TARGET LABEL[,LABEL] NIP-32 kind:1985 labels on an issue/patch/PR
| git apply PATCH_ID [--check|--print] apply a fetched kind:1617 patch to the local tree
| git open|applied|close|draft TARGET [MSG] publish a kind:1630/1631/1632/1633 status
| git issues|patches|prs REPO list a repo's issues/patches/PRs + status
| [--open|--applied|--closed|--draft] [--limit N]
| git thread EVENT_ID print an item + status timeline + comments
| (git-packfile clone/push transport is out of scope — see cli/ROADMAP.md)
|
|Podcasts (NIP-F4):
| podcast metadata --title T --image URL publish kind:10154 show metadata

View File

@@ -0,0 +1,145 @@
/*
* 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.patch.GitPatchEvent
import java.io.File
import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread
/**
* `amy git apply PATCH_ID` — fetch a NIP-34 kind:1617 patch and apply it to the
* local git working tree (the `nak git patch apply` / `ngit pr apply` surface).
* The patch content is `git format-patch` output, so by default it is fed to
* `git am` (applied as a commit); `--check` dry-runs `git apply --check` and
* `--print` just emits the patch without touching the tree.
*
* This shells out to `git`, like `git init` — it operates on the local checkout.
*/
object GitApplyCommand {
/** Safety ceiling for the local `git am`/`git apply` invocation so a wedged git can't hang the CLI. */
private const val GIT_TIMEOUT_SEC = 60L
suspend fun apply(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val patchRef = args.positional(0, "patch-event-id")
val repoDir = File(args.flag("repo") ?: ".").absoluteFile
val check = args.bool("check")
val print = args.bool("print")
val id =
GitSupport.resolveEventId(patchRef)
?: return Output.error("bad_args", "expected a note/nevent/64-hex patch id")
args.rejectUnknown("relay")
Context.openOrAnonymous(dataDir).use { ctx ->
ctx.prepare()
val patch =
GitSupport.fetchEvent(ctx, id, args) as? GitPatchEvent
?: return Output.error("not_found", "no patch (kind 1617) found for $patchRef")
val content = patch.content
val subject = patch.subject()
if (print) {
Output.emit(mapOf("patch_id" to patch.id, "subject" to subject, "content" to content))
return 0
}
val (mode, gitArgs) =
if (check) {
"check" to arrayOf("apply", "--check", "-")
} else {
"am" to arrayOf("am", "--")
}
val (code, output) = runGit(repoDir, content, *gitArgs)
if (code != 0) {
// Leave the tree clean on a failed `git am` so a retry isn't blocked.
if (mode == "am") runGit(repoDir, null, "am", "--abort")
return Output.error(
"apply_failed",
"git $mode failed for patch ${patch.id}",
extra = mapOf("patch_id" to patch.id, "mode" to mode, "git_output" to output.trim()),
)
}
Output.emit(
mapOf(
"patch_id" to patch.id,
"subject" to subject,
"mode" to mode,
"applied" to (mode == "am"),
"git_output" to output.trim(),
),
)
return 0
}
}
/** Run `git <args>` in [repoDir], optionally feeding [input] on stdin; returns (exitCode, merged stdout+stderr). */
private fun runGit(
repoDir: File,
input: String?,
vararg gitArgs: String,
): Pair<Int, String> {
var writer: Thread? = null
var reader: Thread? = null
return try {
val proc =
ProcessBuilder(listOf("git", *gitArgs))
.directory(repoDir)
.redirectErrorStream(true)
.start()
// Feed stdin AND drain stdout on side threads: a large patch (bigger than
// the OS pipe buffer) would otherwise deadlock, and reading on this thread
// would block an unbounded wait. The patch was decoded as UTF-8, so write
// it back as UTF-8 (not the JVM default charset, which would corrupt
// non-ASCII filenames/messages).
writer =
if (input != null) {
thread(name = "git-stdin") { runCatching { proc.outputStream.use { it.write(input.toByteArray(Charsets.UTF_8)) } } }
} else {
proc.outputStream.close()
null
}
val sb = StringBuilder()
reader = thread(name = "git-out") { runCatching { sb.append(proc.inputStream.readBytes().decodeToString()) } }
if (!proc.waitFor(GIT_TIMEOUT_SEC, TimeUnit.SECONDS)) {
proc.destroyForcibly()
124 to "git timed out after ${GIT_TIMEOUT_SEC}s"
} else {
reader.join()
proc.exitValue() to sb.toString()
}
} catch (e: Exception) {
1 to (e.message ?: "could not run git (is it installed and is this a git repo?)")
} finally {
// Always join so the non-daemon side threads can't outlive the call (e.g.
// under the in-process test harness, which doesn't exitProcess).
writer?.join(1_000)
reader?.join(1_000)
}
}
}

View File

@@ -0,0 +1,253 @@
/*
* 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<String>,
): 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<String>,
): 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<String>,
): 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<String>): List<String> {
val out = LinkedHashSet<String>()
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<String> =
path
.trim()
.trim('/')
.split('/')
.filter { it.isNotEmpty() }
private fun entrySummary(entry: GitTreeEntry): Map<String, Any?> =
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 {
val end = minOf(8000, bytes.size)
for (i in 0 until end) if (bytes[i].toInt() == 0) return true
return false
}
}

View File

@@ -25,43 +25,73 @@ 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.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
import com.vitorpamplona.quartz.nip34Git.state.GitRepositoryStateEvent
import com.vitorpamplona.quartz.nip34Git.state.tags.RefTag
/**
* `amy git <announce|list|show|issue>` — NIP-34 Nostr-native git
* repositories (nak's `git`, adapted to amy's event-publish model).
* `amy git <verb>` — NIP-34 Nostr-native git collaboration, adapted to amy's
* event-publish model. Mirrors the pure-Nostr surface of `nak git` and `ngit`:
* repository announcements + state, patches, pull requests, issues, threaded
* comments (NIP-22), and status updates. Thin assembly only — every event lives
* in quartz's `nip34Git` package; the shared glue is in [GitSupport].
*
* announce publish a kind:30617 repository announcement
* list list a user's repository announcements
* show print one repository announcement (naddr or coordinates)
* issue publish a kind:1621 issue against a repository
*
* nak's clone/push/pull (git-packfile transport over relays/GRASP) are out
* of scope — they need a real git plumbing layer. These are the metadata /
* collaboration events. Thin assembly only: every event lives in quartz
* (`GitRepositoryEvent`, `GitIssueEvent`).
* Out of scope: the git *packfile* transport (`clone` / `fetch` / `push` of real
* git objects to clone/GRASP servers). That needs a git plumbing layer, not an
* event builder — see `cli/ROADMAP.md`.
*/
object GitCommands {
val USAGE: String =
"""
|amy git — NIP-34 Nostr-native git repositories
|amy git — NIP-34 Nostr-native git collaboration
|
| 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)
|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)
| [--hashtag T[,T]] [--earliest-commit C]
| [--personal-fork] [--d ID | --identifier ID]
| git list [USER] [--relay URL[,URL]] list a user's repo announcements (default self)
| git show NADDR|kind:pubkey:id print one repo announcement
| [--relay URL[,URL]]
| git issue NADDR|coords --subject S [BODY] publish a kind:1621 issue against a repo
| [--hashtag T[,T]] [--relay URL[,URL]] (BODY from arg or stdin)
| git state REPO|IDENTIFIER publish a kind:30618 repository state
| [--head BRANCH] [--branch name=commit[,…]] (branches/tags as name=commit CSV)
| [--tag name=commit[,…]]
| 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] 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]]
| git patch REPO [--file PATH] publish a kind:1617 patch (git format-patch
| [--root|--root-revision] [--commit C] from --file or stdin)
| [--parent-commit P] [--in-reply-to ID]
| git apply PATCH_ID [--check|--print] apply a fetched kind:1617 patch to the local tree
| [--repo PATH] (default: `git am`; --check dry-runs; --print emits it)
| git pr REPO --commit TIP --clone URL[,URL] publish a kind:1618 pull request [DESC arg]
| [--subject S] [--branch-name N] [--merge-base C] [--label L[,L]]
| git pr-update PR --commit TIP --clone URL[,URL] publish a kind:1619 pull-request update
| git issues|patches|prs REPO list a repo's issues/patches/PRs + status
| [--open|--applied|--closed|--draft|--status a,b] [--limit N]
| git thread EVENT_ID print one item + its status timeline + comments
|
|Comments, labels & status:
| git comment TARGET [BODY] NIP-22 kind:1111 comment (BODY arg or stdin)
| git label TARGET LABEL[,LABEL] [--namespace N] NIP-32 kind:1985 labels on an issue/patch/PR
| git open|applied|close|draft TARGET [MESSAGE] publish a kind:1630/1631/1632/1633 status
| applied: [--merge-commit C] [--commit C[,C]] [--patch ID[,ID]]
|
|Every write verb takes [--relay URL[,URL]] to override the target relays
|(default: the repo's advertised relays, else your outbox).
""".trimMargin()
suspend fun dispatch(
@@ -71,12 +101,34 @@ object GitCommands {
route(
"git",
tail,
"git <announce|list|show|issue>",
"git <init|announce|state|list|show|grasp|browse|cat|log|issue|patch|apply|pr|comment|label|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) },
"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) },
"patches" to { rest -> GitReadCommands.patches(dataDir, rest) },
"pr" to { rest -> GitPrCommands.pr(dataDir, rest) },
"pr-update" to { rest -> GitPrCommands.prUpdate(dataDir, rest) },
"prs" to { rest -> GitReadCommands.prs(dataDir, rest) },
"thread" to { rest -> GitReadCommands.thread(dataDir, rest) },
"comment" to { rest -> GitCommentCommand.comment(dataDir, rest) },
"label" to { rest -> GitLabelCommand.label(dataDir, rest) },
"apply" to { rest -> GitApplyCommand.apply(dataDir, rest) },
"open" to { rest -> GitStatusCommands.open(dataDir, rest) },
"applied" to { rest -> GitStatusCommands.applied(dataDir, rest) },
"merged" to { rest -> GitStatusCommands.applied(dataDir, rest) },
"resolved" to { rest -> GitStatusCommands.applied(dataDir, rest) },
"close" to { rest -> GitStatusCommands.close(dataDir, rest) },
"draft" to { rest -> GitStatusCommands.draft(dataDir, rest) },
),
help = USAGE,
)
@@ -91,14 +143,6 @@ object GitCommands {
// eagerly so passing both spellings doesn't trip rejectUnknown().
val identifierAlias = args.flag("identifier")
val identifier = args.flag("d") ?: identifierAlias ?: name
val csv = { key: String ->
args
.flag(key)
?.split(',')
?.map { it.trim() }
?.filter { it.isNotEmpty() }
.orEmpty()
}
Context.open(dataDir).use { ctx ->
ctx.prepare()
@@ -106,11 +150,11 @@ object GitCommands {
GitRepositoryEvent.build(
name = name,
description = args.flag("description"),
webUrls = csv("web"),
cloneUrls = csv("clone"),
relays = csv("relay"),
maintainers = csv("maintainer"),
hashtags = csv("hashtag"),
webUrls = GitSupport.csv(args, "web"),
cloneUrls = GitSupport.csv(args, "clone"),
relays = GitSupport.csv(args, "relay"),
maintainers = GitSupport.csv(args, "maintainer"),
hashtags = GitSupport.csv(args, "hashtag"),
earliestUniqueCommit = args.flag("earliest-commit"),
personalFork = args.bool("personal-fork"),
dTag = identifier,
@@ -130,6 +174,49 @@ object GitCommands {
}
}
private suspend fun state(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val ref = args.positional(0, "repo-identifier-or-naddr")
val addr = GitSupport.resolveAddress(ref)
val dTag = addr?.dTag ?: ref
val head = args.flag("head")
val branches = GitSupport.keyValueCsv(args, "branch")
val tagRefs = GitSupport.keyValueCsv(args, "tag")
args.rejectUnknown("relay")
if (branches.isEmpty() && tagRefs.isEmpty() && head == null) {
return Output.error("bad_args", "git state needs at least one --branch, --tag, or --head")
}
Context.open(dataDir).use { ctx ->
ctx.prepare()
val refs =
branches.map { RefTag.branch(it.first, it.second) } +
tagRefs.map { RefTag.tag(it.first, it.second) }
val template = GitRepositoryStateEvent.build(dTag = dTag, refs = refs, head = head)
val signed = ctx.signer.sign(template)
// Deliver to the announcement's advertised relays (the announcement may
// be someone else's when we're a maintainer publishing state for it).
val announceOwner = addr?.pubKeyHex ?: ctx.identity.pubKeyHex
val repo = GitSupport.fetchRepo(ctx, Address(GitRepositoryEvent.KIND, announceOwner, dTag), args)
val targets = GitSupport.deliveryTargets(ctx, repo, args)
val ack = ctx.publish(signed, targets)
RawEventSupport.publishGuard(ack, signed.id)?.let { return it }
Output.emit(
mapOf(
"event_id" to signed.id,
"address" to Address.assemble(GitRepositoryStateEvent.KIND, signed.pubKey, dTag),
"branches" to branches.size,
"tags" to tagRefs.size,
"head" to head,
) + RawEventSupport.ackFields(ack),
)
return 0
}
}
private suspend fun list(
dataDir: DataDir,
rest: Array<String>,
@@ -165,14 +252,14 @@ object GitCommands {
val coord = args.positional(0, "naddr-or-coordinates")
// `--relay` is read later inside fetchRepo's queryTargets.
args.rejectUnknown("relay")
val addr = resolveAddress(coord) ?: return Output.error("bad_args", "expected an naddr or kind:pubkey:identifier (or pubkey:identifier)")
val addr = GitSupport.resolveAddress(coord) ?: return Output.error("bad_args", "expected an naddr or kind:pubkey:identifier (or pubkey:identifier)")
if (addr.kind != GitRepositoryEvent.KIND) {
return Output.error("bad_args", "not a git repository address (expected kind ${GitRepositoryEvent.KIND}, got ${addr.kind})")
}
Context.openOrAnonymous(dataDir).use { ctx ->
ctx.prepare()
val repo = fetchRepo(ctx, addr, args) ?: return Output.error("not_found", "no repository announcement found for $coord")
val repo = GitSupport.fetchRepo(ctx, addr, args) ?: return Output.error("not_found", "no repository announcement found for $coord")
Output.emit(repoSummary(repo) + mapOf("event_id" to repo.id, "content" to repo.content))
return 0
}
@@ -185,21 +272,15 @@ object GitCommands {
val args = Args(rest)
val coord = args.positional(0, "repo-naddr-or-coordinates")
val subject = args.flag("subject") ?: return Output.error("bad_args", "git issue requires --subject")
val addr = resolveAddress(coord) ?: return Output.error("bad_args", "expected an naddr or kind:pubkey:identifier")
val addr = GitSupport.resolveAddress(coord) ?: return Output.error("bad_args", "expected an naddr or kind:pubkey:identifier")
val body = args.positionalOrNull(1) ?: ""
val topics =
args
.flag("hashtag")
?.split(',')
?.map { it.trim() }
?.filter { it.isNotEmpty() }
.orEmpty()
// `--relay` is read later (relayFlag + fetchRepo's queryTargets).
val topics = GitSupport.csv(args, "hashtag")
// `--relay` is read later (deliveryTargets + fetchRepo's queryTargets).
args.rejectUnknown("relay")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val repo = fetchRepo(ctx, addr, args) ?: return Output.error("not_found", "no repository announcement found for $coord")
val repo = GitSupport.fetchRepo(ctx, addr, args) ?: return Output.error("not_found", "no repository announcement found for $coord")
val template =
GitIssueEvent.build(
subject = subject,
@@ -209,9 +290,7 @@ object GitCommands {
topics = topics,
)
val signed = ctx.signer.sign(template)
// Deliver to the repo's advertised relays when present, else our targets.
val repoRelays = repo.relays().mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
val targets = RawEventSupport.relayFlag(args).ifEmpty { repoRelays }.ifEmpty { ctx.outboxRelays() }
val targets = GitSupport.deliveryTargets(ctx, repo, args)
val ack = ctx.publish(signed, targets)
RawEventSupport.publishGuard(ack, signed.id)?.let { return it }
Output.emit(
@@ -226,42 +305,6 @@ object GitCommands {
}
}
// ------------------------------------------------------------------
private suspend fun fetchRepo(
ctx: Context,
addr: Address,
args: Args,
): GitRepositoryEvent? {
// Cache-first, then drain the query relays.
val filter =
Filter(
kinds = listOf(GitRepositoryEvent.KIND),
authors = listOf(addr.pubKeyHex),
tags = mapOf("d" to listOf(addr.dTag)),
limit = 1,
)
ctx.store
.query<Event>(filter)
.firstOrNull()
?.let { return it as? GitRepositoryEvent }
val relays = RawEventSupport.queryTargets(ctx, args)
ctx.drain(relays.associateWith { listOf(filter) })
return ctx.store.query<Event>(filter).firstOrNull() as? GitRepositoryEvent
}
/** Accept `naddr1…`, `kind:pubkey:dtag`, or `pubkey:dtag` (kind defaults to 30617). */
private fun resolveAddress(input: String): Address? {
val trimmed = input.trim().removePrefix("nostr:")
if (trimmed.startsWith("naddr")) {
val n = NAddress.parse(trimmed) ?: return null
return Address(n.kind, n.author, n.dTag)
}
Address.parse(trimmed)?.let { return it }
val parts = trimmed.split(":")
return if (parts.size == 2 && parts[0].length == 64) Address(GitRepositoryEvent.KIND, parts[0], parts[1]) else null
}
private fun repoSummary(repo: GitRepositoryEvent): Map<String, Any?> =
mapOf(
"identifier" to repo.dTag(),

View File

@@ -0,0 +1,98 @@
/*
* 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.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
/**
* `amy git comment TARGET [BODY]` — reply to a NIP-34 issue, patch, pull
* request, or repository with a NIP-22 kind:1111 comment (the modern
* replacement for the deprecated kind:1622 git reply). TARGET is a
* note/nevent/64-hex event id, or an naddr / `kind:pubkey:id` repo coordinate.
* BODY comes from the argument or stdin.
*/
object GitCommentCommand {
suspend fun comment(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val targetRef = args.positional(0, "target-event-or-repo")
val bodyArg = args.positionalOrNull(1)
// Never block on an interactive TTY: amy is non-interactive, so require the
// body as an argument unless it's actually being piped in.
if (bodyArg == null && System.console() != null) {
return Output.error("bad_args", "comment body required as an argument (or piped on stdin)")
}
val body = (bodyArg ?: System.`in`.readBytes().decodeToString()).trim()
if (body.isBlank()) return Output.error("bad_args", "empty comment (pass BODY as an argument or on stdin)")
args.rejectUnknown("relay")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val target =
resolveTarget(ctx, targetRef, args)
?: return Output.error("not_found", "no event or repository found for $targetRef")
val template = CommentEvent.replyBuilder(body, EventHintBundle<Event>(target))
val signed = ctx.signer.sign(template)
// Deliver to the repository's advertised relays when we can find them.
val repo =
(target as? GitRepositoryEvent)
?: GitSupport.repositoryOf(target)?.let { GitSupport.fetchRepo(ctx, Address(it.kind, it.pubKeyHex, it.dTag), args) }
val targets = GitSupport.deliveryTargets(ctx, repo, args)
val ack = ctx.publish(signed, targets)
RawEventSupport.publishGuard(ack, signed.id)?.let { return it }
Output.emit(
mapOf(
"event_id" to signed.id,
"kind" to signed.kind,
"in_reply_to" to target.id,
"target_kind" to target.kind,
) + RawEventSupport.ackFields(ack),
)
return 0
}
}
/** Resolve TARGET as an event id first, then fall back to a repo coordinate. */
private suspend fun resolveTarget(
ctx: Context,
ref: String,
args: Args,
): Event? {
GitSupport.resolveEventId(ref)?.let { id ->
GitSupport.fetchEvent(ctx, id, args)?.let { return it }
}
GitSupport.resolveAddress(ref)?.let { addr ->
if (addr.kind == GitRepositoryEvent.KIND) return GitSupport.fetchRepo(ctx, addr, args)
}
return null
}
}

View File

@@ -0,0 +1,116 @@
/*
* 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.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip34Git.grasp.UserGraspListEvent
/**
* `amy git grasp <list|set>` — a user's NIP-34 GRASP (Git-over-Nostr hosting)
* server list (kind 10317). Functions like NIP-65's relay list: it declares,
* in preference order, where PR tip branches (`refs/nostr/<pr-id>`) get pushed
* so maintainers know where to fetch them. `ngit` and `nak git` read this to
* pick a push host; amy publishes and reads the list (the git push itself is
* out of scope — see cli/ROADMAP.md).
*/
object GitGraspCommands {
val USAGE: String =
"""
|amy git grasp — NIP-34 GRASP server list (kind 10317)
|
| git grasp list [USER] list a user's grasp servers (default self)
| git grasp set URL[,URL] [--relay URL[,URL]] publish your grasp server list (preference order)
""".trimMargin()
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int =
route(
"git grasp",
tail,
"git grasp <list|set>",
mapOf(
"list" to { rest -> list(dataDir, rest) },
"set" to { rest -> set(dataDir, rest) },
),
help = USAGE,
)
private suspend fun list(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
Context.openOrAnonymous(dataDir).use { ctx ->
ctx.prepare()
val author = args.positionalOrNull(0)?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex
args.rejectUnknown("relay")
val filter = Filter(kinds = listOf(UserGraspListEvent.KIND), authors = listOf(author), limit = 1)
var event = ctx.store.query<Event>(filter).firstOrNull() as? UserGraspListEvent
if (event == null) {
val relays = RawEventSupport.queryTargets(ctx, args)
ctx.drain(relays.associateWith { listOf(filter) })
event = ctx.store.query<Event>(filter).firstOrNull() as? UserGraspListEvent
}
val grasps = event?.grasps().orEmpty()
Output.emit(mapOf("pubkey" to author, "count" to grasps.size, "grasps" to grasps))
return 0
}
}
private suspend fun set(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val grasps =
args
.positional(0, "grasp-server-urls")
.split(',')
.map { it.trim() }
.filter { it.isNotEmpty() }
if (grasps.isEmpty()) return Output.error("bad_args", "git grasp set requires URL[,URL] (a comma-separated grasp server list)")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val template = UserGraspListEvent.build(grasps)
val signed = ctx.signer.sign(template)
val targets = RawEventSupport.publishTargets(ctx, args)
args.rejectUnknown()
val ack = ctx.publish(signed, targets)
RawEventSupport.publishGuard(ack, signed.id)?.let { return it }
Output.emit(
mapOf(
"event_id" to signed.id,
"kind" to signed.kind,
"grasps" to grasps,
) + RawEventSupport.ackFields(ack),
)
return 0
}
}
}

View File

@@ -0,0 +1,223 @@
/*
* 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
import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread
/**
* `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 {
/** Safety ceiling for a single local `git` invocation; a normal read-only op finishes in milliseconds. */
private const val GIT_TIMEOUT_SEC = 60L
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 repo's mainline (first-parent) root
// commit — the cross-fork identity every NIP-34 client must agree on. A
// SHALLOW clone cannot know its true root (`rev-list --max-parents=0`
// returns the shallow-boundary commits, not the real first commit), so we
// must NOT derive a bogus euc there — that would announce the repo under a
// wrong identity and fork it away from ngit's view. `--first-parent` keeps
// the mainline root deterministic when a history has merged-in subtree roots.
val shallow = git(repoDir, "rev-parse", "--is-shallow-repository") == "true"
val euc =
if (toplevel == null || shallow) {
null
} else {
git(repoDir, "rev-list", "--max-parents=0", "--first-parent", "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
if (earliestCommit == null && toplevel != null) {
System.err.println(
"[git init] warning: could not derive the earliest-unique-commit" +
(if (shallow) " (shallow clone)" else "") +
" — the announcement will omit it. Pass --earliest-commit <root-commit-id> so the repo keeps a stable cross-fork identity.",
)
}
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)
// Surface the state publish result separately (state_*) rather than
// dropping it — otherwise a fully-rejected 30618 reads as success.
val stateAck = 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
result.putAll(RawEventSupport.ackFields(stateAck).mapKeys { "state_${it.key}" })
if (stateAck.isNotEmpty() && stateAck.none { it.value.accepted }) {
System.err.println("[git init] warning: no relay accepted the repository-state (30618) event — branches/tags/HEAD were not delivered.")
}
}
}
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). stderr is discarded straight to the OS so a
* chatty command can never fill its stderr pipe and deadlock the stdout read.
*/
private fun git(
repoDir: File,
vararg gitArgs: String,
): String? =
try {
val proc =
ProcessBuilder(listOf("git", *gitArgs))
.directory(repoDir)
.redirectError(ProcessBuilder.Redirect.DISCARD)
.start()
// Drain stdout on a side thread and bound the wait: a wedged git (a
// hung filter/hook, a credential prompt) must not hang the CLI forever.
val sb = StringBuilder()
val reader = thread(name = "git-out") { runCatching { sb.append(proc.inputStream.readBytes().decodeToString()) } }
if (!proc.waitFor(GIT_TIMEOUT_SEC, TimeUnit.SECONDS)) {
proc.destroyForcibly()
reader.join(1_000)
null
} else {
reader.join()
if (proc.exitValue() == 0) sb.toString().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@") -> {
// ssh://git@host[:port]/owner/repo.git → https://host/owner/repo.git
// (drop the SSH port; carrying it into the https URL makes it unreachable).
val rest = url.removePrefix("ssh://git@")
val slash = rest.indexOf('/')
val hostPort = if (slash >= 0) rest.take(slash) else rest
val path = if (slash >= 0) rest.substring(slash) else ""
"https://${hostPort.substringBefore(':')}$path"
}
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 }
}

View File

@@ -0,0 +1,88 @@
/*
* 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.nip32Labeling.LabelEvent
import com.vitorpamplona.quartz.nip32Labeling.tags.LabelTag
/**
* `amy git label TARGET LABEL[,LABEL]` — attach NIP-32 kind:1985 labels to a
* patch, pull request, or issue (the `ngit pr label` / `issue label` surface).
* Labels default to the `ugc` namespace; override with `--namespace`.
*/
object GitLabelCommand {
suspend fun label(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val targetRef = args.positional(0, "target-event-id")
val namespace = args.flag("namespace") ?: LabelTag.DEFAULT_NAMESPACE
val labels =
args
.positional(1, "label[,label]")
.split(',')
.map { it.trim() }
.filter { it.isNotEmpty() }
.map { LabelTag(it, namespace) }
if (labels.isEmpty()) return Output.error("bad_args", "git label requires at least one label")
val content = args.flag("content") ?: ""
val id =
GitSupport.resolveEventId(targetRef)
?: return Output.error("bad_args", "expected a note/nevent/64-hex target id")
args.rejectUnknown("relay")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val target =
GitSupport.fetchEvent(ctx, id, args)
?: return Output.error("not_found", "no event found for $targetRef")
val template =
LabelEvent.buildEventLabel(
labeledEventId = target.id,
labeledEventAuthor = target.pubKey,
labels = labels,
content = content,
)
val signed = ctx.signer.sign(template)
val repoATag = GitSupport.repositoryOf(target)
val repo = repoATag?.let { GitSupport.fetchRepo(ctx, Address(it.kind, it.pubKeyHex, it.dTag), args) }
val targets = GitSupport.deliveryTargets(ctx, repo, args)
val ack = ctx.publish(signed, targets)
RawEventSupport.publishGuard(ack, signed.id)?.let { return it }
Output.emit(
mapOf(
"event_id" to signed.id,
"kind" to signed.kind,
"target" to target.id,
"namespace" to namespace,
"labels" to labels.map { it.label },
) + RawEventSupport.ackFields(ack),
)
return 0
}
}
}

View File

@@ -0,0 +1,133 @@
/*
* 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.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import java.io.File
/**
* `amy git patch REPO` — publish a NIP-34 kind:1617 patch against a repository.
*
* The patch body is the raw `git format-patch` output, read from `--file PATH`
* or (by default) stdin, so the natural pipeline is:
*
* git format-patch --stdout HEAD~1 | amy git patch nostr:naddr1… --root
*
* Thin assembly only — every tag lives in quartz's [GitPatchEvent].
*/
object GitPatchCommands {
suspend fun patch(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val coord = args.positional(0, "repo-naddr-or-coordinates")
val addr =
GitSupport.resolveAddress(coord)
?: return Output.error("bad_args", "expected an naddr or kind:pubkey:identifier")
val root = args.bool("root")
val rootRevision = args.bool("root-revision")
val commit = args.flag("commit")
val parentCommit = args.flag("parent-commit")
val eucOverride = args.flag("earliest-commit")
val replyTo = args.flag("in-reply-to")
val file = args.flag("file")
// `--relay` is consumed later by deliveryTargets / fetchRepo's queryTargets.
args.rejectUnknown("relay")
val body = readPatch(file)
if (body.isBlank()) return Output.error("bad_args", "empty patch (pass --file PATH or pipe `git format-patch` to stdin)")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val repo =
GitSupport.fetchRepo(ctx, addr, args)
?: return Output.error("not_found", "no repository announcement found for $coord")
val euc =
repo.earliestUniqueCommit()
?: eucOverride
?: return Output.error(
"bad_args",
"repository announcement has no earliest-unique-commit; pass --earliest-commit <id>",
)
val repoBundle = EventHintBundle(repo)
val template =
if (replyTo != null) {
val replyId =
GitSupport.resolveEventId(replyTo)
?: return Output.error("bad_args", "--in-reply-to expects a note/nevent/64-hex, got '$replyTo'")
val prior =
GitSupport.fetchEvent(ctx, replyId, args) as? GitPatchEvent
?: return Output.error("not_found", "no patch found to reply to: $replyTo")
GitPatchEvent.reply(
patch = body,
repository = repoBundle,
earliestUniqueCommit = euc,
replyingTo = EventHintBundle(prior),
commit = commit,
parentCommit = parentCommit,
)
} else {
GitPatchEvent.build(
patch = body,
repository = repoBundle,
earliestUniqueCommit = euc,
commit = commit,
parentCommit = parentCommit,
root = root,
rootRevision = rootRevision,
)
}
val signed = ctx.signer.sign(template)
val targets = GitSupport.deliveryTargets(ctx, repo, args)
val ack = ctx.publish(signed, targets)
RawEventSupport.publishGuard(ack, signed.id)?.let { return it }
Output.emit(
mapOf(
"event_id" to signed.id,
"kind" to signed.kind,
"repository" to Address.assemble(addr.kind, addr.pubKeyHex, addr.dTag),
"subject" to (signed as? GitPatchEvent)?.subject(),
) + RawEventSupport.ackFields(ack),
)
return 0
}
}
/** Read the patch body from [file] when given, otherwise from stdin. */
private fun readPatch(file: String?): String =
if (file != null) {
File(file).takeIf { it.isFile }?.readText()
?: throw IllegalArgumentException("--file not found: $file")
} else {
// Non-interactive: don't block waiting for a human to type a patch.
require(System.console() == null) { "no patch given: pass --file PATH or pipe `git format-patch` to stdin" }
System.`in`.readBytes().decodeToString()
}.trim()
}

View File

@@ -0,0 +1,156 @@
/*
* 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.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
/**
* `amy git pr REPO` (kind:1618) and `amy git pr-update PR` (kind:1619) —
* branch-based NIP-34 contributions that reference a pushed tip by clone URL +
* commit id instead of inlining a patch. The actual git branch push (to a clone
* or GRASP server) is out of scope — amy only publishes the collaboration event.
*/
object GitPrCommands {
suspend fun pr(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val coord = args.positional(0, "repo-naddr-or-coordinates")
val addr =
GitSupport.resolveAddress(coord)
?: return Output.error("bad_args", "expected an naddr or kind:pubkey:identifier")
val currentCommit = args.flag("commit") ?: return Output.error("bad_args", "git pr requires --commit <tip-commit-id>")
val cloneUrls = GitSupport.csv(args, "clone")
if (cloneUrls.isEmpty()) return Output.error("bad_args", "git pr requires --clone <url>[,<url>] where the branch tip can be fetched")
val subject = args.flag("subject")
val branchName = args.flag("branch-name")
val mergeBase = args.flag("merge-base")
val labels = GitSupport.csv(args, "label")
val eucOverride = args.flag("earliest-commit")
val description = args.positionalOrNull(1) ?: ""
args.rejectUnknown("relay")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val repo =
GitSupport.fetchRepo(ctx, addr, args)
?: return Output.error("not_found", "no repository announcement found for $coord")
val euc =
repo.earliestUniqueCommit()
?: eucOverride
?: return Output.error("bad_args", "repository announcement has no earliest-unique-commit; pass --earliest-commit <id>")
val template =
GitPullRequestEvent.build(
description = description,
repository = EventHintBundle(repo),
earliestUniqueCommit = euc,
currentCommit = currentCommit,
cloneUrls = cloneUrls,
subject = subject,
labels = labels,
branchName = branchName,
mergeBase = mergeBase,
)
val signed = ctx.signer.sign(template)
val targets = GitSupport.deliveryTargets(ctx, repo, args)
val ack = ctx.publish(signed, targets)
RawEventSupport.publishGuard(ack, signed.id)?.let { return it }
Output.emit(
mapOf(
"event_id" to signed.id,
"kind" to signed.kind,
"repository" to Address.assemble(addr.kind, addr.pubKeyHex, addr.dTag),
"subject" to subject,
"current_commit" to currentCommit,
) + RawEventSupport.ackFields(ack),
)
return 0
}
}
suspend fun prUpdate(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val prRef = args.positional(0, "pull-request-id")
val prId =
GitSupport.resolveEventId(prRef)
?: return Output.error("bad_args", "expected a note/nevent/64-hex pull-request id")
val currentCommit = args.flag("commit") ?: return Output.error("bad_args", "git pr-update requires --commit <new-tip-commit-id>")
val cloneUrls = GitSupport.csv(args, "clone")
if (cloneUrls.isEmpty()) return Output.error("bad_args", "git pr-update requires --clone <url>[,<url>]")
val mergeBase = args.flag("merge-base")
val eucOverride = args.flag("earliest-commit")
args.rejectUnknown("relay")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val parent =
GitSupport.fetchEvent(ctx, prId, args) as? GitPullRequestEvent
?: return Output.error("not_found", "no pull request (kind 1618) found for $prRef")
val repoAddr =
parent.repositoryAddress()
?: return Output.error("bad_args", "pull request $prRef carries no repository address")
val repo =
GitSupport.fetchRepo(ctx, repoAddr, args)
?: return Output.error("not_found", "no repository announcement found for ${GitSupport.repoCoordinate(repoAddr)}")
val euc =
repo.earliestUniqueCommit()
?: parent.earliestUniqueCommit()
?: eucOverride
?: return Output.error("bad_args", "no earliest-unique-commit available; pass --earliest-commit <id>")
val template =
GitPullRequestUpdateEvent.build(
parentPullRequest = EventHintBundle(parent),
repository = EventHintBundle<GitRepositoryEvent>(repo),
earliestUniqueCommit = euc,
currentCommit = currentCommit,
cloneUrls = cloneUrls,
mergeBase = mergeBase,
)
val signed = ctx.signer.sign(template)
val targets = GitSupport.deliveryTargets(ctx, repo, args)
val ack = ctx.publish(signed, targets)
RawEventSupport.publishGuard(ack, signed.id)?.let { return it }
Output.emit(
mapOf(
"event_id" to signed.id,
"kind" to signed.kind,
"pull_request" to parent.id,
"current_commit" to currentCommit,
) + RawEventSupport.ackFields(ack),
)
return 0
}
}
}

View File

@@ -0,0 +1,256 @@
/*
* 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.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusEvent
/**
* Read-side `amy git` verbs — list a repository's issues / patches / pull
* requests with their derived status, and print one collaboration thread with
* its status timeline and comments. All read-only (anonymous-capable).
*/
object GitReadCommands {
private val STATUS_KINDS =
listOf(
GitStatusEvent.KIND_OPEN,
GitStatusEvent.KIND_APPLIED,
GitStatusEvent.KIND_CLOSED,
GitStatusEvent.KIND_DRAFT,
)
/** Idle timeout for the list reads — tighter than `drainAllPages`' 30s default so a dead relay can't stall a list. */
private const val READ_TIMEOUT_MS = 12_000L
/** Max event ids per `#e` status filter — many relays cap tag-filter values around 100, so stay well under. */
private const val STATUS_ID_CHUNK = 50
suspend fun issues(
dataDir: DataDir,
rest: Array<String>,
): Int = listItems(dataDir, rest, GitIssueEvent.KIND)
suspend fun patches(
dataDir: DataDir,
rest: Array<String>,
): Int = listItems(dataDir, rest, GitPatchEvent.KIND)
suspend fun prs(
dataDir: DataDir,
rest: Array<String>,
): Int = listItems(dataDir, rest, GitPullRequestEvent.KIND)
/** Shared list path for issues (1621) / patches (1617) / pull requests (1618). */
private suspend fun listItems(
dataDir: DataDir,
rest: Array<String>,
itemKind: Int,
): Int {
val args = Args(rest)
val coord = args.positional(0, "repo-naddr-or-coordinates")
val addr =
GitSupport.resolveAddress(coord)
?: return Output.error("bad_args", "expected an naddr or kind:pubkey:identifier")
val limit = args.intFlag("limit", 100)
val wanted = statusFilter(args)
args.rejectUnknown("relay")
Context.openOrAnonymous(dataDir).use { ctx ->
ctx.prepare()
val repoAddress = GitSupport.repoCoordinate(addr)
// Fetch the announcement once: it supplies both the maintainer set
// (status authority) AND the advertised relays the events actually live
// on. Reading only from general relays misses GRASP-hosted repos.
val repo = GitSupport.fetchRepo(ctx, addr, args)
val relays = GitSupport.readTargets(ctx, repo, args)
// Two queries so item volume and status volume never starve each other.
// Items are paged to the limit; statuses are fetched separately by the
// items' ids so derived status is never truncated by item volume.
val itemEvents =
ctx
.drainAllPages(
relays.associateWith { listOf(Filter(kinds = listOf(itemKind), tags = mapOf("a" to listOf(repoAddress)), limit = limit)) },
timeoutMs = READ_TIMEOUT_MS,
).asSequence()
.map { it.second }
.filter { it.kind == itemKind }
.distinctBy { it.id }
.sortedByDescending { it.createdAt }
.take(limit)
.toList()
val statusesByRoot = fetchStatusesFor(ctx, relays, itemEvents.map { it.id }).groupBy { it.rootEventId() }
val authorities = repoAuthorities(repo, addr)
val items =
itemEvents
.map { item -> GitSupport.targetSummary(item) + mapOf("status" to latestStatus(item, statusesByRoot, authorities)) }
.filter { wanted == null || it["status"] in wanted }
Output.emit(mapOf("repository" to repoAddress, "count" to items.size, "items" to items))
return 0
}
}
/**
* `amy git thread TARGET` — the target event plus its status timeline and
* NIP-22 comments (and legacy kind:1622 replies).
*
* Scope: first-level replies/statuses that `e`-reference the target. Nested
* comment trees and PR-update (1619) events (which use NIP-22 uppercase `E`)
* are out of scope here.
*/
suspend fun thread(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val ref = args.positional(0, "target-event-id")
val id =
GitSupport.resolveEventId(ref)
?: return Output.error("bad_args", "expected a note/nevent/64-hex event id")
args.rejectUnknown("relay")
Context.openOrAnonymous(dataDir).use { ctx ->
ctx.prepare()
val target =
GitSupport.fetchEvent(ctx, id, args)
?: return Output.error("not_found", "no event found for $ref")
val repoATag = GitSupport.repositoryOf(target)
val repo = repoATag?.let { GitSupport.fetchRepo(ctx, Address(it.kind, it.pubKeyHex, it.dTag), args) }
val relays = GitSupport.readTargets(ctx, repo, args)
// Everything that `e`-references the target: statuses, comments, replies.
val related =
ctx
.drain(
relays.associateWith {
listOf(Filter(kinds = STATUS_KINDS + listOf(CommentEvent.KIND, GitReplyEvent.KIND), tags = mapOf("e" to listOf(id))))
},
timeoutMs = READ_TIMEOUT_MS,
).map { it.second }
.distinctBy { it.id }
val authorities = repoATag?.let { repoAuthorities(repo, Address(it.kind, it.pubKeyHex, it.dTag)) } ?: setOf(target.pubKey)
val statuses = related.filterIsInstance<GitStatusEvent>().filter { it.rootEventId() == id }
val statusesByRoot = statuses.groupBy { it.rootEventId() }
val comments =
related
.filter { it.kind == CommentEvent.KIND || it.kind == GitReplyEvent.KIND }
.sortedBy { it.createdAt }
.map { mapOf("event_id" to it.id, "kind" to it.kind, "author" to it.pubKey, "created_at" to it.createdAt, "content" to it.content) }
Output.emit(
GitSupport.targetSummary(target) +
mapOf(
"content" to target.content,
"status" to latestStatus(target, statusesByRoot, authorities),
"status_events" to
statuses.sortedBy { it.createdAt }.map {
mapOf("event_id" to it.id, "status" to GitSupport.statusLabel(it.kind), "author" to it.pubKey, "created_at" to it.createdAt)
},
"comments" to comments,
),
)
return 0
}
}
// ------------------------------------------------------------------
/**
* Fetch the status events that `e`-reference [itemIds]. Chunked to stay under
* relay tag-filter value caps, and paged (`drainAllPages`) so a heavily
* reopened/closed item's older status can't fall off a single page.
*/
private suspend fun fetchStatusesFor(
ctx: Context,
relays: Set<NormalizedRelayUrl>,
itemIds: List<HexKey>,
): List<GitStatusEvent> {
if (itemIds.isEmpty()) return emptyList()
return itemIds
.chunked(STATUS_ID_CHUNK)
.flatMap { chunk ->
ctx
.drainAllPages(
relays.associateWith { listOf(Filter(kinds = STATUS_KINDS, tags = mapOf("e" to chunk))) },
timeoutMs = READ_TIMEOUT_MS,
).map { it.second }
}.filterIsInstance<GitStatusEvent>()
.distinctBy { it.id }
}
/** The set of pubkeys whose status is authoritative for a repo: the owner + declared maintainers. */
private fun repoAuthorities(
repo: GitRepositoryEvent?,
addr: Address,
): Set<HexKey> =
buildSet {
add(addr.pubKeyHex)
repo?.maintainers()?.let { addAll(it) }
}
/**
* The authoritative status label for [item]: the newest status event (by
* `created_at`, id as a deterministic tie-break) that `e`-roots this item and
* is signed by the item author, the repo owner, or a maintainer. Defaults to
* `open` when none exists. [statusesByRoot] is pre-grouped by root event id so
* this is an O(1) lookup instead of an O(items × statuses) rescan.
*/
private fun latestStatus(
item: Event,
statusesByRoot: Map<HexKey?, List<GitStatusEvent>>,
authorities: Set<HexKey>,
): String {
val allowed = authorities + item.pubKey
val newest =
statusesByRoot[item.id]
?.filter { it.pubKey in allowed }
?.maxWithOrNull(compareBy({ it.createdAt }, { it.id }))
return GitSupport.statusLabel(newest?.kind)
}
/** Translate `--status a,b` plus the `--open/--applied/--closed/--draft/--all` bools into a wanted set (null = all). */
private fun statusFilter(args: Args): Set<String>? {
val explicit = GitSupport.csv(args, "status").toMutableSet()
if (args.bool("open")) explicit.add("open")
if (args.bool("applied")) explicit.add("applied")
if (args.bool("closed")) explicit.add("closed")
if (args.bool("draft")) explicit.add("draft")
args.bool("all") // consumed; means "no filter"
return explicit.takeIf { it.isNotEmpty() }
}
}

View File

@@ -0,0 +1,177 @@
/*
* 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.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
/**
* `amy git open|applied|close|draft TARGET` — publish a NIP-34 status event
* (kinds 1630 / 1631 / 1632 / 1633) against a patch, pull request, or issue.
*
* open 1630 — mark open / reopen / ready-for-review
* applied 1631 — mark applied / merged (patches, PRs) or resolved (issues)
* close 1632 — close without applying
* draft 1633 — move back to draft
*
* The newest status from the root author or a repo maintainer is authoritative
* (NIP-34). amy publishes the event; it does not enforce maintainership.
*/
object GitStatusCommands {
suspend fun open(
dataDir: DataDir,
rest: Array<String>,
): Int = simpleStatus(dataDir, rest, GitStatusOpenEvent.KIND)
suspend fun close(
dataDir: DataDir,
rest: Array<String>,
): Int = simpleStatus(dataDir, rest, GitStatusClosedEvent.KIND)
suspend fun draft(
dataDir: DataDir,
rest: Array<String>,
): Int = simpleStatus(dataDir, rest, GitStatusDraftEvent.KIND)
/** open / close / draft share one shape: `TARGET [MESSAGE]`. */
private suspend fun simpleStatus(
dataDir: DataDir,
rest: Array<String>,
kind: Int,
): Int {
val args = Args(rest)
val (targetRef, msg) = args.targetAndMessage() ?: return Output.error("bad_args", "expected a note/nevent/64-hex target id")
args.rejectUnknown("relay")
return Context.open(dataDir).use { ctx ->
ctx.prepare()
val target = ctx.resolveTarget(targetRef, args) ?: return@use Output.error("not_found", "no event found for $targetRef")
val repoATag = GitSupport.repositoryOf(target)
val template =
when (kind) {
GitStatusOpenEvent.KIND -> GitStatusOpenEvent.build(msg, EventHintBundle(target)) { repoTags(repoATag, target) }
GitStatusClosedEvent.KIND -> GitStatusClosedEvent.build(msg, EventHintBundle(target)) { repoTags(repoATag, target) }
GitStatusDraftEvent.KIND -> GitStatusDraftEvent.build(msg, EventHintBundle(target)) { repoTags(repoATag, target) }
else -> error("unreachable status kind $kind")
}
ctx.emitStatus(template, target, repoATag, args, kind)
}
}
/** `applied` (1631) additionally carries merge-commit / applied-as-commits / applied-patch tags. */
suspend fun applied(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val (targetRef, msg) = args.targetAndMessage() ?: return Output.error("bad_args", "expected a note/nevent/64-hex target id")
val mergeCommit = args.flag("merge-commit")
val appliedAsCommits = GitSupport.csv(args, "commit")
val patchRefs = GitSupport.csv(args, "patch")
args.rejectUnknown("relay")
return Context.open(dataDir).use { ctx ->
ctx.prepare()
val target = ctx.resolveTarget(targetRef, args) ?: return@use Output.error("not_found", "no event found for $targetRef")
val repoATag = GitSupport.repositoryOf(target)
val appliedPatches =
patchRefs.mapNotNull { ref ->
GitSupport.resolveEventId(ref)?.let { id -> GitSupport.fetchEvent(ctx, id, args) as? GitPatchEvent }?.let { EventHintBundle(it) }
}
val template =
GitStatusAppliedEvent.build(
content = msg,
target = EventHintBundle(target),
appliedPatches = appliedPatches,
mergeCommit = mergeCommit,
appliedAsCommits = appliedAsCommits,
) { repoTags(repoATag, target) }
ctx.emitStatus(template, target, repoATag, args, GitStatusAppliedEvent.KIND)
}
}
// ------------------------------------------------------------------
/** `TARGET [MESSAGE]` — the shape every status verb shares. */
private fun Args.targetAndMessage(): Pair<String, String>? {
val ref = positionalOrNull(0) ?: return null
return ref to (positionalOrNull(1) ?: "")
}
private suspend fun Context.resolveTarget(
ref: String,
args: Args,
): Event? {
val id = GitSupport.resolveEventId(ref) ?: return null
return GitSupport.fetchEvent(this, id, args)
}
/**
* Attach the repository `a` tag and, when it differs from the target author
* (already p-tagged by the shared status builder), the repo owner `p` tag.
*/
private fun <T : Event> TagArrayBuilder<T>.repoTags(
repoATag: ATag?,
target: Event,
) {
repoATag ?: return
add(repoATag.toATagArray())
if (repoATag.pubKeyHex != target.pubKey) pTag(repoATag.pubKeyHex)
}
private suspend fun Context.emitStatus(
template: EventTemplate<out Event>,
target: Event,
repoATag: ATag?,
args: Args,
kind: Int,
): Int {
val signed = signer.sign(template)
val repo = repoATag?.let { GitSupport.fetchRepo(this, Address(it.kind, it.pubKeyHex, it.dTag), args) }
val targets = GitSupport.deliveryTargets(this, repo, args)
val ack = publish(signed, targets)
RawEventSupport.publishGuard(ack, signed.id)?.let { return it }
Output.emit(
mapOf(
"event_id" to signed.id,
"kind" to signed.kind,
"status" to GitSupport.statusLabel(kind),
"target" to target.id,
"repository" to repoATag?.toTag(),
) + RawEventSupport.ackFields(ack),
)
return 0
}
}

View File

@@ -0,0 +1,247 @@
/*
* 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.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip19Bech32.decodeEventIdAsHexOrNull
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusEvent
/**
* Shared, protocol-free glue for the `amy git` (NIP-34) sub-verbs — address /
* event-id parsing, cache-first fetch helpers, relay routing, and the read-side
* summaries. Every real Nostr piece lives in quartz's `nip34Git` package; this
* object only parses CLI input and shapes the `--json` result maps.
*/
object GitSupport {
/** Accept `naddr1…`, `kind:pubkey:dtag`, or `pubkey:dtag` (kind defaults to 30617). */
fun resolveAddress(input: String): Address? {
val trimmed = input.trim().removePrefix("nostr:")
if (trimmed.startsWith("naddr")) {
val n = NAddress.parse(trimmed) ?: return null
return Address(n.kind, n.author, n.dTag)
}
Address.parse(trimmed)?.let { return it }
val parts = trimmed.split(":")
return if (parts.size == 2 && parts[0].length == 64) Address(GitRepositoryEvent.KIND, parts[0], parts[1]) else null
}
/** Decode a `note1…` / `nevent1…` / 64-hex event reference to its raw event id. */
fun resolveEventId(input: String): String? = decodeEventIdAsHexOrNull(input.trim().removePrefix("nostr:"))
/** The `["a", …]` coordinate value of a repository announcement (`30617:pubkey:identifier`). */
fun repoCoordinate(addr: Address): String = Address.assemble(GitRepositoryEvent.KIND, addr.pubKeyHex, addr.dTag)
/**
* Cache-first fetch of a repository announcement (kind 30617) for [addr],
* draining the query relays only on a store miss.
*/
suspend fun fetchRepo(
ctx: Context,
addr: Address,
args: Args,
): GitRepositoryEvent? {
val filter =
Filter(
kinds = listOf(GitRepositoryEvent.KIND),
authors = listOf(addr.pubKeyHex),
tags = mapOf("d" to listOf(addr.dTag)),
limit = 1,
)
ctx.store
.query<Event>(filter)
.firstOrNull()
?.let { return it as? GitRepositoryEvent }
val relays = RawEventSupport.queryTargets(ctx, args)
ctx.drain(relays.associateWith { listOf(filter) })
return ctx.store.query<Event>(filter).firstOrNull() as? GitRepositoryEvent
}
/**
* Cache-first fetch of any event by its raw [idHex], draining the query
* relays only on a store miss. Used to resolve the patch / PR / issue a
* status, comment, or thread view targets.
*/
suspend fun fetchEvent(
ctx: Context,
idHex: String,
args: Args,
): Event? {
val filter = Filter(ids = listOf(idHex), limit = 1)
ctx.store
.query<Event>(filter)
.firstOrNull()
?.let { return it }
val relays = RawEventSupport.queryTargets(ctx, args)
ctx.drain(relays.associateWith { listOf(filter) })
return ctx.store.query<Event>(filter).firstOrNull()
}
/** The repository this issue / patch / PR / status refers to, as an [ATag] (or null). */
fun repositoryOf(event: Event): ATag? =
when (event) {
is GitIssueEvent -> event.repository()
is GitPatchEvent -> event.repository()
is GitPullRequestEvent -> event.repository()
is GitPullRequestUpdateEvent -> event.repository()
is GitStatusEvent -> event.repository()
else -> null
}
/**
* Where to deliver a collaboration event: the repo announcement's advertised
* `relays` (the NIP-34 monitored set), else the explicit `--relay` flag, else
* the account outbox. [repo] is the fetched announcement, or null when it
* couldn't be resolved.
*/
suspend fun deliveryTargets(
ctx: Context,
repo: GitRepositoryEvent?,
args: Args,
): Set<NormalizedRelayUrl> {
val flag = RawEventSupport.relayFlag(args)
if (flag.isNotEmpty()) return flag
val advertised =
repo
?.relays()
?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
?.toSet()
.orEmpty()
if (advertised.isNotEmpty()) return advertised
// Falling back to the account outbox: the repo couldn't be resolved or
// advertises no relays, so a maintainer watching only the repo's NIP-34
// relays may never see this event. Say so instead of reporting silent success.
System.err.println(
"[git] warning: no repository relays known — delivering to your outbox. " +
"A maintainer watching only the repo's relays may not see this; pass --relay to target them.",
)
return ctx.outboxRelays()
}
/**
* Where to READ a repo's collaboration events from: the account's query
* relays (explicit `--relay`, else outbox, else bootstrap) UNION the repo
* announcement's own advertised `relays` — the NIP-34 monitored set where
* patches/issues/statuses actually live. Without the union, a repo hosted on
* GRASP/git-specific relays (which general relays don't mirror) reads empty.
*/
suspend fun readTargets(
ctx: Context,
repo: GitRepositoryEvent?,
args: Args,
): Set<NormalizedRelayUrl> {
val advertised =
repo
?.relays()
?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
?.toSet()
.orEmpty()
return RawEventSupport.queryTargets(ctx, args) + advertised
}
/** Parse a `--flag a,b,c` CSV flag into a trimmed, non-empty list. */
fun csv(
args: Args,
key: String,
): List<String> =
args
.flag(key)
?.split(',')
?.map { it.trim() }
?.filter { it.isNotEmpty() }
.orEmpty()
/**
* Parse a `--flag name=commit,other=commit` CSV of `key=value` pairs (used
* for `git state --branch`/`--tag`). An entry without `=` is a bad-args
* error so a typo can't silently drop a ref.
*/
fun keyValueCsv(
args: Args,
key: String,
): List<Pair<String, String>> =
csv(args, key).map { entry ->
val idx = entry.indexOf('=')
require(idx > 0 && idx < entry.length - 1) { "--$key expects name=commit pairs, got '$entry'" }
entry.take(idx).trim() to entry.substring(idx + 1).trim()
}
/** The single-word status of a patch/PR/issue, derived from its status events. */
fun statusLabel(kind: Int?): String =
when (kind) {
GitStatusEvent.KIND_OPEN -> "open"
GitStatusEvent.KIND_APPLIED -> "applied"
GitStatusEvent.KIND_CLOSED -> "closed"
GitStatusEvent.KIND_DRAFT -> "draft"
null -> "open"
else -> "open"
}
/** A compact `--json`-friendly summary of an issue / patch / PR event. */
fun targetSummary(event: Event): Map<String, Any?> {
val base =
mutableMapOf<String, Any?>(
"event_id" to event.id,
"kind" to event.kind,
"author" to event.pubKey,
"created_at" to event.createdAt,
)
when (event) {
is GitIssueEvent -> {
base["type"] = "issue"
base["subject"] = event.subject()
base["labels"] = event.topics()
}
is GitPatchEvent -> {
base["type"] = "patch"
base["subject"] = event.subject()
base["commit"] = event.commit()
base["parent_commit"] = event.parentCommit()
base["root"] = event.isRoot()
}
is GitPullRequestEvent -> {
base["type"] = "pull-request"
base["subject"] = event.subject()
base["current_commit"] = event.currentCommit()
base["clone"] = event.cloneUrls()
base["branch_name"] = event.branchName()
base["labels"] = event.labels()
}
is GitPullRequestUpdateEvent -> {
base["type"] = "pull-request-update"
base["current_commit"] = event.currentCommit()
}
}
return base
}
}

View File

@@ -6,3 +6,4 @@ clink/state-clink-headless/
relaygroup/state-relaygroup-headless/
sync/state-sync-deletions/
blossom/state-blossom-live/
git/state-git-nip34/

View File

@@ -2,7 +2,7 @@
Shell-based end-to-end harnesses that drive the `amy` CLI binary — against a
loopback `nostr-rs-relay`, an embedded `amy serve` relay, live public servers,
or no relay at all, depending on the suite. Ten directories:
or no relay at all, depending on the suite. Eleven directories:
```
cli/tests/
@@ -19,6 +19,8 @@ cli/tests/
│ ├── dm-interop-headless.sh
│ ├── setup.sh # preflight + identities
│ └── tests-dm.sh
├── git/ # NIP-34 git collaboration vs embedded `amy serve`
│ └── git-nip34-headless.sh
├── marmot/ # Marmot / MLS group-messaging interop
│ ├── marmot-interop.sh # interactive — prompts Amethyst Android UI
│ ├── marmot-interop-headless.sh # zero-prompt
@@ -65,6 +67,19 @@ Suite notes:
- **`sync/sync-deletions-headless.sh`** proves NIP-77 deletion propagation
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`: `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), NIP-32 label (1985), and status events (1630-1633). It also
publishes a real `git format-patch` and `git apply`s it back into a scratch
working tree, and 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:

View File

@@ -0,0 +1,331 @@
#!/usr/bin/env bash
#
# git-nip34-headless.sh — drives the real `amy` binary against a real
# `amy serve` relay to prove the NIP-34 (git-over-nostr) collaboration surface
# end-to-end: repository announcement + state, patches, pull requests, issues,
# NIP-22 comments, and status events — plus the status-deriving reads.
#
# The verbs mirror the pure-Nostr surface of `ngit` and `nak git`. The git
# packfile transport (clone/fetch/push of real objects) is intentionally out of
# scope, so this harness only exercises the events amy publishes and reads back.
#
# Flow (single maintainer account against one relay):
# announce (30617) → state (30618) → issue (1621) → patch (1617) →
# pr (1618) → pr-update (1619) → comment (1111) → close the issue (1632) →
# mark the pr applied (1631) → list issues/patches/prs (status derived) →
# thread view (status timeline + comments).
#
# Usage: ./git-nip34-headless.sh [--port N] [--no-build]
set -uo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
TESTS_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
STATE_DIR="$SCRIPT_DIR/state-git-nip34"
LOG_DIR="$STATE_DIR/logs"
RUN_TS="$(date +%Y%m%d-%H%M%S)"
LOG_FILE="$LOG_DIR/run-$RUN_TS.log"
RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv"
AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
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
done
rm -rf "$STATE_DIR"
mkdir -p "$LOG_DIR"
: >"$RESULTS_FILE"
# shellcheck source=../lib.sh
source "$TESTS_DIR/lib.sh"
assert_eq() {
local actual="$1" expected="$2" test_id="$3" note="${4:-}"
if [[ "${actual// /}" == "${expected// /}" ]]; then
info "assert: $test_id \"$actual\" == \"$expected\""
record_result "$test_id" pass "$note"
return 0
fi
fail_msg "$test_id: expected \"$expected\", got \"$actual\" (${note:-})"
record_result "$test_id" fail "${note:-mismatch}"
return 1
}
assert_nonempty() {
local actual="$1" test_id="$2" note="${3:-}"
if [[ -n "$actual" && "$actual" != "null" ]]; then
info "assert: $test_id nonempty (\"$actual\")"
record_result "$test_id" pass "$note"
return 0
fi
fail_msg "$test_id: expected a value, got empty/null (${note:-})"
record_result "$test_id" fail "${note:-empty}"
return 1
}
SERVE_PID=""
cleanup() {
[[ -n "$SERVE_PID" ]] && kill "$SERVE_PID" 2>/dev/null
trap - EXIT INT TERM HUP
print_summary
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
banner "amy git — NIP-34 collaboration headless ($RUN_TS)"
# ---- build ------------------------------------------------------------------
if [[ "$NO_BUILD" -eq 0 ]]; then
step "Building amy (installDist)…"
(cd "$REPO_ROOT" && ./gradlew -q :cli:installDist) >>"$LOG_FILE" 2>&1 \
|| { fail_msg "build failed (see $LOG_FILE)"; exit 1; }
fi
[[ -x "$AMY_BIN" ]] || { fail_msg "amy binary not found at $AMY_BIN"; exit 1; }
strip() { grep -vE "Picked up JAVA_TOOL|DEBUG:|INFO:"; }
mk_home() { mktemp -d "$STATE_DIR/home.XXXXXX"; }
# amy_run <home> <account> args...
amy_run() {
local home="$1" acct="$2"; shift 2
HOME="$home" "$AMY_BIN" --account "$acct" --secret-backend plaintext --json "$@" 2>>"$LOG_FILE" | strip
}
M_HOME="$(mk_home)"
amy_run "$M_HOME" m init >/dev/null
M() { amy_run "$M_HOME" m "$@"; }
step "Starting amy serve on $RELAY_URL"
HOME="$M_HOME" "$AMY_BIN" --account m --secret-backend plaintext \
serve --host "$RELAY_HOST" --port "$RELAY_PORT" >>"$LOG_FILE" 2>&1 &
SERVE_PID=$!
for _ in $(seq 1 60); do
grep -q "relay up at" "$LOG_FILE" && break
sleep 0.5
done
grep -q "relay up at" "$LOG_FILE" || { fail_msg "relay did not come up"; exit 1; }
# =============================================================================
# git init — bootstrap from a local (full, non-shallow) git checkout
# =============================================================================
banner "git init (from a full scratch checkout)"
INITREPO="$(mk_home)/initrepo"
git init -q "$INITREPO"
git -C "$INITREPO" config user.email a@b.c
git -C "$INITREPO" config user.name t
echo "hello" >"$INITREPO/README.md"
git -C "$INITREPO" add README.md
git -C "$INITREPO" commit -qm "initial commit"
ROOT_COMMIT="$(git -C "$INITREPO" rev-list --max-parents=0 --first-parent HEAD | tail -1)"
INIT="$(M git init --repo "$INITREPO" --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_eq "$(echo "$INIT" | jq -r '.earliest_commit')" "$ROOT_COMMIT" init.euc "earliest-unique-commit is the first-parent root"
assert_nonempty "$(echo "$INIT" | jq -r '.state_event_id')" init.state "init also published a 30618 state event"
# A SHALLOW clone must NOT invent an euc (it can't know the true root).
SHALLOWREPO="$(mk_home)/shallowrepo"
git clone -q --depth 1 "file://$INITREPO" "$SHALLOWREPO" 2>/dev/null || git clone -q --depth 1 "$INITREPO" "$SHALLOWREPO"
if [[ "$(git -C "$SHALLOWREPO" rev-parse --is-shallow-repository)" == "true" ]]; then
SINIT="$(M git init --repo "$SHALLOWREPO" --d shallow-test --relay "$RELAY_URL")"
assert_eq "$(echo "$SINIT" | jq -r '.earliest_commit')" "null" init.shallow_euc "shallow clone omits the euc (no wrong identity)"
else
skip_msg "could not create a shallow clone for init.shallow_euc"
fi
# =============================================================================
# Repository announcement (30617) + state (30618)
# =============================================================================
banner "announce + state"
ANN="$(M git announce --name demo-repo --description "a demo" \
--clone https://example.com/demo.git --earliest-commit abc123 --relay "$RELAY_URL")"
info "announce: $ANN"
ADDR="$(echo "$ANN" | jq -r '.address')"
assert_nonempty "$ADDR" announce.address "kind:pubkey:id coordinate returned"
assert_eq "$(echo "$ANN" | jq -r '.published_to | length')" "1" announce.published "relay accepted 30617"
STATE="$(M git state "$ADDR" --head main --branch main=deadbeef,dev=cafe00 --tag v1.0=abc123 --relay "$RELAY_URL")"
info "state: $STATE"
assert_eq "$(echo "$STATE" | jq -r '.branches')" "2" state.branches "two branch refs"
assert_eq "$(echo "$STATE" | jq -r '.head')" "main" state.head "HEAD=main"
# --- ngit interop: wire-format checks --------------------------------------
banner "ngit interop wire format"
# Announce a repo with TWO clone URLs; NIP-34/ngit want ONE multi-value tag.
IADDR="$(M git announce --name interop --clone https://a.git,https://b.git --web https://a.com,https://b.com --earliest-commit abc123 --relay "$RELAY_URL" | jq -r '.address')"
IEID="$(M git show "$IADDR" --relay "$RELAY_URL" | jq -r '.event_id')"
ITAGS="$(M fetch --id "$IEID" --relay "$RELAY_URL")"
assert_eq "$(echo "$ITAGS" | jq -r '[.events[0].tags[] | select(.[0]=="clone")] | length')" "1" interop.clone_single "clone is one tag (not repeated)"
assert_eq "$(echo "$ITAGS" | jq -r '.events[0].tags[] | select(.[0]=="clone") | length')" "3" interop.clone_multivalue "clone tag carries both URLs (name + 2 values)"
# The tolerant reader must round-trip both URLs back.
assert_eq "$(M git show "$IADDR" --relay "$RELAY_URL" | jq -r '.clone | length')" "2" interop.clone_read "reader returns both clone URLs"
# Issue must p-tag the repository owner (maintainer routing).
IISS="$(M git issue "$IADDR" --subject "interop" "b" --relay "$RELAY_URL" | jq -r '.event_id')"
IOWNER="$(echo "$IADDR" | cut -d: -f2)"
assert_eq "$(M fetch --id "$IISS" --relay "$RELAY_URL" | jq -r "[.events[0].tags[] | select(.[0]==\"p\" and .[1]==\"$IOWNER\")] | length")" "1" interop.issue_ptag "issue carries the repo owner p tag"
# A PR (1618) also carries clone URLs as ONE multi-value tag (same fix as 30617).
IPR="$(M git pr "$IADDR" --commit tip1 --clone https://c1.git,https://c2.git --subject s --relay "$RELAY_URL" | jq -r '.event_id')"
IPRTAGS="$(M fetch --id "$IPR" --relay "$RELAY_URL")"
assert_eq "$(echo "$IPRTAGS" | jq -r '[.events[0].tags[] | select(.[0]=="clone")] | length')" "1" interop.pr_clone_single "PR clone is one tag"
assert_eq "$(echo "$IPRTAGS" | jq -r '.events[0].tags[] | select(.[0]=="clone") | length')" "3" interop.pr_clone_multivalue "PR clone tag carries both URLs"
# GRASP server list (10317) round-trip.
GRASP="$(M git grasp set "wss://grasp.example.com,wss://grasp2.example.com" --relay "$RELAY_URL")"
assert_eq "$(echo "$GRASP" | jq -r '.kind')" "10317" grasp.kind "grasp list is kind 10317"
GRASP_READ="$(M git grasp list --relay "$RELAY_URL")"
assert_eq "$(echo "$GRASP_READ" | jq -r '.count')" "2" grasp.count "two grasp servers read back"
assert_eq "$(echo "$GRASP_READ" | jq -r '.grasps[0]')" "wss://grasp.example.com" grasp.order "preference order preserved"
# =============================================================================
# Issue (1621) + patch (1617) + PR (1618) + PR update (1619)
# =============================================================================
banner "issue / patch / pr / pr-update"
ISS="$(M git issue "$ADDR" --subject "First bug" "the issue body" --relay "$RELAY_URL")"
ISSID="$(echo "$ISS" | jq -r '.event_id')"
assert_eq "$(echo "$ISS" | jq -r '.kind')" "1621" issue.kind "issue is kind 1621"
assert_nonempty "$ISSID" issue.id "issue event id"
PATCH="$(printf 'From abc\nSubject: [PATCH] fix the thing\n\ndiff --git a/x b/x\n' \
| M git patch "$ADDR" --root --commit deadbeef --relay "$RELAY_URL")"
info "patch: $PATCH"
assert_eq "$(echo "$PATCH" | jq -r '.kind')" "1617" patch.kind "patch is kind 1617"
assert_eq "$(echo "$PATCH" | jq -r '.subject')" "fix the thing" patch.subject "subject parsed from format-patch"
PR="$(M git pr "$ADDR" --commit feed01 --clone https://example.com/demo.git \
--subject "add feature" "pr description" --relay "$RELAY_URL")"
PRID="$(echo "$PR" | jq -r '.event_id')"
assert_eq "$(echo "$PR" | jq -r '.kind')" "1618" pr.kind "pr is kind 1618"
PRUP="$(M git pr-update "$PRID" --commit feed02 --clone https://example.com/demo.git --relay "$RELAY_URL")"
assert_eq "$(echo "$PRUP" | jq -r '.kind')" "1619" prupdate.kind "pr-update is kind 1619"
# =============================================================================
# Comment (1111) + status events (1632 close, 1631 applied)
# =============================================================================
banner "comment + status"
CMT="$(M git comment "$ISSID" "thanks for reporting" --relay "$RELAY_URL")"
assert_eq "$(echo "$CMT" | jq -r '.kind')" "1111" comment.kind "comment is NIP-22 kind 1111"
LABEL="$(M git label "$ISSID" "bug,help-wanted" --relay "$RELAY_URL")"
assert_eq "$(echo "$LABEL" | jq -r '.kind')" "1985" label.kind "label is NIP-32 kind 1985"
assert_eq "$(echo "$LABEL" | jq -r '.labels | length')" "2" label.count "two labels attached"
CLOSE="$(M git close "$ISSID" "wontfix" --relay "$RELAY_URL")"
assert_eq "$(echo "$CLOSE" | jq -r '.kind')" "1632" close.kind "close status is kind 1632"
APPLIED="$(M git applied "$PRID" "merged it" --merge-commit feed99 --commit feed02 --relay "$RELAY_URL")"
assert_eq "$(echo "$APPLIED" | jq -r '.kind')" "1631" applied.kind "applied status is kind 1631"
# =============================================================================
# git apply — publish a real patch and apply it to a local working tree
# =============================================================================
banner "git apply (nostr patch → local git am)"
SCRATCH="$(mk_home)/scratch"
git init -q "$SCRATCH"
git -C "$SCRATCH" config user.email a@b.c
git -C "$SCRATCH" config user.name t
echo "line1" >"$SCRATCH/f.txt"
git -C "$SCRATCH" add f.txt
git -C "$SCRATCH" commit -qm "init"
# Make a real commit, capture its format-patch, then roll it back so `apply` can re-add it.
echo "line2" >>"$SCRATCH/f.txt"
git -C "$SCRATCH" commit -qam "add line2"
PATCHTXT="$(git -C "$SCRATCH" format-patch -1 --stdout)"
git -C "$SCRATCH" reset -q --hard HEAD~1
APATCH="$(printf '%s' "$PATCHTXT" | M git patch "$ADDR" --root --relay "$RELAY_URL")"
APID="$(echo "$APATCH" | jq -r '.event_id')"
CHECK="$(M git apply "$APID" --check --repo "$SCRATCH")"
assert_eq "$(echo "$CHECK" | jq -r '.mode')" "check" apply.check "apply --check dry-runs cleanly"
APPLY="$(M git apply "$APID" --repo "$SCRATCH")"
info "apply: $APPLY"
assert_eq "$(echo "$APPLY" | jq -r '.applied')" "true" apply.applied "patch applied via git am"
assert_eq "$(git -C "$SCRATCH" log --oneline | head -1 | sed 's/^[0-9a-f]* //')" "add line2" apply.commit "commit landed in the working tree"
# =============================================================================
# Status-deriving reads
# =============================================================================
banner "reads derive status"
ISSUES="$(M git issues "$ADDR" --relay "$RELAY_URL")"
info "issues: $ISSUES"
assert_eq "$(echo "$ISSUES" | jq -r '.items[0].status')" "closed" issues.status "closed issue reads as closed"
PRS="$(M git prs "$ADDR" --relay "$RELAY_URL")"
assert_eq "$(echo "$PRS" | jq -r '.items[0].status')" "applied" prs.status "applied pr reads as applied"
PATCHES="$(M git patches "$ADDR" --relay "$RELAY_URL")"
assert_eq "$(echo "$PATCHES" | jq -r '.items[0].status')" "open" patches.status "un-statused patch reads as open"
# --status filter: closed issues present, open issues empty.
FILTERED="$(M git issues "$ADDR" --closed --relay "$RELAY_URL")"
assert_eq "$(echo "$FILTERED" | jq -r '.count')" "1" issues.filter_closed "--closed keeps the closed issue"
OPEN_ONLY="$(M git issues "$ADDR" --open --relay "$RELAY_URL")"
assert_eq "$(echo "$OPEN_ONLY" | jq -r '.count')" "0" issues.filter_open "--open drops the closed issue"
# =============================================================================
# Thread view
# =============================================================================
banner "thread view"
THREAD="$(M git thread "$ISSID" --relay "$RELAY_URL")"
info "thread: $THREAD"
assert_eq "$(echo "$THREAD" | jq -r '.status')" "closed" thread.status "thread shows closed"
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"
# ngit interop against the REAL amethyst repo (published by ngit to relay.ngit.dev).
# Proves our reader parses ngit's actual multi-value `clone` tag (4 URLs) — the
# exact case the pre-fix reader collapsed to one.
banner "live: reading the real ngit-published amethyst repo"
NGIT_RELAY="${NGIT_RELAY:-wss://relay.ngit.dev}"
NGIT_REPO="30617:460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c:amethyst"
RSHOW="$(M git show "$NGIT_REPO" --relay "$NGIT_RELAY")"
if [[ -n "$RSHOW" && "$(echo "$RSHOW" | jq -r '.name // empty')" == "amethyst" ]]; then
CLONES="$(echo "$RSHOW" | jq -r '.clone | length')"
if [[ "$CLONES" -ge 2 ]]; then
pass_msg "ngit.clone_multivalue: read $CLONES clone URLs from ngit's multi-value tag"
record_result ngit.clone_multivalue pass "$CLONES clone URLs"
else
fail_msg "ngit.clone_multivalue: only $CLONES clone URL parsed from ngit's multi-value tag"
record_result ngit.clone_multivalue fail "expected >=2, got $CLONES"
fi
RISS="$(M git issues "$NGIT_REPO" --relay "$NGIT_RELAY" --limit 1 | jq -r '.count')"
assert_nonempty "$RISS" ngit.issues "read ngit-published issues"
else
skip_msg "ngit live repo unreachable (relay.ngit.dev) — skipping real-repo interop check"
fi
else
skip_msg "live git smart-HTTP reads (browse/cat/log) + real ngit repo — pass --live to run"
fi
grep -q $'\tfail\t' "$RESULTS_FILE" && exit 1
exit 0

View File

@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.store.sqlite.DefaultIndexingStrategy
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
@@ -42,6 +43,18 @@ class LocalRelayStore(
) : AutoCloseable {
companion object {
val LOCAL_RELAY_URL: NormalizedRelayUrl = NormalizedRelayUrl("ws://localhost/amethyst-local/")
/**
* Client defaults plus the authors-without-kinds index: shared
* ViewModels (`Nip65RelayListViewModel`, `PrivateOutboxRelayListViewModel`,
* `VanishRequestsState`) replay `authors`-only filters against this
* store, which full-scan without `(pubkey, created_at)` — the
* `(kind, pubkey, …)` index can't serve them, pubkey is its second
* column. A personal store is small, so the extra insert cost is
* negligible; existing DBs get the index built on next open via
* `ensureOptionalIndexes`.
*/
val INDEX_STRATEGY = DefaultIndexingStrategy(indexEventsByPubkeyAlone = true)
}
private fun dbDir(pubKeyHex: String): File = File(homeDir, ".amethyst/accounts/${pubKeyHex.take(8)}")
@@ -87,14 +100,14 @@ class LocalRelayStore(
dir.mkdirs()
val path = File(dir, "events.db").absolutePath
try {
store = EventStore(dbName = path, relay = LOCAL_RELAY_URL)
store = EventStore(dbName = path, relay = LOCAL_RELAY_URL, indexStrategy = INDEX_STRATEGY)
_lastError.value = null
refreshStats()
} catch (e: Exception) {
Log.w("LocalRelayStore") { "DB open failed, recreating: ${e.message}" }
try {
deleteDbFiles(path)
store = EventStore(dbName = path, relay = LOCAL_RELAY_URL)
store = EventStore(dbName = path, relay = LOCAL_RELAY_URL, indexStrategy = INDEX_STRATEGY)
_lastError.value = "Database was recreated: ${e.message}"
} catch (e2: Exception) {
_lastError.value = "Cannot open local store: ${e2.message}"

View File

@@ -57,6 +57,14 @@ fun relayIndexingStrategy(
// index unconditionally; without it the filter walks the whole
// time index.
indexEventsByPubkeyAlone = true,
// The tag ∩ author ∩ kind shape (DM rooms, reports-by-follows,
// follows-scoped community feeds — 65 client assembler call sites)
// otherwise reads every row for the tag/kind before filtering the
// author. TagAuthorIndexBenchmark @ 1M events: 14.2 ms -> 0.66 ms
// (~21x, growing with corpus size) with insert cost inside run noise
// (49.0 vs 47.4 µs/event). Existing DBs build the index on next open
// via ensureOptionalIndexes.
indexTagsWithKindAndPubkey = true,
indexFullTextSearch = fullTextSearch,
// Tokenize off the commit path; NostrServer drives the catch-up
// worker and search queries drain it first, so NIP-50 stays

View File

@@ -83,6 +83,8 @@ val store = EventStore(
By default, all single-letter tags with values are indexed. Override `shouldIndex(kind, tag)` for custom behavior. More indexes = faster queries but larger database.
Flag flips are safe on existing databases: any flag-gated index the strategy wants but the on-disk schema lacks is created on the next open (idempotent `CREATE INDEX IF NOT EXISTS`, one-time build cost) — no schema version bump involved. Disabling a flag never drops an existing index.
`indexFullTextSearch` defaults to `true` and controls the NIP-50 full-text index (`event_fts`). Set it to `false` when search is served elsewhere (e.g. a Vespa backend, or a `SearchEventSource` as shown below): inserts skip the FTS tokenization cost, no `event_fts` table/trigger is created, and any filter carrying a non-empty `search` term returns no matches.
## Non-Storage Relays (search, redirector, computed)

View File

@@ -94,6 +94,8 @@ kotlin {
// Forward the negentropy-benchmark corpus size to the test JVM.
System.getProperty("negBenchN")?.let { systemProperty("negBenchN", it) }
System.getProperty("followBenchScale")?.let { systemProperty("followBenchScale", it) }
System.getProperty("tagBenchScale")?.let { systemProperty("tagBenchScale", it) }
System.getProperty("fsBenchScale")?.let { systemProperty("fsBenchScale", it) }
// Opt-in JFR profiling of a benchmark run (-PnegProfile=/tmp/neg.jfr).
(project.findProperty("negProfile") as? String)?.let {
jvmArgs("-XX:+FlightRecorder", "-XX:StartFlightRecording=filename=$it,settings=profile,dumponexit=true")

View File

@@ -25,6 +25,22 @@ class EoseMessage(
) : Message {
override fun label() = LABEL
/**
* Wire form is `["EOSE","<subId>"]` — sent once per REQ, so it is on
* the per-subscription floor. Splice it directly when [subId] needs no
* escaping (the common case: client-chosen sub ids are short ASCII),
* skipping the generic serializer's node tree. Byte-identical output;
* any exotic subId falls back.
*/
override fun toJson(): String {
if (!isEscapeFreeAscii(subId)) return super.toJson()
return buildString(subId.length + 12) {
append("[\"EOSE\",\"")
append(subId)
append("\"]")
}
}
companion object {
const val LABEL = "EOSE"
}

View File

@@ -29,6 +29,25 @@ class OkMessage(
) : Message {
override fun label() = LABEL
/**
* Wire form is `["OK","<eventId>",<true|false>,"<message>"]` — sent
* once per published EVENT. [eventId] is validated hex (always
* escape-free); splice directly when [message] also needs no escaping,
* which covers the empty-string success ack and the plain-ASCII
* rejection reasons. Byte-identical output; a reason with quotes or
* non-ASCII falls back to the generic serializer.
*/
override fun toJson(): String {
if (!isEscapeFreeAscii(message)) return super.toJson()
return buildString(eventId.length + message.length + 20) {
append("[\"OK\",\"")
append(eventId)
append(if (success) "\",true,\"" else "\",false,\"")
append(message)
append("\"]")
}
}
companion object {
const val LABEL = "OK"

View File

@@ -0,0 +1,36 @@
/*
* 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.quartz.nip01Core.relay.commands.toClient
/**
* True when every char of [s] is printable ASCII (0x200x7e) and not a JSON
* metacharacter (`"` / `\`) — i.e. exactly the bytes a JSON string encoder
* would emit verbatim between the quotes. Frame builders use this to gate a
* direct-`buildString` fast path against the generic serializer: when it holds
* the spliced output is byte-identical, and any exotic value (control chars,
* quotes, non-ASCII) falls back to the escaping serializer.
*/
internal fun isEscapeFreeAscii(s: String): Boolean {
for (c in s) {
if (c < ' ' || c > '~' || c == '"' || c == '\\') return false
}
return true
}

View File

@@ -22,6 +22,11 @@ package com.vitorpamplona.quartz.nip01Core.relay.filters
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.collections.immutable.PersistentMap
import kotlinx.collections.immutable.PersistentSet
import kotlinx.collections.immutable.persistentHashMapOf
import kotlinx.collections.immutable.persistentHashSetOf
import kotlinx.collections.immutable.toPersistentHashSet
import kotlin.concurrent.atomics.AtomicReference
import kotlin.concurrent.atomics.ExperimentalAtomicApi
@@ -62,9 +67,10 @@ import kotlin.concurrent.atomics.ExperimentalAtomicApi
* copy-on-write CAS loops, mirroring the
* `nip86RelayManagement.server.BanStore` pattern. Reads in
* [candidatesFor] and [forEach] are wait-free single-load atomic.
* Writes (subscription register / unregister) copy the inner maps
* — fine for this workload because writes are subscription-rate
* (rare) while reads are event-rate (frequent).
* Writes (subscription register / unregister) build the next snapshot
* from persistent (HAMT) maps — O(keys × log S) with structural
* sharing rather than a full O(S) copy of both maps, since on a relay
* a write happens on every REQ open and close.
*
* ## What the index does NOT cover
*
@@ -109,14 +115,26 @@ class FilterIndex<S : Any> {
private object Unindexed : BucketKey
/**
* Single immutable snapshot. [buckets] maps a key to the set of
* subscribers registered under it; [assignments] is the reverse
* map used by [unregister] to find a subscriber's keys without
* scanning every bucket.
* Single immutable snapshot. Subscribers are held in one map per
* indexable dimension so [candidatesFor] — called once per accepted
* ingest event, the hot read — can probe each dimension with the
* event's own field (`event.id`, `event.pubKey`, `tag[0]`/`tag[1]`,
* `event.kind`) and allocate no key-wrapper objects. [assignments] is
* the reverse map ([S] → the [BucketKey]s it occupies) used by
* [unregister]; the wrappers live only here, built on the rare
* register path.
*
* Persistent (HAMT) maps/sets: a register/unregister produces the
* next snapshot in O(keys × log S) with structural sharing, instead
* of copying full maps — registration happens on every REQ open/close.
*/
private data class State<S>(
val buckets: Map<BucketKey, Set<S>> = emptyMap(),
val assignments: Map<S, Set<BucketKey>> = emptyMap(),
val ids: PersistentMap<HexKey, PersistentSet<S>> = persistentHashMapOf(),
val authors: PersistentMap<HexKey, PersistentSet<S>> = persistentHashMapOf(),
val tags: PersistentMap<String, PersistentMap<String, PersistentSet<S>>> = persistentHashMapOf(),
val kinds: PersistentMap<Int, PersistentSet<S>> = persistentHashMapOf(),
val unindexed: PersistentSet<S> = persistentHashSetOf(),
val assignments: PersistentMap<S, PersistentSet<BucketKey>> = persistentHashMapOf(),
)
private val state: AtomicReference<State<S>> = AtomicReference(State())
@@ -181,18 +199,23 @@ class FilterIndex<S : Any> {
while (true) {
val current = state.load()
val keys = current.assignments[subscriber] ?: return
val newBuckets = current.buckets.toMutableMap()
var ids = current.ids
var authors = current.authors
var tags = current.tags
var kinds = current.kinds
var unindexed = current.unindexed
for (key in keys) {
val cur = newBuckets[key] ?: continue
val next = cur - subscriber
if (next.isEmpty()) {
newBuckets.remove(key)
} else {
newBuckets[key] = next
when (key) {
is IdKey -> ids = ids.removeSub(key.id, subscriber)
is AuthorKey -> authors = authors.removeSub(key.author, subscriber)
is KindKey -> kinds = kinds.removeSub(key.kind, subscriber)
is TagKey -> tags = tags.removeTagSub(key.letter, key.value, subscriber)
Unindexed -> unindexed = unindexed.remove(subscriber)
}
}
val newAssignments = current.assignments - subscriber
if (state.compareAndSet(current, State(newBuckets, newAssignments))) return
val next =
State(ids, authors, tags, kinds, unindexed, current.assignments.remove(subscriber))
if (state.compareAndSet(current, next)) return
}
}
@@ -202,19 +225,22 @@ class FilterIndex<S : Any> {
* candidate to handle negative constraints.
*
* Iteration order is insertion-stable per call but otherwise
* unspecified.
* unspecified. Allocates only the result set — dimensions are
* probed with the event's own fields, no key wrappers.
*/
fun candidatesFor(event: Event): Set<S> {
val s = state.load()
if (s.buckets.isEmpty()) return emptySet()
if (s.assignments.isEmpty()) return emptySet()
val result = LinkedHashSet<S>()
s.buckets[Unindexed]?.let { result.addAll(it) }
s.buckets[IdKey(event.id)]?.let { result.addAll(it) }
s.buckets[AuthorKey(event.pubKey)]?.let { result.addAll(it) }
s.buckets[KindKey(event.kind)]?.let { result.addAll(it) }
for (tag in event.tags) {
if (tag.size >= 2 && tag[0].length == 1) {
s.buckets[TagKey(tag[0], tag[1])]?.let { result.addAll(it) }
if (s.unindexed.isNotEmpty()) result.addAll(s.unindexed)
s.ids[event.id]?.let { result.addAll(it) }
s.authors[event.pubKey]?.let { result.addAll(it) }
s.kinds[event.kind]?.let { result.addAll(it) }
if (s.tags.isNotEmpty()) {
for (tag in event.tags) {
if (tag.size >= 2 && tag[0].length == 1) {
s.tags[tag[0]]?.get(tag[1])?.let { result.addAll(it) }
}
}
}
return result
@@ -235,19 +261,75 @@ class FilterIndex<S : Any> {
keys: List<BucketKey>,
) {
if (keys.isEmpty()) return
val keySet = keys.toSet()
val keySet = keys.toPersistentHashSet()
while (true) {
val current = state.load()
val newBuckets = current.buckets.toMutableMap()
var ids = current.ids
var authors = current.authors
var tags = current.tags
var kinds = current.kinds
var unindexed = current.unindexed
for (key in keySet) {
val cur = newBuckets[key] ?: emptySet()
if (subscriber in cur) continue
newBuckets[key] = cur + subscriber
when (key) {
is IdKey -> ids = ids.addSub(key.id, subscriber)
is AuthorKey -> authors = authors.addSub(key.author, subscriber)
is KindKey -> kinds = kinds.addSub(key.kind, subscriber)
is TagKey -> tags = tags.addTagSub(key.letter, key.value, subscriber)
Unindexed -> unindexed = unindexed.add(subscriber)
}
}
val existing = current.assignments[subscriber]
val merged = if (existing == null) keySet else existing + keySet
val newAssignments = current.assignments + (subscriber to merged)
if (state.compareAndSet(current, State(newBuckets, newAssignments))) return
val merged = existing?.addAll(keySet) ?: keySet
val next = State(ids, authors, tags, kinds, unindexed, current.assignments.put(subscriber, merged))
if (state.compareAndSet(current, next)) return
}
}
// Per-dimension add/remove of one subscriber, returning the same map
// instance when nothing changed so the CAS builds minimal new nodes.
private fun <K> PersistentMap<K, PersistentSet<S>>.addSub(
key: K,
sub: S,
): PersistentMap<K, PersistentSet<S>> {
val cur = this[key] ?: persistentHashSetOf()
val next = cur.add(sub)
return if (next === cur) this else put(key, next)
}
private fun <K> PersistentMap<K, PersistentSet<S>>.removeSub(
key: K,
sub: S,
): PersistentMap<K, PersistentSet<S>> {
val cur = this[key] ?: return this
val next = cur.remove(sub)
return when {
next === cur -> this
next.isEmpty() -> remove(key)
else -> put(key, next)
}
}
private fun PersistentMap<String, PersistentMap<String, PersistentSet<S>>>.addTagSub(
letter: String,
value: String,
sub: S,
): PersistentMap<String, PersistentMap<String, PersistentSet<S>>> {
val inner = this[letter] ?: persistentHashMapOf()
val newInner = inner.addSub(value, sub)
return if (newInner === inner) this else put(letter, newInner)
}
private fun PersistentMap<String, PersistentMap<String, PersistentSet<S>>>.removeTagSub(
letter: String,
value: String,
sub: S,
): PersistentMap<String, PersistentMap<String, PersistentSet<S>>> {
val inner = this[letter] ?: return this
val newInner = inner.removeSub(value, sub)
return when {
newInner === inner -> this
newInner.isEmpty() -> remove(letter)
else -> put(letter, newInner)
}
}

View File

@@ -49,6 +49,7 @@ import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.ClosedSendChannelException
import kotlinx.coroutines.launch
@@ -291,8 +292,16 @@ class RelaySession(
// Policy may rewrite filters to match the user's access level.
val filters = (result as PolicyResult.Accepted).cmd.filters
// UNDISPATCHED: the stored replay runs inline on this coroutine —
// the reader-pool acquire doesn't suspend when a connection is
// free, so EVENT frames and EOSE go out without a scheduler hop
// (SmallReqFloorBenchmark: the hop was most of the dispatch
// slice on small REQs). The coroutine first parks at the live
// tail (awaitCancellation), which is when launch returns and the
// job lands in [subscriptions]; commands on this connection are
// processed sequentially, so nothing can target the sub earlier.
val job =
scope.launch {
scope.launch(start = CoroutineStart.UNDISPATCHED) {
try {
if (policy.filtersOutgoingEvents) {
// Screened path: every event is materialized so the
@@ -331,7 +340,20 @@ class RelaySession(
},
)
},
onEachLive = { event -> send(EventMessage(cmd.subId, event)) },
// Live events arrive with their wire body already
// serialized (once per event, shared across every
// matching subscription): splice it into the same
// per-sub frame prefix as the stored replay, no
// per-event EventMessage or re-serialize.
onEachLive = { _, body ->
sendRaw(
buildString(framePrefix.length + body.length + 1) {
append(framePrefix)
append(body)
append(']')
},
)
},
onEose = { send(EoseMessage(cmd.subId)) },
)
}

View File

@@ -74,13 +74,57 @@ class LiveEventStore(
* One live REQ subscription. Carries the filters (for the
* post-index `match` re-check needed for negative constraints
* like `since` / `until` / `tagsAll`) and the delivery callback
* the index dispatches into. Identity-keyed inside [FilterIndex].
* the index dispatches into. [deliver] receives the event and its
* pre-serialized wire body (memoized once per fanout across all
* matching subscribers). Identity-keyed inside [FilterIndex].
*/
private class LiveSubscription(
val filters: List<Filter>,
val deliver: (Event) -> Unit,
val deliver: (Event, String) -> Unit,
)
/**
* Replay-dedupe set for one REQ. During the historical replay the
* store's ids are [record]ed here so the concurrent live path can
* drop an event the replay also emitted; after EOSE the set is
* [release]d and the live path forwards everything.
*
* Written from the replay coroutine and read from the [IngestQueue]
* drain coroutine (via `fanout`), so every access takes a tiny spin
* lock — [locked] is `inline`, so the per-row `record` / `isDuplicate`
* calls allocate no closure. The backing `HashSet` is created empty
* up front (so the register-before-replay race guarantee holds) but
* the JVM defers its table allocation to the first `add`, so a
* zero-row replay costs only the empty set object, not a sized table.
* It MUST stay a mutable set under a lock, never a copy-on-add
* immutable set — `set + id` per row made large replays O(n²).
*/
private class SeenIds {
private val lock = AtomicBoolean(false)
private var ids: HashSet<String>? = HashSet()
private inline fun <R> locked(block: () -> R): R {
while (lock.exchange(true)) {
while (lock.load()) { }
}
try {
return block()
} finally {
lock.store(false)
}
}
fun record(id: String) {
locked { ids?.add(id) }
}
fun isDuplicate(id: String): Boolean = locked { ids?.contains(id) ?: false }
fun release() {
locked { ids = null }
}
}
/**
* Fire-and-forget enqueue: hand [event] to the [IngestQueue] and
* fire [onComplete] once the writer's batch has a per-row
@@ -149,9 +193,17 @@ class LiveEventStore(
* batch writer.
*/
private fun fanout(event: Event) {
for (sub in index.candidatesFor(event)) {
val candidates = index.candidatesFor(event)
if (candidates.isEmpty()) return
// Serialize the wire body at most once for this event, no matter
// how many subscriptions match it — the old path re-serialized the
// whole event per matching subscriber, so a note landing in N live
// feeds paid N identical Jackson passes. Lazy so a fanout that
// matches nothing (index over-approximates) serializes nothing.
var body: String? = null
for (sub in candidates) {
if (sub.filters.any { it.match(event) }) {
sub.deliver(event)
sub.deliver(event, body ?: event.toJson().also { body = it })
}
}
}
@@ -178,61 +230,33 @@ class LiveEventStore(
onEose: () -> Unit,
) {
drainFtsIfSearching(filters)
// During the historical replay, record ids the store has
// emitted so the live path can dedupe. The index registers
// *before* the replay starts (otherwise an event accepted
// mid-replay would slip past the live path entirely — same
// race the previous SharedFlow-based implementation closed
// with `onSubscription`).
//
// The set is read from the [IngestQueue] drain coroutine (in
// `deliver`, called synchronously from `fanout`) and written
// from this coroutine (the historical-replay closure below),
// so access is guarded by a tiny spin lock (contains/add,
// never I/O). It MUST be a mutable set under a lock, not an
// immutable Set under an AtomicReference with copy-on-add:
// `set + id` copies the whole set per streamed event, which
// made large replays accidentally O(n²) — a 100k-event REQ
// crawled at ~700 events/s and the rate degraded as the
// response grew (see the plan doc's giant-REQ finding).
//
// Once cleared to null after EOSE, `deliver` short-circuits
// and every live event is forwarded.
val seenLock = AtomicBoolean(false)
var seenIds: HashSet<String>? = HashSet(1024)
fun <R> seenLocked(block: () -> R): R {
while (seenLock.exchange(true)) {
while (seenLock.load()) { }
}
try {
return block()
} finally {
seenLock.store(false)
}
}
// The index registers *before* the replay starts (otherwise an
// event accepted mid-replay would slip past the live path entirely
// — same race the previous SharedFlow-based implementation closed
// with `onSubscription`), and [SeenIds] bridges the two coroutines:
// the replay records ids here, the live `deliver` drops duplicates,
// and after EOSE the set is released so every live event forwards.
val seen = SeenIds()
val sub =
LiveSubscription(
filters = filters,
deliver = { event ->
val duplicate = seenLocked { seenIds?.contains(event.id) ?: false }
if (duplicate) return@LiveSubscription
onEach(event)
deliver = { event, _ ->
if (!seen.isDuplicate(event.id)) onEach(event)
},
)
index.register(filters, sub)
try {
store.query<Event>(filters.strippingSearchExtensions()) { event ->
seenLocked { seenIds?.add(event.id) }
seen.record(event.id)
onEach(event)
}
onEose()
// Drop the dedupe set so the live path stops paying for
// it. From this point the index drives delivery and
// duplicates are no longer possible.
seenLocked { seenIds = null }
seen.release()
// Suspend until the caller's coroutine is cancelled
// (e.g. NIP-01 CLOSE or connection drop). The `finally`
// unregisters from the index.
@@ -255,42 +279,28 @@ class LiveEventStore(
ctx: RequestContext,
filters: List<Filter>,
onEachStored: (RawEvent) -> Unit,
onEachLive: (Event) -> Unit,
onEachLive: (Event, String) -> Unit,
onEose: () -> Unit,
) {
drainFtsIfSearching(filters)
val seenLock = AtomicBoolean(false)
var seenIds: HashSet<String>? = HashSet(1024)
fun <R> seenLocked(block: () -> R): R {
while (seenLock.exchange(true)) {
while (seenLock.load()) { }
}
try {
return block()
} finally {
seenLock.store(false)
}
}
val seen = SeenIds()
val sub =
LiveSubscription(
filters = filters,
deliver = { event ->
val duplicate = seenLocked { seenIds?.contains(event.id) ?: false }
if (duplicate) return@LiveSubscription
onEachLive(event)
deliver = { event, body ->
if (!seen.isDuplicate(event.id)) onEachLive(event, body)
},
)
index.register(filters, sub)
try {
store.rawQuery(filters.strippingSearchExtensions()) { raw ->
seenLocked { seenIds?.add(raw.id) }
seen.record(raw.id)
onEachStored(raw)
}
onEose()
seenLocked { seenIds = null }
seen.release()
awaitCancellation()
} finally {
index.unregister(sub)

View File

@@ -78,9 +78,9 @@ interface SessionBackend {
ctx: RequestContext,
filters: List<Filter>,
onEachStored: (RawEvent) -> Unit,
onEachLive: (Event) -> Unit,
onEachLive: (Event, String) -> Unit,
onEose: () -> Unit,
): Unit = query(ctx, filters, onEachLive, onEose)
): Unit = query(ctx, filters, { onEachLive(it, it.toJson()) }, onEose)
/** Answers a NIP-45 COUNT with an exact cardinality for the caller in [ctx]. */
suspend fun count(

View File

@@ -147,13 +147,38 @@ class EventIndexesModule(
*/
fun migrateV2AddPubkeyIndex(db: SQLiteConnection) {
if (!indexStrategy.indexEventsByPubkeyAlone) return
val orderBy =
if (indexStrategy.useAndIndexIdOnOrderBy) {
"created_at DESC, id ASC"
} else {
"created_at DESC"
}
db.execSQL("CREATE INDEX IF NOT EXISTS query_by_pubkey_created ON event_headers (pubkey, $orderBy)")
db.execSQL("CREATE INDEX IF NOT EXISTS query_by_pubkey_created ON event_headers (pubkey, ${orderByColumns()})")
}
private fun orderByColumns() =
if (indexStrategy.useAndIndexIdOnOrderBy) {
"created_at DESC, id ASC"
} else {
"created_at DESC"
}
/**
* Materializes any flag-gated index the current [indexStrategy] wants
* but the on-disk schema predates. Flags are runtime configuration, not
* schema — a deployment can flip one without a `user_version` bump — so
* this runs idempotently on every open. The first open after enabling a
* flag pays a one-time index build over the existing rows; subsequent
* opens are no-ops. A disabled flag never drops an existing index (that
* stays an operator decision).
*/
fun ensureOptionalIndexes(db: SQLiteConnection) {
if (indexStrategy.indexEventsByCreatedAtAlone) {
db.execSQL("CREATE INDEX IF NOT EXISTS query_by_created_at_id ON event_headers (${orderByColumns()})")
}
if (indexStrategy.indexEventsByPubkeyAlone) {
db.execSQL("CREATE INDEX IF NOT EXISTS query_by_pubkey_created ON event_headers (pubkey, ${orderByColumns()})")
}
if (indexStrategy.indexTagsByCreatedAtAlone) {
db.execSQL("CREATE INDEX IF NOT EXISTS query_by_tags_hash ON event_tags (tag_hash, created_at DESC)")
}
if (indexStrategy.indexTagsWithKindAndPubkey) {
db.execSQL("CREATE INDEX IF NOT EXISTS query_by_tags_hash_kind_pubkey ON event_tags (tag_hash, kind, pubkey_hash, created_at DESC)")
}
}
val sqlInsertHeader =

View File

@@ -74,9 +74,21 @@ interface IndexingStrategy {
* Activate this if you see too many Tag-centric Filters without
* kind AND pubkey at the same time.
*
* This is a rarely used index (reports by your follows or
* NIP-04 DMs for instance) that becomes quite large without
* major gains.
* This shape (reports by your follows, NIP-04 DM rooms, follows-scoped
* community feeds) is not rare on the client side: the 2026-07 filter
* assembler survey counted 65 call sites building
* `kinds + authors + tags`. Without this index the plan seeks
* `(tag_hash, kind)` and reads every row for that tag/kind before
* filtering the author.
*
* Measured by `TagAuthorIndexBenchmark` (jvmTest prodbench): the
* DM-room query drops 9.4 ms → 0.6 ms (~15×) at 200k events and
* 14.2 ms → 0.66 ms (~21×) at 1M — the gap grows with corpus size —
* while batch-insert cost stays inside run noise (49.0 vs 47.4
* µs/event at 1M). geode enables it; the client default stays off
* because a client store's per-tag row counts are bounded by one
* user's data. Flipping it on an existing DB is safe: the index is
* built on next open by `EventIndexesModule.ensureOptionalIndexes`.
*
* Keep in mind that activating too many indexes increases the size of the
* DB so much that the indexes themselves won't fit in memory, requiring

View File

@@ -52,6 +52,17 @@ import androidx.sqlite.SQLiteStatement
* exactly at a same-second boundary.
*/
internal object MergeQueryExecutor {
// TODO: the same collect-all + TEMP-B-TREE-sort pattern exists one index
// over, on the tag path: `kinds + tags(#e IN [hundreds]) + limit` (the
// reactions/replies watcher archetype) unions per-value streams that are
// each sorted off `(tag_hash, kind, created_at)` and sorts the union.
// `streamCount` currently rejects any filter with tags, so those queries
// never merge. Measured by `TagAuthorIndexBenchmark`: `#e IN 300,
// limit 500` costs 12.8 ms cold at 200k events and 14.2 ms at 1M
// (6.7 ms with indexTagsWithKindAndPubkey on) — tolerable, but it
// scales with matching history like the follow-feed shape did; extend
// the merge to per-tag-value streams if the relayBench
// `reactions-watch` scenario shows it in the profile vs strfry.
const val COLS = "id, pubkey, created_at, kind, tags, content, sig"
/**

View File

@@ -160,6 +160,11 @@ class SQLiteEventStore(
setUserVersion(this, DATABASE_VERSION)
}
}
// Flag-gated indexes are runtime config, not schema: a
// deployment that flips an IndexingStrategy flag on an
// existing DB gets the index built here (idempotent,
// one-time cost), with no user_version bump involved.
eventIndexModule.ensureOptionalIndexes(db)
},
)
}

View File

@@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.nip14Subject.SubjectTag
import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag
@@ -130,6 +131,9 @@ class GitIssueEvent(
) = eventTemplate(KIND, content, createdAt) {
subject(subject)
repository(repository)
// NIP-34 issues carry the repository owner as a `p` tag so maintainers
// watching `#p` are notified; the same tag patches/PRs already include.
pTag(repository.event.pubKey, repository.authorHomeRelay)
notify(notify)
hashtags(topics)

View File

@@ -38,12 +38,12 @@ fun TagArrayBuilder<GitPatchEvent>.repository(rep: EventHintBundle<GitRepository
/**
* Adds the earliest-unique-commit `r` tag used by patches to point at the
* target repository. NIP-34 uses the plain `["r", <commit>]` shape for
* patches (unlike the repository announcement which marks the tag with
* `euc`). We also emit the marked form so the same event can be matched by
* implementations that look for the marker.
* target repository. NIP-34 uses the plain `["r", <commit>]` shape for patches
* — the `"euc"` marker appears only on the kind-30617 repository announcement
* (confirmed against the ngit reference implementation). A `#r` filter still
* matches either shape (both key off the value at index 1).
*/
fun TagArrayBuilder<GitPatchEvent>.euc(commit: String) = addUnique(EucTag.assemble(commit))
fun TagArrayBuilder<GitPatchEvent>.euc(commit: String) = addUnique(arrayOf(EucTag.TAG_NAME, commit))
fun TagArrayBuilder<GitPatchEvent>.commit(commit: String) = addUnique(CommitTag.assemble(commit))

View File

@@ -87,7 +87,9 @@ class GitPullRequestEvent(
fun currentCommit(): String? = tags.firstNotNullOfOrNull(CurrentCommitTag::parse)
fun cloneUrls(): List<String> = tags.mapNotNull(CloneTag::parse)
// Tolerant of both the NIP-34 spec form (one multi-value `["clone", a, b]` tag,
// which ngit emits) and the legacy repeated form; deduped.
fun cloneUrls(): List<String> = tags.flatMap(CloneTag::parseAll).distinct()
fun subject(): String? = tags.firstNotNullOfOrNull(SubjectTag::parse)
@@ -138,7 +140,7 @@ class GitPullRequestEvent(
pTag(repository.event.pubKey, repository.authorHomeRelay)
if (notify.isNotEmpty()) pTags(notify)
currentCommit(currentCommit)
cloneUrls.forEach { cloneUrl(it) }
if (cloneUrls.isNotEmpty()) cloneUrls(cloneUrls)
subject?.let { subject(it) }
if (labels.isNotEmpty()) hashtags(labels)
branchName?.let { branchName(it) }

View File

@@ -81,7 +81,8 @@ class GitPullRequestUpdateEvent(
fun currentCommit(): String? = tags.firstNotNullOfOrNull(CurrentCommitTag::parse)
fun cloneUrls(): List<String> = tags.mapNotNull(CloneTag::parse)
// Tolerant of both the multi-value and legacy repeated `clone` forms; deduped.
fun cloneUrls(): List<String> = tags.flatMap(CloneTag::parseAll).distinct()
fun earliestUniqueCommit(): String? = tags.firstOrNull { it.size > 1 && it[0] == "r" && it[1].isNotEmpty() }?.get(1)
@@ -119,7 +120,7 @@ class GitPullRequestUpdateEvent(
pTag(repository.event.pubKey, repository.authorHomeRelay)
if (notify.isNotEmpty()) pTags(notify)
currentCommit(currentCommit)
cloneUrls.forEach { cloneUrl(it) }
if (cloneUrls.isNotEmpty()) cloneUrls(cloneUrls)
mergeBase?.let { mergeBase(it) }
initializer()
}

View File

@@ -38,12 +38,17 @@ fun TagArrayBuilder<GitPullRequestEvent>.repository(rep: ATag) = addUnique(rep.t
fun TagArrayBuilder<GitPullRequestEvent>.repository(rep: EventHintBundle<GitRepositoryEvent>) = addUnique(rep.toATag().toATagArray())
fun TagArrayBuilder<GitPullRequestEvent>.euc(commit: String) = addUnique(EucTag.assemble(commit))
// NIP-34 pull requests use the plain `["r", <commit>]` shape; the `"euc"` marker
// is only on the kind-30617 announcement (see the patch builder for the rationale).
fun TagArrayBuilder<GitPullRequestEvent>.euc(commit: String) = addUnique(arrayOf(EucTag.TAG_NAME, commit))
fun TagArrayBuilder<GitPullRequestEvent>.currentCommit(commit: String) = addUnique(CurrentCommitTag.assemble(commit))
fun TagArrayBuilder<GitPullRequestEvent>.cloneUrl(url: String) = add(CloneTag.assemble(url))
/** Emit all clone URLs as one NIP-34 multi-value `["clone", url1, url2, …]` tag (the spec/ngit form). */
fun TagArrayBuilder<GitPullRequestEvent>.cloneUrls(urls: List<String>) = addUnique(CloneTag.assemble(urls))
fun TagArrayBuilder<GitPullRequestEvent>.subject(subject: String) = addUnique(SubjectTag.assemble(subject))
fun TagArrayBuilder<GitPullRequestEvent>.branchName(name: String) = addUnique(BranchNameTag.assemble(name))

View File

@@ -36,12 +36,16 @@ fun TagArrayBuilder<GitPullRequestUpdateEvent>.repository(rep: ATag) = addUnique
fun TagArrayBuilder<GitPullRequestUpdateEvent>.repository(rep: EventHintBundle<GitRepositoryEvent>) = addUnique(rep.toATag().toATagArray())
fun TagArrayBuilder<GitPullRequestUpdateEvent>.euc(commit: String) = addUnique(EucTag.assemble(commit))
// Plain `["r", <commit>]` — the `"euc"` marker is only on the 30617 announcement.
fun TagArrayBuilder<GitPullRequestUpdateEvent>.euc(commit: String) = addUnique(arrayOf(EucTag.TAG_NAME, commit))
fun TagArrayBuilder<GitPullRequestUpdateEvent>.currentCommit(commit: String) = addUnique(CurrentCommitTag.assemble(commit))
fun TagArrayBuilder<GitPullRequestUpdateEvent>.cloneUrl(url: String) = add(CloneTag.assemble(url))
/** Emit all clone URLs as one NIP-34 multi-value `["clone", url1, url2, …]` tag (the spec/ngit form). */
fun TagArrayBuilder<GitPullRequestUpdateEvent>.cloneUrls(urls: List<String>) = addUnique(CloneTag.assemble(urls))
fun TagArrayBuilder<GitPullRequestUpdateEvent>.mergeBase(commit: String) = addUnique(MergeBaseTag.assemble(commit))
/** Adds the NIP-22 `E` tag pointing at the parent Pull Request. */

View File

@@ -58,14 +58,21 @@ class GitRepositoryEvent(
fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse)
/** First web URL, for backwards compatibility. Prefer [webs]. */
fun web() = tags.firstNotNullOfOrNull(WebTag::parse)
fun web() = webs().firstOrNull()
fun webs(): List<String> = tags.mapNotNull(WebTag::parse)
/**
* All browse URLs. Tolerant of both the NIP-34 spec form (one multi-value
* `["web", url1, url2]` tag, which ngit emits) and the legacy repeated
* `["web", url1]` / `["web", url2]` form, so a repo announced by any client
* round-trips without dropping URLs.
*/
fun webs(): List<String> = tags.flatMap(WebTag::parseAll).distinct()
/** First clone URL, for backwards compatibility. Prefer [clones]. */
fun clone() = tags.firstNotNullOfOrNull(CloneTag::parse)
fun clone() = clones().firstOrNull()
fun clones(): List<String> = tags.mapNotNull(CloneTag::parse)
/** All clone URLs — tolerant of both the multi-value and repeated forms (see [webs]); deduped. */
fun clones(): List<String> = tags.flatMap(CloneTag::parseAll).distinct()
/**
* Relays the repository author monitors for patches and issues. NIP-34
@@ -131,8 +138,8 @@ class GitRepositoryEvent(
dTag(dTag)
name(name)
description?.let { description(it) }
webUrls.forEach { webUrl(it) }
cloneUrls.forEach { cloneUrl(it) }
if (webUrls.isNotEmpty()) webUrls(webUrls)
if (cloneUrls.isNotEmpty()) cloneUrls(cloneUrls)
if (relays.isNotEmpty()) relays(relays)
if (maintainers.isNotEmpty()) maintainers(maintainers)
if (hashtags.isNotEmpty()) hashtags(hashtags)

View File

@@ -36,11 +36,13 @@ fun TagArrayBuilder<GitRepositoryEvent>.description(description: String) = addUn
fun TagArrayBuilder<GitRepositoryEvent>.webUrl(webUrl: String) = add(WebTag.assemble(webUrl))
fun TagArrayBuilder<GitRepositoryEvent>.webUrls(webUrls: List<String>) = addAll(webUrls.map(WebTag::assemble))
/** Emit all browse URLs as one NIP-34 multi-value `["web", url1, url2, …]` tag (the spec/ngit form). */
fun TagArrayBuilder<GitRepositoryEvent>.webUrls(webUrls: List<String>) = addUnique(WebTag.assemble(webUrls))
fun TagArrayBuilder<GitRepositoryEvent>.cloneUrl(cloneUrl: String) = add(CloneTag.assemble(cloneUrl))
fun TagArrayBuilder<GitRepositoryEvent>.cloneUrls(cloneUrls: List<String>) = addAll(cloneUrls.map(CloneTag::assemble))
/** Emit all clone URLs as one NIP-34 multi-value `["clone", url1, url2, …]` tag (the spec/ngit form). */
fun TagArrayBuilder<GitRepositoryEvent>.cloneUrls(cloneUrls: List<String>) = addUnique(CloneTag.assemble(cloneUrls))
fun TagArrayBuilder<GitRepositoryEvent>.relays(relays: List<String>) = addUnique(RelaysTag.assemble(relays))

View File

@@ -23,6 +23,13 @@ package com.vitorpamplona.quartz.nip34Git.repository.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* NIP-34 repository `clone` tag. The spec encodes clone URLs as a single
* multi-value tag — `["clone", "<url1>", "<url2>", ...]` — so [assemble] emits
* that form and [parseAll] reads every value. [parse] (first value only) and
* the legacy repeated `["clone", "<url>"]` form are still read for backward
* compatibility (see `GitRepositoryEvent.clones`).
*/
class CloneTag {
companion object {
const val TAG_NAME = "clone"
@@ -34,6 +41,16 @@ class CloneTag {
return tag[1]
}
/** Every non-empty URL carried by a single `clone` tag. */
fun parseAll(tag: Array<String>): List<String> {
ensure(tag.has(1)) { return emptyList() }
ensure(tag[0] == TAG_NAME) { return emptyList() }
return tag.drop(1).filter { it.isNotEmpty() }
}
fun assemble(name: String) = arrayOf(TAG_NAME, name)
/** The spec form: one tag carrying all clone URLs. */
fun assemble(urls: List<String>): Array<String> = (listOf(TAG_NAME) + urls.filter { it.isNotEmpty() }).toTypedArray()
}
}

View File

@@ -23,6 +23,12 @@ package com.vitorpamplona.quartz.nip34Git.repository.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* NIP-34 repository `web` tag. Like `clone`, the spec encodes browse URLs as a
* single multi-value tag — `["web", "<url1>", "<url2>", ...]` — so [assemble]
* emits that form and [parseAll] reads every value. [parse] (first value only)
* and the legacy repeated form are still read for backward compatibility.
*/
class WebTag {
companion object {
const val TAG_NAME = "web"
@@ -34,6 +40,16 @@ class WebTag {
return tag[1]
}
/** Every non-empty URL carried by a single `web` tag. */
fun parseAll(tag: Array<String>): List<String> {
ensure(tag.has(1)) { return emptyList() }
ensure(tag[0] == TAG_NAME) { return emptyList() }
return tag.drop(1).filter { it.isNotEmpty() }
}
fun assemble(name: String) = arrayOf(TAG_NAME, name)
/** The spec form: one tag carrying all browse URLs. */
fun assemble(urls: List<String>): Array<String> = (listOf(TAG_NAME) + urls.filter { it.isNotEmpty() }).toTypedArray()
}
}

View File

@@ -202,8 +202,20 @@ fun Filter.strippingSearchExtensions(): Filter {
/**
* Applies [strippingSearchExtensions] to every filter, returning this
* same list when no filter carried extension tokens.
*
* This runs on every REQ/COUNT/snapshot, and the overwhelming majority
* carry no `search` term at all, so the no-search case must not allocate:
* bail before building any list when nothing could be stripped.
*/
fun List<Filter>.strippingSearchExtensions(): List<Filter> {
var hasSearch = false
for (i in indices) {
if (!this[i].search.isNullOrEmpty()) {
hasSearch = true
break
}
}
if (!hasSearch) return this
var changed = false
val out =
map {

View File

@@ -0,0 +1,140 @@
/*
* 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.quartz.nip34Git
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Locks in byte-level interoperability with ngit (github.com/DanConwayDev/ngit-cli)
* and the NIP-34 spec for the tag shapes that previously diverged:
*
* 1. `clone` / `web` are ONE multi-value tag, not repeated single-value tags —
* ngit's parser keeps only the last of repeated known tags, so the repeated
* form silently dropped URLs across implementations.
* 2. Issues carry the repository owner as a `p` tag (maintainer routing).
* 3. Patch/PR `r` tags are plain `["r", <commit>]`; the `"euc"` marker lives
* only on the kind-30617 announcement.
*/
class GitNip34InteropTest {
private val owner = "aa".repeat(32)
private fun repo(tags: Array<Array<String>>) = GitRepositoryEvent("00", owner, 0, tags, "", "00")
@Test
fun announcementEmitsSingleMultiValueCloneAndWeb() {
val tmpl =
GitRepositoryEvent.build(
name = "demo",
description = null,
webUrls = listOf("https://a.com", "https://b.com"),
cloneUrls = listOf("https://a.git", "https://b.git"),
relays = emptyList(),
maintainers = emptyList(),
hashtags = emptyList(),
earliestUniqueCommit = null,
dTag = "demo",
)
assertEquals(1, tmpl.tags.count { it[0] == "clone" }, "clone must be a single tag")
assertEquals(1, tmpl.tags.count { it[0] == "web" }, "web must be a single tag")
assertEquals(listOf("clone", "https://a.git", "https://b.git"), tmpl.tags.first { it[0] == "clone" }.toList())
assertEquals(listOf("web", "https://a.com", "https://b.com"), tmpl.tags.first { it[0] == "web" }.toList())
}
@Test
fun readsSpecMultiValueForm() {
val r =
repo(
arrayOf(
arrayOf("d", "x"),
arrayOf("clone", "https://a.git", "https://b.git"),
arrayOf("web", "https://a.com", "https://b.com"),
),
)
assertEquals(listOf("https://a.git", "https://b.git"), r.clones())
assertEquals(listOf("https://a.com", "https://b.com"), r.webs())
}
@Test
fun readsLegacyRepeatedForm() {
val r =
repo(
arrayOf(
arrayOf("d", "x"),
arrayOf("clone", "https://a.git"),
arrayOf("clone", "https://b.git"),
arrayOf("web", "https://a.com"),
arrayOf("web", "https://b.com"),
),
)
assertEquals(listOf("https://a.git", "https://b.git"), r.clones())
assertEquals(listOf("https://a.com", "https://b.com"), r.webs())
}
@Test
fun issueCarriesRepositoryOwnerPTag() {
val repoEvent = repo(arrayOf(arrayOf("d", "x"), arrayOf("name", "x")))
val tmpl = GitIssueEvent.build("subject", "body", EventHintBundle(repoEvent), emptyList(), emptyList())
val pTags = tmpl.tags.filter { it[0] == "p" }.map { it[1] }
assertTrue(owner in pTags, "issue must p-tag the repository owner for maintainer routing")
}
@Test
fun pullRequestEmitsAndReadsMultiValueClone() {
val repoEvent = repo(arrayOf(arrayOf("d", "x"), arrayOf("r", "root", "euc")))
val tmpl =
GitPullRequestEvent.build(
description = "desc",
repository = EventHintBundle(repoEvent),
earliestUniqueCommit = "root",
currentCommit = "tip",
cloneUrls = listOf("https://a.git", "https://b.git"),
)
// One multi-value clone tag (ngit/spec form), not repeated single-value tags.
assertEquals(1, tmpl.tags.count { it[0] == "clone" }, "PR clone must be a single tag")
assertEquals(listOf("clone", "https://a.git", "https://b.git"), tmpl.tags.first { it[0] == "clone" }.toList())
// Reader flattens both the multi-value and the legacy repeated forms.
val multi = GitPullRequestEvent("00", owner, 0, arrayOf(arrayOf("clone", "https://a.git", "https://b.git")), "", "00")
val repeated = GitPullRequestEvent("00", owner, 0, arrayOf(arrayOf("clone", "https://a.git"), arrayOf("clone", "https://b.git")), "", "00")
assertEquals(listOf("https://a.git", "https://b.git"), multi.cloneUrls())
assertEquals(listOf("https://a.git", "https://b.git"), repeated.cloneUrls())
}
@Test
fun patchRTagIsPlainWithoutEucMarker() {
val repoEvent = repo(arrayOf(arrayOf("d", "x"), arrayOf("r", "rootcommit", "euc")))
val tmpl =
GitPatchEvent.build(
patch = "diff",
repository = EventHintBundle(repoEvent),
earliestUniqueCommit = "rootcommit",
commit = "c1",
)
assertEquals(listOf("r", "rootcommit"), tmpl.tags.first { it[0] == "r" }.toList(), "patch r tag must be plain (no euc marker)")
}
}

View File

@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.isAddressable
import com.vitorpamplona.quartz.nip01Core.core.isReplaceable
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.store.sqlite.TagNameValueHasher
import java.nio.file.DirectoryStream
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.exists
@@ -36,16 +37,23 @@ import kotlin.io.path.exists
*
* Step-2 coverage:
* - `ids` → direct canonical opens
* - `tagsAll`/`tags` → tag index union (first key)
* - `kinds` → kind index union
* - `authors` → author index union
* - `tagsAll`/`tags`/`kinds`/`authors` → cheapest index tree drives
* (capped entry-count comparison), the rest post-filter
* - otherwise → full scan via every `idx/kind/<k>/` subtree
*
* The planner is intentionally dumb about selectivity — "first available
* driver wins". A cost-based picker (smallest listing) can slot in
* later without changing callers. All FilterMatcher semantics (tag
* AND/OR, since/until, id, author, kind cross-checks) are enforced in
* the orchestrator, so picking a loose driver is correctness-safe.
* Driver choice is cost-based: every legal driver (each `tagsAll` value
* alone — AND semantics make any single value a complete driver — each
* `tags` key's value union, the kind set, the author set) opens a lazy
* directory iterator, all are drained in lockstep, and the first to
* exhaust — the smallest listing — drives. A giant tree (`idx/kind/1/`
* with a million entries) is therefore never read past ~the smallest
* candidate's size. Before the pick, the fixed tags → kinds → authors
* order sent `authors + kinds + limit` — the most common CLI shape —
* through the kind tree: 149 ms fixed-order vs 4.0 ms cost-based at 30k
* events per `FsDriverSelectionBenchmark` (floor: author-only at
* 1.4 ms). All FilterMatcher semantics (tag AND/OR,
* since/until, id, author, kind cross-checks) are enforced in the
* orchestrator, so any driver pick is correctness-safe.
*/
internal class FsQueryPlanner(
private val layout: FsLayout,
@@ -74,19 +82,100 @@ internal class FsQueryPlanner(
return ftsDriver(search)
}
firstTagKey(filter)?.let { (name, values) ->
return mergeDesc(values.map { v -> walkDir(layout.tagValueDir(name, v, hasher.hash(name, v))) })
val candidates = driverCandidates(filter)
if (candidates.isEmpty()) return allKindsDriver()
return mergeDesc(cheapestDriver(candidates).map { walkDir(it) })
}
/**
* Every set of index directories that, walked and post-filtered, yields
* a superset of the filter's matches:
* - each `tagsAll` value alone (AND semantics — every match carries it),
* - each `tags` key's full value union (OR within a key, AND across),
* - the kind set, and the author set.
* Listed in the old fixed-priority order so [cheapestDriver] keeps that
* order on cost ties.
*/
private fun driverCandidates(filter: Filter): List<List<Path>> {
val out = ArrayList<List<Path>>()
filter.tagsAll?.forEach { (name, values) ->
values.forEach { v -> out.add(listOf(layout.tagValueDir(name, v, hasher.hash(name, v)))) }
}
filter.tags?.forEach { (name, values) ->
if (values.isNotEmpty()) {
out.add(values.map { v -> layout.tagValueDir(name, v, hasher.hash(name, v)) })
}
}
filter.kinds?.takeIf { it.isNotEmpty() }?.let { kinds ->
out.add(kinds.map { layout.kindDir(it) })
}
filter.authors?.takeIf { it.isNotEmpty() }?.let { authors ->
out.add(authors.map { layout.authorDir(it) })
}
return out
}
/**
* Smallest candidate by lockstep listing drain: one lazy directory
* iterator per candidate, all advanced [COST_BATCH] entries per round —
* the first to exhaust its listing is the smallest, so a giant tree is
* never read past ~the smallest candidate's size (a candidate that
* exhausts on round one costs the others one batch each). A candidate
* whose dirs are all missing exhausts immediately: driving from an empty
* mandatory predicate correctly yields an empty result. If every
* candidate survives [COST_CAP] entries, all are huge and relative
* driver choice stops mattering — the first (old fixed-priority order)
* wins.
*/
private fun cheapestDriver(candidates: List<List<Path>>): List<Path> {
if (candidates.size == 1) return candidates[0]
val cursors = candidates.map { EntryCursor(it) }
try {
var advanced = 0L
while (advanced < COST_CAP) {
for (i in cursors.indices) {
if (!cursors[i].skip(COST_BATCH)) return candidates[i]
}
advanced += COST_BATCH
}
return candidates[0]
} finally {
cursors.forEach { it.close() }
}
}
/** Lazy entry iterator over a candidate's directories, in order. */
private class EntryCursor(
dirs: List<Path>,
) : AutoCloseable {
private val remaining = ArrayDeque(dirs)
private var stream: DirectoryStream<Path>? = null
private var iter: Iterator<Path> = emptyList<Path>().iterator()
/** Advances up to [n] entries; false when the listing ends first. */
fun skip(n: Int): Boolean {
var left = n
while (left > 0) {
if (iter.hasNext()) {
iter.next()
left--
continue
}
close()
val dir = remaining.removeFirstOrNull() ?: return false
if (!Files.isDirectory(dir)) continue
stream = Files.newDirectoryStream(dir)
iter = stream!!.iterator()
}
return true
}
filter.kinds?.let { kinds ->
return mergeDesc(kinds.map { walkDir(layout.kindDir(it)) })
override fun close() {
stream?.close()
stream = null
iter = emptyList<Path>().iterator()
}
filter.authors?.let { authors ->
return mergeDesc(authors.map { walkDir(layout.authorDir(it)) })
}
return allKindsDriver()
}
/**
@@ -301,14 +390,15 @@ internal class FsQueryPlanner(
var top: Candidate,
)
// ---- helpers ------------------------------------------------------
private companion object {
/** Entries each candidate's cursor advances per lockstep round. */
const val COST_BATCH = 64
/** First tag filter with at least one value, preferring `tagsAll`. */
private fun firstTagKey(filter: Filter): Pair<String, List<String>>? {
filter.tagsAll?.firstNonEmpty()?.let { return it }
filter.tags?.firstNonEmpty()?.let { return it }
return null
/**
* Stop draining once every candidate has survived this many
* entries: past it they are all huge, relative choice stops
* mattering, and the first candidate in priority order wins.
*/
const val COST_CAP = 65_536L
}
private fun Map<String, List<String>>.firstNonEmpty(): Pair<String, List<String>>? = entries.firstOrNull { it.value.isNotEmpty() }?.let { it.key to it.value }
}

View File

@@ -32,11 +32,14 @@ import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import com.vitorpamplona.quartz.utils.EventFactory
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import java.util.concurrent.atomic.AtomicInteger
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -65,6 +68,8 @@ class SmallReqFloorBenchmark {
const val AUTHORS = 2_500 // ~20 events per author, matching author-archive
const val ROUNDS = 400
const val WARMUP = 100
const val IDLE_SUBS = 1_000
const val FANOUT_SUBS = 200
}
private fun hexId(seed: Int): String = seed.toString(16).padStart(64, '0')
@@ -122,16 +127,19 @@ class SmallReqFloorBenchmark {
}
// --- B: backend queryRaw to EOSE (live machinery included) ---
// UNDISPATCHED mirrors the production path (RelaySession.handleReq
// starts the query coroutine undispatched), so BA is the live
// machinery itself, not a benchmark-only scheduler hop.
suspend fun timeBackend(round: Int): Long {
val eose = CompletableDeferred<Long>()
val t0 = System.nanoTime()
val job =
scope.launch {
scope.launch(start = CoroutineStart.UNDISPATCHED) {
live.queryRaw(
ctx = ctx,
filters = listOf(filterFor(round)),
onEachStored = {},
onEachLive = {},
onEachLive = { _, _ -> },
onEose = { eose.complete(System.nanoTime() - t0) },
)
}
@@ -143,6 +151,33 @@ class SmallReqFloorBenchmark {
val b = LongArray(ROUNDS)
repeat(ROUNDS) { b[it] = timeBackend(it) }
// --- B@1k: same, with 1000 idle live subscriptions parked ---
// Register/unregister cost scales with the live population
// (FilterIndex mutates a shared snapshot per REQ open/close),
// which the single-sub stage can't see. Each idle sub filters
// on an author absent from the corpus: 0-row replay, then parks
// at the live tail and stays registered.
val idleJobs =
(0 until IDLE_SUBS).map { i ->
val ready = CompletableDeferred<Unit>()
val job =
scope.launch(start = CoroutineStart.UNDISPATCHED) {
live.queryRaw(
ctx = ctx,
filters = listOf(Filter(authors = listOf(hexId(1_000_000 + i)), kinds = listOf(1), limit = 1)),
onEachStored = {},
onEachLive = { _, _ -> },
onEose = { ready.complete(Unit) },
)
}
ready.await()
job
}
repeat(WARMUP) { timeBackend(it) }
val b1k = LongArray(ROUNDS)
repeat(ROUNDS) { b1k[it] = timeBackend(it) }
idleJobs.forEach { it.cancel() }
// --- C: full session dispatch, REQ json in → EOSE frame out ---
suspend fun timeSession(round: Int): Long {
val eose = CompletableDeferred<Long>()
@@ -160,15 +195,67 @@ class SmallReqFloorBenchmark {
val c = LongArray(ROUNDS)
repeat(ROUNDS) { c[it] = timeSession(it) }
// --- fanout: one live event → FANOUT_SUBS live subscriptions ---
// All subs register on `live` directly (via queryRaw, same backend
// we submit into) and filter an author with no stored events (0-row
// replay, then park live). Submitting one matching event fans out to
// every sub; the body is serialized once and spliced per sub, so
// this measures the shared-serialization path (#2). skipVerify so
// the synthetic sig is accepted. Fewer rounds than AC: each round
// is FANOUT_SUBS deliveries and a real group-commit insert.
val fanAuthor = hexId(9_000_001)
val delivered = AtomicInteger(0)
var fanDone = CompletableDeferred<Long>()
var fanStart = 0L
val fanJobs =
(0 until FANOUT_SUBS).map {
val ready = CompletableDeferred<Unit>()
val job =
scope.launch(start = CoroutineStart.UNDISPATCHED) {
live.queryRaw(
ctx = ctx,
filters = listOf(Filter(authors = listOf(fanAuthor), kinds = listOf(1))),
onEachStored = {},
onEachLive = { _, _ ->
if (delivered.incrementAndGet() == FANOUT_SUBS) {
fanDone.complete(System.nanoTime() - fanStart)
}
},
onEose = { ready.complete(Unit) },
)
}
ready.await()
job
}
val fanRounds = 60
val fanWarmup = 15
val fan = LongArray(fanRounds)
var fanSeq = 0
repeat(fanWarmup + fanRounds) { r ->
delivered.set(0)
fanDone = CompletableDeferred()
val ev = EventFactory.create<Event>(hexId(9_500_000 + fanSeq), fanAuthor, 1_700_000_000L + fanSeq, 1, emptyArray(), "fanout $fanSeq", sig)
fanSeq++
fanStart = System.nanoTime()
live.submit(ev, skipVerify = true) {}
val nanos = withTimeout(30_000) { fanDone.await() }
if (r >= fanWarmup) fan[r - fanWarmup] = nanos
}
fanJobs.forEach { it.cancel() }
assertEquals(true, rowsA > 0, "author filters must return rows")
val mA = median(a)
val mB = median(b)
val mB1k = median(b1k)
val mC = median(c)
println("SmallReqFloorBenchmark @ ${EVENTS / 1000}k events, ~${rowsA / ROUNDS} rows/req, medians of $ROUNDS")
println(" A raw store query: ${"%6.3f".format(mA)} ms")
println(" B backend queryRaw→EOSE: ${"%6.3f".format(mB)} ms (live machinery +${"%6.3f".format(mB - mA)})")
println(" B@${IDLE_SUBS} idle subs: ${"%6.3f".format(mB1k)} ms (population cost +${"%6.3f".format(mB1k - mB)})")
println(" C session REQ→EOSE: ${"%6.3f".format(mC)} ms (dispatch+frames +${"%6.3f".format(mC - mB)})")
val mFan = median(fan)
println(" fanout 1→$FANOUT_SUBS live subs: ${"%6.3f".format(mFan)} ms (${"%.2f".format(mFan * 1000 / FANOUT_SUBS)} µs/sub; body serialized once)")
server.close()
scope.cancel()

View File

@@ -0,0 +1,184 @@
/*
* 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.quartz.nip01Core.relay.prodbench
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.store.sqlite.DefaultIndexingStrategy
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import com.vitorpamplona.quartz.utils.EventFactory
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
/**
* Measures the two tag-path query shapes the client filter-assembler survey
* (2026-07) found hot but that no existing benchmark covers:
*
* 1. **tag ∩ author (DM-room shape)** — `kinds=[4] AND authors=[peer] AND
* #p=[me] LIMIT n`. 65 assembler call sites build this shape (every
* NIP-04 chat room, reports-by-follows, follows-scoped community feeds).
* [com.vitorpamplona.quartz.nip01Core.store.sqlite.IndexingStrategy.indexTagsWithKindAndPubkey]
* gates a covering `(tag_hash, kind, pubkey_hash, created_at)` index for
* it, but the flag is off everywhere (including geode). Without it the
* plan seeks `(tag_hash, kind)` and reads EVERY DM the user has ever
* received before filtering to the one peer. This compares query latency
* with the flag off vs on, and the batch-insert cost the extra index adds.
*
* 2. **large-IN tag watcher (reactions shape)** — `kinds=[7] AND
* #e=[hundreds of note ids] LIMIT n`. The per-value streams come sorted
* off `(tag_hash, kind, created_at)`, but their union does not, so SQLite
* collects every matching row and TEMP-B-TREE sorts to the limit — the
* tag-index analogue of the follow-feed regression
* [MergeQueryExecutor] fixed for author streams. Reported with and
* without a `since` bound to show what EOSE-warm steady state hides.
*
* Size the seed with `-DtagBenchScale=N` (default 1 ≈ ~200k events).
*/
class TagAuthorIndexBenchmark {
companion object {
val SCALE = System.getProperty("tagBenchScale")?.toInt() ?: 1
}
private val hex = "0123456789abcdef"
private fun mix(seed: Long): Long {
var z = seed + -0x61c8864680b583ebL
z = (z xor (z ushr 30)) * -0x40a7b892e31b1a47L
z = (z xor (z ushr 27)) * -0x6b2fb644ecceee15L
return z xor (z ushr 31)
}
private fun hex64(
salt: Long,
index: Int,
): String {
val out = CharArray(64)
for (w in 0 until 4) {
val v = mix(salt * 1_000_003 + index.toLong() * 4 + w)
for (b in 0 until 8) {
val byte = ((v ushr (b * 8)) and 0xFF).toInt()
out[(w * 8 + b) * 2] = hex[byte ushr 4]
out[(w * 8 + b) * 2 + 1] = hex[byte and 0xF]
}
}
return String(out)
}
private val sig = "0".repeat(128)
private var idSeq = 0
private fun ev(
pubkey: String,
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
): Event = EventFactory.create(hex64(7, idSeq++), pubkey, createdAt, kind, tags, "", sig)
private fun seedEvents(): List<Event> {
idSeq = 0
val base = 1_700_000_000L
val span = 3_000_000L // ~35 days
val me = hex64(9, 0)
val events = ArrayList<Event>(220_000 * SCALE)
// DM inbox: 200 peers, 300 DMs each → 60k kind-4 rows sharing the
// same (p:me) tag hash. The room query wants one peer's 300.
val peers = (0 until 200).map { hex64(2, it) }
for ((i, peer) in peers.withIndex()) {
repeat(300 * SCALE) {
val ts = base + (mix(i * 131L + it) and 0x7fffffff) % span
events.add(ev(peer, ts, 4, arrayOf(arrayOf("p", me))))
}
}
// Notification noise: 2000 authors mention me in kind-1 notes, so
// (p:me) spans multiple kinds like a real inbox does.
repeat(40_000 * SCALE) {
val author = hex64(3, it % 2_000)
val ts = base + (mix(it * 17L) and 0x7fffffff) % span
events.add(ev(author, ts, 1, arrayOf(arrayOf("p", me))))
}
// Reactions: 100k kind-7 events spread over 5000 target notes, for
// the large-IN watcher shape.
val noteIds = (0 until 5_000).map { hex64(5, it) }
repeat(100_000 * SCALE) {
val author = hex64(4, it % 3_000)
val ts = base + (mix(it * 29L) and 0x7fffffff) % span
events.add(ev(author, ts, 7, arrayOf(arrayOf("e", noteIds[it % noteIds.size]))))
}
return events
}
@Test
fun compareTagAuthorIndex() =
runBlocking {
val events = seedEvents()
val me = hex64(9, 0)
val peers = (0 until 200).map { hex64(2, it) }
val noteIds = (0 until 5_000).map { hex64(5, it) }
println("─ TagAuthorIndexBenchmark: ${events.size} events (scale=$SCALE) ─")
val strategies =
listOf(
"flag-off" to DefaultIndexingStrategy(indexFullTextSearch = false),
"flag-on " to DefaultIndexingStrategy(indexFullTextSearch = false, indexTagsWithKindAndPubkey = true),
)
for ((label, strategy) in strategies) {
val store = EventStore(dbName = null, indexStrategy = strategy)
val t0 = System.nanoTime()
events.chunked(10_000).forEach { store.batchInsert(it) }
val insertMs = (System.nanoTime() - t0) / 1e6
println("$label ═ insert: %.0f ms (%.1f µs/event)".format(insertMs, insertMs * 1000 / events.size))
// 1. DM room: one peer's DMs out of the whole (p:me) inbox.
val room = Filter(kinds = listOf(4), authors = listOf(peers[42]), tags = mapOf("p" to listOf(me)), limit = 100)
time(store, "dm-room (#p ∩ author ∩ kind, limit 100)", room)
// 2. Reactions watcher: 300 note ids, cold (no since).
val watcher = Filter(kinds = listOf(7), tags = mapOf("e" to noteIds.take(300)), limit = 500)
time(store, "reactions (#e IN 300, limit 500, cold)", watcher)
// 3. Same watcher, EOSE-warm (since bounds the window).
val warm = watcher.copy(since = 1_700_000_000L + 2_900_000L)
time(store, "reactions (#e IN 300, limit 500, since)", warm)
store.close()
}
}
private suspend fun time(
store: EventStore,
label: String,
filter: Filter,
) {
repeat(3) { store.query<Event>(filter) }
val runs = 10
var rows = 0
val start = System.nanoTime()
repeat(runs) { rows = store.query<Event>(filter).size }
val ms = (System.nanoTime() - start) / 1e6 / runs
println(" %-42s %8.2f ms (%d rows)".format(label, ms, rows))
}
}

View File

@@ -0,0 +1,159 @@
/*
* 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.quartz.nip01Core.store.fs
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.utils.EventFactory
import kotlinx.coroutines.runBlocking
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.exists
import kotlin.test.Test
/**
* Guards [FsQueryPlanner]'s cost-based driver pick on the
* `authors + kinds + limit` shape — the most common CLI query (27
* assembler call sites; every `amy feed`-style author timeline over
* non-replaceable kinds).
*
* Under the pre-pick fixed order (tags → kinds → authors),
* `Filter(authors=[pk], kinds=[1], limit=n)` drove from `idx/kind/1/`
* (the biggest tree in any real store) and post-filtered the author:
* 149 ms at 30k events. The lockstep pick drives from the author tree
* and runs at ~4 ms. The benchmark times:
*
* - **planner (cost-based pick)**: the filter as the planner runs it —
* should sit near the floor, far below a kind-tree walk.
* - **author-driver emulation**: the author tree walked via an
* authors-only query with the kind check applied by the caller — the
* reference the pick is expected to match or beat.
* - **author-only floor**: `authors + limit` with no kind, the cheapest
* possible walk of the same tree.
*
* Size the seed with `-DfsBenchScale=N` (default 1 ≈ ~30k events; each
* event is a file + ~3 hardlinks, so seeding dominates wall time).
*/
class FsDriverSelectionBenchmark {
companion object {
val SCALE = System.getProperty("fsBenchScale")?.toInt() ?: 1
}
private val hex = "0123456789abcdef"
private fun mix(seed: Long): Long {
var z = seed + -0x61c8864680b583ebL
z = (z xor (z ushr 30)) * -0x40a7b892e31b1a47L
z = (z xor (z ushr 27)) * -0x6b2fb644ecceee15L
return z xor (z ushr 31)
}
private fun hex64(
salt: Long,
index: Int,
): String {
val out = CharArray(64)
for (w in 0 until 4) {
val v = mix(salt * 1_000_003 + index.toLong() * 4 + w)
for (b in 0 until 8) {
val byte = ((v ushr (b * 8)) and 0xFF).toInt()
out[(w * 8 + b) * 2] = hex[byte ushr 4]
out[(w * 8 + b) * 2 + 1] = hex[byte and 0xF]
}
}
return String(out)
}
private val sig = "0".repeat(128)
private var idSeq = 0
private fun ev(
pubkey: String,
createdAt: Long,
kind: Int,
): Event = EventFactory.create(hex64(7, idSeq++), pubkey, createdAt, kind, emptyArray(), "", sig)
@Test
fun compareDrivers() =
runBlocking {
val root: Path = Files.createTempDirectory("fs-driver-bench-")
val store = FsEventStore(root)
try {
val base = 1_700_000_000L
val span = 3_000_000L
val target = hex64(9, 0)
// Background: 300 authors × 100 kind-1 notes.
val bg = ArrayList<Event>(30_000 * SCALE + 300)
repeat(30_000 * SCALE) {
val author = hex64(1, it % 300)
bg.add(ev(author, base + (mix(it * 31L) and 0x7fffffff) % span, 1))
}
// Target author: 200 kind-1 notes + 50 kind-7 reactions.
repeat(200) { bg.add(ev(target, base + (mix(it * 131L) and 0x7fffffff) % span, 1)) }
repeat(50) { bg.add(ev(target, base + (mix(it * 61L) and 0x7fffffff) % span, 7)) }
val t0 = System.nanoTime()
store.transaction { bg.forEach { insert(it) } }
val insertMs = (System.nanoTime() - t0) / 1e6
println("─ FsDriverSelectionBenchmark: ${bg.size} events (scale=$SCALE), seed %.0f ms ─".format(insertMs))
// The planner's own pick — expected to choose the author
// tree over the ~30k-entry kind-1 tree.
val kindDriven = Filter(authors = listOf(target), kinds = listOf(1), limit = 50)
time(store, "planner (cost-based pick)") { store.query<Event>(kindDriven).size }
// Reference: author tree walked explicitly, kind checked
// by the caller — the pick should match or beat this.
time(store, "author-driver emulation") {
store
.query<Event>(Filter(authors = listOf(target), limit = 250))
.asSequence()
.filter { it.kind == 1 }
.take(50)
.count()
}
// Floor: author-only shape, the cheapest walk of the tree.
val authorOnly = Filter(authors = listOf(target), limit = 50)
time(store, "author-only floor") { store.query<Event>(authorOnly).size }
} finally {
store.close()
if (root.exists()) {
Files.walk(root).use { it.sorted(Comparator.reverseOrder()).forEach { p -> Files.deleteIfExists(p) } }
}
}
}
private inline fun time(
store: FsEventStore,
label: String,
run: () -> Int,
) {
repeat(3) { run() }
val runs = 10
var rows = 0
val start = System.nanoTime()
repeat(runs) { rows = run() }
val ms = (System.nanoTime() - start) / 1e6 / runs
println(" %-32s %8.2f ms (%d rows)".format(label, ms, rows))
}
}

View File

@@ -70,11 +70,11 @@ object Scenarios {
}
val topAuthors = notesByAuthor.entries.sortedWith(compareByDescending<Map.Entry<String, Int>> { it.value }.thenBy { it.key }).map { it.key }
val hottestThread =
val hotNotes =
eTagRefs.entries
.sortedWith(compareByDescending<Map.Entry<String, Int>> { it.value }.thenBy { it.key })
.firstOrNull()
?.key
.map { it.key }
val hottestThread = hotNotes.firstOrNull()
val mostMentioned =
pTagRefs.entries
.sortedWith(compareByDescending<Map.Entry<String, Int>> { it.value }.thenBy { it.key })
@@ -86,6 +86,23 @@ object Scenarios {
.firstOrNull()
?.key
// The author that most often tags the most-mentioned pubkey — a
// conversation pair for the tag ∩ author (DM-room) query shape.
val conversationPeer =
mostMentioned?.let { me ->
val byAuthor = HashMap<String, Int>()
for (e in events) {
if (e.kind != 1 || e.pubKey == me) continue
if (e.tags.any { it.size >= 2 && it[0] == "p" && it[1] == me }) {
byAuthor.merge(e.pubKey, 1, Int::plus)
}
}
byAuthor.entries
.sortedWith(compareByDescending<Map.Entry<String, Int>> { it.value }.thenBy { it.key })
.firstOrNull()
?.key
}
// Evenly spread sample of note ids — a "fetch these 100 events" batch.
val idSample =
if (noteIds.size <= 100) {
@@ -159,6 +176,33 @@ object Scenarios {
),
)
}
if (mostMentioned != null && conversationPeer != null) {
// The tag ∩ author ∩ kind shape (65 client assembler call
// sites: NIP-04 DM rooms, reports-by-follows, follows-scoped
// community feeds). Modeled on kind 1 because public corpora
// carry no DMs; the index path exercised is identical.
add(
Scenario(
"conversation",
"notes by one author tagging the most-mentioned pubkey (DM-room shape)",
Filter(kinds = listOf(1), authors = listOf(conversationPeer), tags = mapOf("p" to listOf(mostMentioned)), limit = 500),
),
)
}
if (hotNotes.size > 1) {
// Large-IN tag watcher: per-value streams come sorted off the
// tag index but their union does not, exposing whether the
// store collects+sorts or merges. 150 values stays inside
// strfry's default 200-element filter cap.
val watched = hotNotes.take(150)
add(
Scenario(
"reactions-watch",
"reactions on the ${watched.size} hottest notes (visible-feed reaction watcher)",
Filter(kinds = listOf(7), tags = mapOf("e" to watched), limit = 500),
),
)
}
topHashtag?.let {
add(
Scenario(