#!/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 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