diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index 3e6d47371..05fc1083a 100644 --- a/.agents/skills/bootstrap-diagnostics/SKILL.md +++ b/.agents/skills/bootstrap-diagnostics/SKILL.md @@ -2,7 +2,7 @@ name: bootstrap-diagnostics description: >- Agent-only handling playbook for session-start bootstrap diagnostics. - Use whenever the session-start digest's bootstrap section prints any diagnostic or capability line - MISSING, MISSING_MANUAL, BACKEND_INVALID, NEEDS_GH_AUTH, TANGLE, CREW_HARNESS_OVERRIDE, CREW_DISPATCH, FLEET_SYNC, SECONDMATE_SYNC, SECONDMATE_LIVENESS, TASKS_AXI, NUDGE_SECONDMATES, or FMX - or when a standalone bin/fm-bootstrap.sh run prints one. + Use whenever the session-start digest's bootstrap section prints any diagnostic or capability line - MISSING, MISSING_MANUAL, BACKEND_INVALID, NEEDS_GH_AUTH, TANGLE, GATE_FORK, CREW_HARNESS_OVERRIDE, CREW_DISPATCH, FLEET_SYNC, SECONDMATE_SYNC, SECONDMATE_LIVENESS, TASKS_AXI, NUDGE_SECONDMATES, or FMX - or when a standalone bin/fm-bootstrap.sh run prints one. A silent bootstrap section means all good and needs no skill load. user-invocable: false metadata: @@ -26,6 +26,9 @@ The inline rules in `AGENTS.md` section 3 still bind: detect, then consent, then - `TANGLE: ` - the primary checkout is stranded on a feature branch instead of its default branch; `AGENTS.md` section 8 explains why this guard exists and what it protects. The work is safe on that branch ref; restore the primary to its default branch with the printed `git -C checkout `, then re-validate that branch in a proper worktree. This is the only sanctioned firstmate-initiated git write to the primary, and it is a non-destructive branch switch that strands nothing. +- `GATE_FORK: : ... Fix: (cd && no-mistakes init --fork-url git@github.com:/.git)` - that gate-initialized repo has no push-writable fork target while the current gh user cannot push to its upstream, so the next no-mistakes ship there would run the whole review+test+doc+lint pipeline and only then 403 at the push step (the `project-management` skill's Initialize section explains the fork-contributed-upstream init requirement). + This is a captain-owned local fix, not a firstmate git write: relay the printed `no-mistakes init --fork-url ...` for the captain to run (`! (cd ... && no-mistakes init --fork-url ...)`), or run it yourself only with the captain's say-so, then rerun session start to confirm the line clears before dispatching a ship task to that repo. + It never blocks a scout task or work on an already-fork-routed repo. - `CREW_HARNESS_OVERRIDE: ` - record and use the override silently; surface a harness fact only if it actually blocks work or the captain asks. - `CREW_DISPATCH: invalid config/crew-dispatch.json - ` - the optional dispatch profile file exists but failed low-cost bootstrap validation; continue with the normal fallback chain, resolve and pass the chosen fallback harness explicitly while the file remains present, fix the malformed schema, unverified harness name, unknown selector, or invalid harness/effort pair when convenient, and do not select a bad profile. - `CREW_DISPATCH: active config/crew-dispatch.json` - bootstrap validated the optional dispatch profile file and printed its active rules and `default:` when present. diff --git a/.agents/skills/project-management/SKILL.md b/.agents/skills/project-management/SKILL.md index 54ab841ae..16d3c9c98 100644 --- a/.agents/skills/project-management/SKILL.md +++ b/.agents/skills/project-management/SKILL.md @@ -64,6 +64,10 @@ Run no-mistakes initialization only for `no-mistakes` projects: cd projects/ && no-mistakes init && no-mistakes doctor ``` +When the current GitHub user cannot push to the project's upstream, as with a fork-contributed upstream such as the firstmate repo itself, initialize with `no-mistakes init --fork-url git@github.com:/.git` instead, so the gate pushes branches to the writable fork and opens the PR cross-repo. +Plain `no-mistakes init` is only for an upstream you can push to directly. +`CONTRIBUTING.md` owns the fork onboarding detail, and `bin/fm-gate-fork-assert.sh`, surfaced as the session-start `GATE_FORK:` line, catches a missing or non-writable fork target before a ship wastes the whole pipeline and 403s at push. + Initialization configures the local gate and does not vendor a no-mistakes skill into the project. Do not create a commit merely because initialization ran. If doctor reports an environment, authentication, or daemon problem, resolve that blocker before dispatching work and never restart the shared daemon from a project operation. diff --git a/AGENTS.md b/AGENTS.md index 1df624bc7..6ea12cad3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -353,7 +353,7 @@ It performs guarded fast-forward updates of firstmate and registered secondmate These skills are not captain-invocable; load them only at their precise triggers. -- `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap section prints any diagnostic or capability line (`MISSING:`, `MISSING_MANUAL:`, `BACKEND_INVALID:`, `NEEDS_GH_AUTH`, `TANGLE:`, `CREW_HARNESS_OVERRIDE:`, `CREW_DISPATCH:`, `FLEET_SYNC:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `TASKS_AXI:`, `NUDGE_SECONDMATES:`, or `FMX:`); silence needs no load. +- `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap section prints any diagnostic or capability line at all (the skill's own trigger list enumerates them, and `bin/fm-bootstrap.sh`'s header owns which lines exist); silence needs no load. - `diagnostic-reasoning` - load before scoping a reported bug and before acting on a diagnostic report. - `harness-adapters` - load before spawning or recovering a crewmate or secondmate, handling a trust dialog, sending a harness-specific skill invocation, interrupting or exiting an agent, resuming an exited agent, or verifying a new harness adapter. - `firstmate-orca` - load before switching to Orca, spawning or supervising Orca-backed work, smoke-testing Orca backend behavior, debugging Orca task state, or reconciling Orca-backed task metadata. diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index 260d2b666..50b575094 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -739,7 +739,11 @@ FM_BACKEND_HERDR_IDLE_RE=${FM_BACKEND_HERDR_IDLE_RE:-'^Type a message\.\.\.$'} # Known bare (unbordered) prompt glyphs a composer row may start with: ❯ # (claude) and › (codex) only. Generic shell-style glyphs > $ % # are still # recognized after a bordered composer row has already been structurally found. -FM_BACKEND_HERDR_BARE_PROMPT_RE=${FM_BACKEND_HERDR_BARE_PROMPT_RE:-'^[❯›]'} +# Alternation, not a [❯›] bracket class: under a byte-oriented locale (LC_CTYPE=C) +# grep walks a bracket class byte-wise, so [❯›] matches any row whose first byte is +# 0xE2 - including the box-drawing rows ╭ ╰ │ - and misreads a composer border as a +# bare prompt. The (❯|›) form matches each full glyph sequence, correct in any locale. +FM_BACKEND_HERDR_BARE_PROMPT_RE=${FM_BACKEND_HERDR_BARE_PROMPT_RE:-'^(❯|›)'} # Pi allows a multi-line composer between its horizontal separators. Bound the # structural candidate so two unrelated transcript rules with an arbitrarily # large region between them can never be promoted into a composer. @@ -903,7 +907,7 @@ EOF fi # Delegate the empty/pending/unknown decision to the shared owner. The bare # shape only ever starts with an AGENT glyph (FM_BACKEND_HERDR_BARE_PROMPT_RE - # is '^[❯›]'), so a bare shell prompt never reaches here - it stays 'unknown' + # is '^(❯|›)'), so a bare shell prompt never reaches here - it stays 'unknown' # via the no-composer-row path above, exactly as before. fm_composer_classify_content "$bordered" "$stripped" "$FM_BACKEND_HERDR_IDLE_RE" } diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 1f5cc5d80..771b165ec 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -11,6 +11,7 @@ # "CREW_DISPATCH: active config/crew-dispatch.json" plus indented rules, # "FLEET_SYNC: : skipped|recovered|STUCK: ", # "TASKS_AXI: available", "TANGLE: ", +# "GATE_FORK: : ... Fix: no-mistakes init --fork-url ...", # "SECONDMATE_SYNC: secondmate : skipped: ", # "NUDGE_SECONDMATES: fm-...", # "SECONDMATE_LIVENESS: secondmate : already-live|respawned|skipped: |respawn failed: ", @@ -41,6 +42,10 @@ # A TANGLE line means the firstmate primary checkout (FM_ROOT) is stranded # on a feature branch instead of its default branch - a crewmate's work # landed in the primary instead of its own worktree; restore it per the line. +# A GATE_FORK line means a gate-initialized repo whose upstream the +# current gh user cannot push to has a missing or non-writable fork_url, +# so the next no-mistakes ship would 403 at the push step (7 of 9) after +# the whole pipeline; fm-gate-fork-assert.sh owns the check and message. # treehouse is also MISSING when its installed version lacks # "treehouse get --lease" support. # no-mistakes is also MISSING when its installed version is older than @@ -624,6 +629,11 @@ if [ -n "$tangle_branch" ]; then echo "TANGLE: primary checkout on feature branch '$tangle_branch' (expected '$tangle_default'); the work is safe on that ref - restore the primary with: git -C $FM_ROOT checkout $tangle_default, then re-validate the branch in a proper worktree" fi fi +# Fork-push pre-flight: a gate-initialized repo whose upstream the current gh +# user cannot push to must have a push-writable fork_url, or the next ship 403s +# at the push step after the whole pipeline. fm-gate-fork-assert.sh owns the +# check and the GATE_FORK: line; read-only, best-effort, never fatal here. +"$SCRIPT_DIR/fm-gate-fork-assert.sh" 2>/dev/null || true crew= [ -f "$CONFIG/crew-harness" ] && crew=$(tr -d '[:space:]' < "$CONFIG/crew-harness" || true) [ -n "$crew" ] && [ "$crew" != "default" ] && echo "CREW_HARNESS_OVERRIDE: $crew" diff --git a/bin/fm-gate-fork-assert.sh b/bin/fm-gate-fork-assert.sh new file mode 100755 index 000000000..2d12e3848 --- /dev/null +++ b/bin/fm-gate-fork-assert.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# fm-gate-fork-assert.sh - pre-flight the no-mistakes fork-push target. +# +# The no-mistakes gate pushes a validated branch and opens its PR at step 7 of 9 +# (review -> test -> document -> lint -> push -> pr -> ci). When the current gh +# user has no write access to a gate-initialized repo's UPSTREAM, that push must +# go to a FORK instead, routed by the repo's fork_url in the local, gitignored +# gate DB (~/.no-mistakes/state.sqlite). That routing is a one-time hand-applied +# `no-mistakes init --fork-url ...` step: it is absent from a fresh gate DB, a +# reinstall, or a new firstmate home, and nothing else verifies it. When it is +# missing, the whole multi-hour review+test+doc+lint pipeline runs first and only +# THEN 403s at push, throwing the validated work away (5 of 20 historical +# firstmate runs died exactly this way). See data/fm-pr-stuck-w4/report.md. +# +# This check converts that multi-hour silent waste into a one-line session-start +# warning. For each gate-initialized repo whose UPSTREAM the current gh user +# cannot push to, it asserts the repo has a push-writable fork_url, and prints +# one GATE_FORK: line per repo that would 403 at push: +# +# GATE_FORK: : no push access to and not push-writable>; the next ship 403s at push after +# the full pipeline. Fix: (cd && no-mistakes init --fork-url +# git@github.com:/.git) +# +# It exits non-zero when any repo is flagged, 0 when every repo is healthy. It is +# read-only (only `gh api` reads and a sqlite SELECT), detect-only, and +# best-effort: it stays silent and exits 0 when it cannot decide (no gate DB, no +# sqlite3, gh absent or unauthenticated - NEEDS_GH_AUTH owns that case, and a +# non-github upstream it cannot verify). +# +# bin/fm-bootstrap.sh runs it as a detect line, like the TANGLE guard, and +# bootstrap-diagnostics owns the response. +# +# Env: +# FM_GATE_STATE_DB override the gate DB path (default ~/.no-mistakes/state.sqlite) +set -u + +DB=${FM_GATE_STATE_DB:-$HOME/.no-mistakes/state.sqlite} + +# Silent, healthy no-op when we cannot decide: nothing to assert, or NEEDS_GH_AUTH +# (owned by bootstrap) already covers the missing/unauthenticated gh case. +[ -f "$DB" ] || exit 0 +command -v sqlite3 >/dev/null 2>&1 || exit 0 +command -v gh >/dev/null 2>&1 || exit 0 +gh auth status >/dev/null 2>&1 || exit 0 +GH_LOGIN=$(gh api user --jq .login 2>/dev/null || true) +[ -n "$GH_LOGIN" ] || exit 0 + +# Memoize push-access verdicts so repos sharing an upstream cost one gh call. +CACHE=$(mktemp "${TMPDIR:-/tmp}/fm-gate-fork.XXXXXX") || exit 0 +trap 'rm -f "$CACHE"' EXIT + +# Echo "owner/repo" for a github URL (https, ssh, or scp-style), or return 1 for +# any non-github or malformed URL (which we cannot verify and must not flag). +gate_github_slug() { + local url=$1 rest + case $url in + https://github.com/*) rest=${url#https://github.com/} ;; + http://github.com/*) rest=${url#http://github.com/} ;; + ssh://git@github.com/*) rest=${url#ssh://git@github.com/} ;; + git@github.com:*) rest=${url#git@github.com:} ;; + *) return 1 ;; + esac + rest=${rest%.git} + rest=${rest%/} + case $rest in + */*/*) return 1 ;; # more than owner/repo + */*) + [ -n "${rest%/*}" ] && [ -n "${rest#*/}" ] || return 1 + printf '%s\n' "$rest" + return 0 ;; + esac + return 1 +} + +# Echo the current gh user's push-access verdict for github slug $1: +# push - gh confirmed .permissions.push is true +# nopush - gh confirmed .permissions.push is false +# unknown - the gh call failed or returned unparseable output (cannot decide) +# Verdicts (including unknown) are memoized so repos sharing a slug cost one call. +gate_push_verdict() { + local slug=$1 verdict perm rc + verdict=$(grep -F " $slug " "$CACHE" 2>/dev/null | cut -f1 || true) + if [ -z "$verdict" ]; then + perm=$(gh api "repos/$slug" --jq '.permissions.push // false' 2>/dev/null) + rc=$? + if [ "$rc" -ne 0 ]; then + verdict=unknown + else + case $perm in + true) verdict=push ;; + false) verdict=nopush ;; + *) verdict=unknown ;; + esac + fi + printf '%s\t%s\t\n' "$verdict" "$slug" >> "$CACHE" + fi + printf '%s\n' "$verdict" +} + +problem=0 +while IFS='|' read -r working_path upstream_url fork_url; do + [ -n "$upstream_url" ] || continue + upstream_slug=$(gate_github_slug "$upstream_url") || continue + # Only flag when we AFFIRMATIVELY know the upstream is unwritable; a writable + # upstream (no fork needed) or an undecidable probe both stay silent. + [ "$(gate_push_verdict "$upstream_slug")" = nopush ] || continue + + fork_state= + if [ -z "$fork_url" ]; then + fork_state="no fork target" + else + fork_slug=$(gate_github_slug "$fork_url" || true) + if [ -z "$fork_slug" ]; then + fork_state="fork $fork_url not push-writable" + else + case "$(gate_push_verdict "$fork_slug")" in + push) ;; + nopush) fork_state="fork $fork_slug not push-writable" ;; + *) continue ;; + esac + fi + fi + [ -n "$fork_state" ] || continue + + repo_name=${upstream_slug#*/} + printf 'GATE_FORK: %s: no push access to %s and %s; the next ship 403s at push after the full pipeline. Fix: (cd %s && no-mistakes init --fork-url git@github.com:%s/%s.git)\n' \ + "$working_path" "$upstream_slug" "$fork_state" "$working_path" "$GH_LOGIN" "$repo_name" + problem=1 +done < <(sqlite3 -separator '|' "$DB" "SELECT working_path, upstream_url, IFNULL(fork_url,'') FROM repos;" 2>/dev/null) + +[ "$problem" -eq 0 ] diff --git a/docs/configuration.md b/docs/configuration.md index d289c4c15..43c0eeaec 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -341,7 +341,7 @@ FM_BACKEND= # optional runtime backend override for new spawns; tmux HERDR_SESSION=default # herdr-only: named session for normal backend ops; not enough for destructive cleanup (docs/herdr-backend.md) FM_BACKEND_HERDR_COMPOSER_LINES=20 # herdr-only: tail lines scanned by composer-state guard/fallback paths; idle-baseline submit confirmation uses agent-state FM_BACKEND_HERDR_IDLE_RE='^Type a message\.\.\.$' # herdr-only: empty-composer placeholder regex after shared ghost extraction plus border and prompt stripping -FM_BACKEND_HERDR_BARE_PROMPT_RE='^[❯›]' # herdr-only: verified agent glyphs recognized as an UNBORDERED (bare) composer row, e.g. claude's ❯ or codex's ›; shell glyphs remain unknown rather than empty, and de-emphasised ghost/placeholder text (dim or dark-truecolor) after an agent prompt reads empty via the shared fm_composer_strip_ghost (docs/herdr-backend.md "Incident (2026-07-08)", "Incident (2026-07-10)") +FM_BACKEND_HERDR_BARE_PROMPT_RE='^(❯|›)' # herdr-only: verified agent glyphs recognized as an UNBORDERED (bare) composer row, e.g. claude's ❯ or codex's ›; shell glyphs remain unknown rather than empty, and de-emphasised ghost/placeholder text (dim or dark-truecolor) after an agent prompt reads empty via the shared fm_composer_strip_ghost (docs/herdr-backend.md "Incident (2026-07-08)", "Incident (2026-07-10)"); spell an override as an alternation, never a [❯›] bracket class, which a byte-oriented locale matches byte-wise and misreads composer borders through (see the default's comment in bin/backends/herdr.sh) FM_BACKEND_HERDR_PI_COMPOSER_MAX_LINES=8 # herdr-only: maximum rows admitted between Pi's native-identity-corroborated separator pair; taller or ambiguous candidates stay unknown (docs/herdr-backend.md "Incident (2026-07-14)") FM_BACKEND_HERDR_SUBMIT_POLLS=6 # herdr-only: agent-state samples spread across each Enter attempt's budget when confirming a submit (docs/herdr-backend.md "Native agent-state submit confirmation") FM_BACKEND_HERDR_SUBMIT_MIN_SLEEP=0.6 # herdr-only: minimum per-Enter confirmation budget before polling agent-state after an idle baseline diff --git a/docs/scripts.md b/docs/scripts.md index 8ed71b89f..155a1d836 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -9,6 +9,7 @@ The shared no-mistakes gate refusal used by `fm-spawn.sh`, `fm-send.sh`, and `fm | ------------------------ | ------------------------------------------------------------------------------------ | | `fm-session-start.sh` | Compose lock, bootstrap, and wake drain into the single ordered session-start digest | | `fm-bootstrap.sh` | Detect toolchain and fleet problems, run the locked session-start sweeps, and install approved tools | +| `fm-gate-fork-assert.sh` | Assert a push-writable fork target for each gate-initialized repo whose upstream this gh user cannot push to, so a ship cannot 403 at push after the whole pipeline | | `fm-fleet-sync.sh` | Refresh project clones with safe fast-forwards, self-heals, `STUCK:` reports, branch pruning, and bounded recovery from an orphaned `.git/packed-refs.lock` | | `fm-fleet-snapshot.sh` | Print the read-only structured fleet snapshot JSON (schema `fm-fleet-snapshot.v1`) | | `fm-fleet-view.sh` | Render the fleet snapshot as a human Markdown view | diff --git a/tests/fm-gate-fork-assert.test.sh b/tests/fm-gate-fork-assert.test.sh new file mode 100755 index 000000000..bd6a352e9 --- /dev/null +++ b/tests/fm-gate-fork-assert.test.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# Behavior tests for fm-gate-fork-assert.sh, the fork-push pre-flight guard. +# +# The guard reads the local no-mistakes gate DB (~/.no-mistakes/state.sqlite, +# overridable via FM_GATE_STATE_DB) and, for each gate-initialized repo whose +# upstream the current gh user cannot push to, asserts a push-writable fork_url. +# It prints one GATE_FORK: line per offending repo and exits non-zero, or stays +# silent and exits 0 when every repo is healthy or it cannot decide. +# +# gh is stubbed via a PATH shim: the current user is `zachlandes`, whose own +# forks (repos/zachlandes/*) are push-writable and everything else is not. sqlite3 +# is required (the guard's data source); the suite skips if it is absent. +set -u + +# shellcheck source=tests/lib.sh disable=SC1091 +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +BASE_PATH=${FM_TEST_BASE_PATH:-/usr/bin:/bin:/usr/sbin:/sbin} +SCRIPT="$ROOT/bin/fm-gate-fork-assert.sh" + +command -v sqlite3 >/dev/null 2>&1 || { pass "sqlite3 absent - guard is a silent no-op, nothing to test"; exit 0; } + +TMP=$(fm_test_tmproot fm-gate-fork-tests) + +# A fake gh: authenticated as zachlandes; only repos/zachlandes/* are writable. +FAKEBIN=$(fm_fakebin "$TMP") +cat > "$FAKEBIN/gh" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = auth ] && [ "${2:-}" = status ]; then exit 0; fi +if [ "${1:-}" = api ] && [ "${2:-}" = user ]; then echo zachlandes; exit 0; fi +if [ "${1:-}" = api ]; then + case "$2" in + repos/zachlandes/*) echo true ;; + repos/*) echo false ;; + *) echo false ;; + esac + exit 0 +fi +exit 0 +SH +chmod +x "$FAKEBIN/gh" + +# Build a gate DB with the real repos schema and the given INSERT rows. +make_db() { + local db=$1; shift + sqlite3 "$db" "CREATE TABLE repos (id TEXT PRIMARY KEY, working_path TEXT NOT NULL UNIQUE, upstream_url TEXT NOT NULL, fork_url TEXT, default_branch TEXT NOT NULL DEFAULT 'main', created_at INTEGER NOT NULL);" + local row + for row in "$@"; do + sqlite3 "$db" "INSERT INTO repos VALUES ($row);" + done +} + +run_guard() { + local db=$1 + PATH="$FAKEBIN:$BASE_PATH" FM_GATE_STATE_DB="$db" "$SCRIPT" 2>/dev/null +} + +# --- Case 1: fork_url unset + unwritable upstream -> fires ------------------- +db1="$TMP/case1.sqlite" +make_db "$db1" "'a','/code/firstmate','https://github.com/kunchenguid/firstmate',NULL,'main',0" +out=$(run_guard "$db1"); rc=$? +[ "$rc" -ne 0 ] || fail "unset fork_url on unwritable upstream must exit non-zero (got $rc)" +case $out in + *"GATE_FORK: /code/firstmate: no push access to kunchenguid/firstmate and no fork target"*) ;; + *) fail "expected GATE_FORK no-fork line, got: $out" ;; +esac +case $out in + *"no-mistakes init --fork-url git@github.com:zachlandes/firstmate.git"*) ;; + *) fail "expected remediation with the derived fork url, got: $out" ;; +esac +pass "fires with a GATE_FORK remediation when fork_url is unset and upstream is unwritable" + +# --- Case 2: fork_url set + writable -> quiet ------------------------------- +db2="$TMP/case2.sqlite" +make_db "$db2" "'a','/code/firstmate','https://github.com/kunchenguid/firstmate','https://github.com/zachlandes/firstmate.git','main',0" +out=$(run_guard "$db2"); rc=$? +[ "$rc" -eq 0 ] || fail "writable fork_url must exit 0 (got $rc)" +[ -z "$out" ] || fail "writable fork_url must stay silent, got: $out" +pass "stays quiet when a push-writable fork_url is set" + +# --- Case 3: writable upstream (no fork needed) -> quiet -------------------- +db3="$TMP/case3.sqlite" +make_db "$db3" "'a','/code/mine','https://github.com/zachlandes/mine.git',NULL,'main',0" +out=$(run_guard "$db3"); rc=$? +[ "$rc" -eq 0 ] || fail "writable upstream must exit 0 (got $rc)" +[ -z "$out" ] || fail "writable upstream must stay silent, got: $out" +pass "stays quiet when the upstream itself is push-writable" + +# --- Case 4: fork_url set but not writable -> fires ------------------------- +db4="$TMP/case4.sqlite" +make_db "$db4" "'a','/code/lav','https://github.com/kunchenguid/lavish-axi','https://github.com/kunchenguid/lavish-axi.git','main',0" +out=$(run_guard "$db4"); rc=$? +[ "$rc" -ne 0 ] || fail "non-writable fork_url must exit non-zero (got $rc)" +case $out in + *"fork kunchenguid/lavish-axi not push-writable"*) ;; + *) fail "expected non-writable-fork line, got: $out" ;; +esac +pass "fires when fork_url is set but not push-writable" + +# --- Case 5: non-github upstream -> unverifiable, quiet --------------------- +db5="$TMP/case5.sqlite" +make_db "$db5" "'a','/code/gl','https://gitlab.com/foo/bar.git',NULL,'main',0" +out=$(run_guard "$db5"); rc=$? +[ "$rc" -eq 0 ] || fail "non-github upstream must exit 0 (got $rc)" +[ -z "$out" ] || fail "non-github upstream must stay silent, got: $out" +pass "stays quiet for a non-github upstream it cannot verify" + +# --- Case 6: missing DB / unauthenticated gh -> silent no-op --------------- +out=$(PATH="$FAKEBIN:$BASE_PATH" FM_GATE_STATE_DB="$TMP/does-not-exist.sqlite" "$SCRIPT" 2>/dev/null); rc=$? +[ "$rc" -eq 0 ] || fail "missing gate DB must exit 0 (got $rc)" +[ -z "$out" ] || fail "missing gate DB must stay silent, got: $out" + +cat > "$FAKEBIN/gh-noauth" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = auth ] && [ "${2:-}" = status ]; then exit 1; fi +exit 0 +SH +chmod +x "$FAKEBIN/gh-noauth" +NOAUTH=$(fm_fakebin "$TMP/noauth") +cp "$FAKEBIN/gh-noauth" "$NOAUTH/gh" +out=$(PATH="$NOAUTH:$BASE_PATH" FM_GATE_STATE_DB="$db1" "$SCRIPT" 2>/dev/null); rc=$? +[ "$rc" -eq 0 ] || fail "unauthenticated gh must exit 0 - NEEDS_GH_AUTH owns that case (got $rc)" +[ -z "$out" ] || fail "unauthenticated gh must stay silent, got: $out" +pass "is a silent no-op when the gate DB is absent or gh is unauthenticated" + +# --- Case 7: transient gh api failure -> cannot decide, silent no-op -------- +# A rate-limited / offline `gh api repos/` probe must NOT be read as +# push:false and flagged as needing a fork (the transient-false-positive +# regression): a failed probe is undecidable, so the guard stays silent. +FAILBIN=$(fm_fakebin "$TMP/ghfail") +cat > "$FAILBIN/gh" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = auth ] && [ "${2:-}" = status ]; then exit 0; fi +if [ "${1:-}" = api ] && [ "${2:-}" = user ]; then echo zachlandes; exit 0; fi +if [ "${1:-}" = api ]; then echo "gh: API rate limit exceeded" >&2; exit 1; fi +exit 0 +SH +chmod +x "$FAILBIN/gh" +out=$(PATH="$FAILBIN:$BASE_PATH" FM_GATE_STATE_DB="$db1" "$SCRIPT" 2>/dev/null); rc=$? +[ "$rc" -eq 0 ] || fail "a failed gh push probe must not flag the repo (got $rc)" +[ -z "$out" ] || fail "a failed gh push probe must stay silent, got: $out" +pass "stays silent when the gh push probe fails instead of flagging a false positive" diff --git a/tests/lib.sh b/tests/lib.sh index add7f2904..2d1ff5b07 100644 --- a/tests/lib.sh +++ b/tests/lib.sh @@ -39,6 +39,17 @@ export FM_GATE_REFUSE_BYPASS=1 # shellcheck disable=SC2034 ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# Keep bin/fm-bootstrap.sh's fork-push pre-flight (bin/fm-gate-fork-assert.sh) +# hermetic in every test. The guard reads the machine-global no-mistakes gate DB +# (~/.no-mistakes/state.sqlite) and probes each repo's push access over the real +# gh network - a bootstrap-invoking test would otherwise reach the network and +# the developer's real DB. Force it unconditionally (like FM_GATE_REFUSE_BYPASS +# above) at a guaranteed-absent path so a pre-exported real value cannot leak +# through, making the check an instant silent no-op. The dedicated test +# (tests/fm-gate-fork-assert.test.sh) still exercises the logic by setting +# FM_GATE_STATE_DB per-command, which overrides this process-level export. +export FM_GATE_STATE_DB="$ROOT/tests/.no-such-dir/gate-absent.sqlite" + # --- reporters -------------------------------------------------------------- fail() {