From 33258dc4a22569db2b3dbfb64aae868aa2c6eefb Mon Sep 17 00:00:00 2001 From: Jacky Date: Mon, 29 Jun 2026 22:05:37 +0800 Subject: [PATCH] fix(spawn): refresh no-mistakes gate so worktree pushes create a run Add bin/fm-nm-gate.sh and call it from fm-spawn for no-mistakes-mode ship tasks. An older no-mistakes left a post-receive hook with a relative gate path, so a crewmate pushing from a treehouse worktree failed with 'invalid gate path: .' and no pipeline run was created. The idempotent no-mistakes init refreshes the shared gate hook; running it at spawn self-heals stale gates without touching project files. Best-effort and non-fatal. --- AGENTS.md | 3 ++ bin/fm-nm-gate.sh | 77 +++++++++++++++++++++++++++++ bin/fm-spawn.sh | 11 +++++ tests/fm-nm-gate.test.sh | 104 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 195 insertions(+) create mode 100755 bin/fm-nm-gate.sh create mode 100755 tests/fm-nm-gate.test.sh diff --git a/AGENTS.md b/AGENTS.md index 32fb84d7..48d35bdb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -347,6 +347,9 @@ The script resolves the harness (`fm-harness.sh crew`), owns the verified launch For `kind=secondmate`, the same script launches in the registered or explicit firstmate home instead of running `treehouse get` for a project, records `home=` and `projects=`, and uses the charter brief as the launch prompt. For ship and scout tasks, the script creates the window (in your current tmux session, or a dedicated `firstmate` session when you are outside tmux), runs `treehouse get`, waits for the worktree subshell, asserts the resolved worktree is a genuine isolated worktree distinct from the primary checkout (aborting the spawn otherwise, to prevent the worktree tangle of section 8), installs the turn-end hook, records `state/.meta`, and launches the agent with the brief. +For a `no-mistakes`-mode ship task, the script also refreshes the project's gate via `bin/fm-nm-gate.sh` (the sanctioned idempotent `no-mistakes init` of section 6) before launch, so a crewmate pushing `no-mistakes ` from its treehouse worktree always hits a current post-receive hook and a run is created. +no-mistakes keys one shared gate per repo identity (the origin URL), used by the main clone and every worktree of it; an older `no-mistakes init` left a stale hook that failed the push with `invalid gate path` and silently skipped the run, and this refresh self-heals that without touching project files. +The refresh is best-effort: a failure warns to stderr and never blocks the spawn. For `kind=secondmate`, the script creates the same kind of window but starts directly in the persistent home. Before launching a secondmate, the script fast-forwards its home worktree to firstmate's own current default-branch commit, so a freshly spawned or recovery-respawned secondmate always starts on firstmate's current version. This is a purely local fast-forward of tracked files - never a fetch from origin, and never touching the gitignored operational dirs - so the secondmate's backlog, projects, and any prior in-flight work are untouched; a dirty, diverged, or in-flight home is left as-is and launches unchanged. diff --git a/bin/fm-nm-gate.sh b/bin/fm-nm-gate.sh new file mode 100755 index 00000000..ebce09c7 --- /dev/null +++ b/bin/fm-nm-gate.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Ensure a no-mistakes-mode project's gate is initialized and its post-receive +# hook is current, so a crewmate pushing from a treehouse-pooled worktree +# (git push no-mistakes ) actually creates a pipeline run instead of +# failing the push with "invalid gate path". +# +# Why this exists: no-mistakes keys ONE gate per repo identity (the origin URL), +# shared by the main clone and every linked/treehouse worktree of it - there is +# no per-worktree gate, and `no-mistakes init` run inside a worktree just +# refreshes that one shared gate. The gate's post-receive hook is installed by +# `no-mistakes init`. An older no-mistakes installed a hook that passed a +# RELATIVE gate path (git runs hooks with a relative GIT_DIR, so it resolved to +# "."), which the daemon rejects with "invalid gate path: ."; no run is created +# and a later `rerun` then reports "no previous run for branch". Current +# no-mistakes passes an absolute path ("$(pwd)") and works, but that fix only +# lands on an already-initialized bare repo when init is re-run. `no-mistakes +# init` is idempotent ("Gate already initialized (refreshed)") and refreshes the +# hook and the no-mistakes remote, so running it here heals a stale gate before a +# crewmate ever pushes. +# +# This is the AGENTS.md section 6 sanctioned-init exception: it runs git +# remote/config setup inside the project but never edits, commits, or otherwise +# mutates project files. It is best-effort and non-fatal - a refresh failure +# warns to stderr but never blocks the caller, because the gate may already be +# healthy and the crewmate's own /no-mistakes run would surface a real problem. +# +# Mode-agnostic by design: the caller decides when a project is no-mistakes mode +# (fm-spawn calls this only for no-mistakes-mode ship tasks). Prints one concise +# status line to stdout; warnings go to stderr. +# Usage: fm-nm-gate.sh +set -eu + +usage() { + echo "usage: fm-nm-gate.sh " >&2 +} + +case "${1:-}" in + -h|--help) usage; exit 0 ;; +esac +[ "$#" -eq 1 ] || { usage; exit 2; } + +DIR=$1 +[ -d "$DIR" ] || { echo "error: not a directory: $DIR" >&2; exit 2; } +DIR=$(cd "$DIR" && pwd -P) + +# Best-effort guards below: each prints a skip line and exits 0, because a gate +# that cannot be refreshed here is not a reason to block a spawn. + +if ! git -C "$DIR" rev-parse --git-dir >/dev/null 2>&1; then + echo "nm-gate: skipped $DIR (not a git repository)" + exit 0 +fi + +# no-mistakes init requires an origin remote to identify the gate; without one +# this is not a gate-backed clone (or it is local-only) and there is nothing to +# refresh. +if ! git -C "$DIR" remote get-url origin >/dev/null 2>&1; then + echo "nm-gate: skipped $DIR (no origin remote)" + exit 0 +fi + +if ! command -v no-mistakes >/dev/null 2>&1; then + echo "nm-gate: skipped $DIR (no-mistakes not installed)" >&2 + exit 0 +fi + +# Refresh the gate. init keys off the origin URL, so running it in the main +# clone refreshes the one shared bare repo every worktree pushes to, repairing a +# stale post-receive hook in place. +if init_out=$(cd "$DIR" && no-mistakes init 2>&1); then + echo "nm-gate: refreshed $DIR" + exit 0 +fi + +echo "nm-gate: warning $DIR (no-mistakes init failed; gate may be stale)" >&2 +printf '%s\n' "$init_out" >&2 +exit 0 diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 38747d5c..cd1d9f3c 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -465,6 +465,17 @@ $("$FM_ROOT/bin/fm-project-mode.sh" "$PROJ_NAME") EOF fi +# no-mistakes gate refresh: for a no-mistakes-mode ship task, make sure the +# project's shared gate hook is current before the crewmate pushes through it, +# so a stale old-version hook can't fail the push with "invalid gate path" and +# silently skip the pipeline run (bin/fm-nm-gate.sh). The gate is shared by the +# main clone and every worktree of it, so refreshing the main clone (PROJ_ABS) +# heals the one bare repo the worktree pushes to. Best-effort: never block the +# spawn, and keep its status line off this script's parseable stdout. +if [ "$KIND" = ship ] && [ "$MODE" = no-mistakes ]; then + "$FM_ROOT/bin/fm-nm-gate.sh" "$PROJ_ABS" >/dev/null || true +fi + mkdir -p "$STATE" { echo "window=$T" diff --git a/tests/fm-nm-gate.test.sh b/tests/fm-nm-gate.test.sh new file mode 100755 index 00000000..b95851ee --- /dev/null +++ b/tests/fm-nm-gate.test.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# Behavior tests for bin/fm-nm-gate.sh - the no-mistakes gate refresh helper. +# +# The gate is shared by a project's main clone and every treehouse worktree of +# it (no-mistakes keys one gate per origin URL). An older no-mistakes installed a +# post-receive hook with a RELATIVE gate path, so a worktree push failed with +# "invalid gate path: ." and no run was created. The fix is to re-run the +# idempotent `no-mistakes init` to refresh the hook. This helper does exactly +# that, guarded and best-effort, so a stale or absent gate never blocks a spawn. +# These cases pin every branch hermetically with a fake `no-mistakes`: +# (a) usage error (no arg) -> exit 2 +# (b) usage error (too many args) -> exit 2 +# (c) non-directory / non-git path -> skip, exit 0 +# (d) git repo without an origin remote -> skip, exit 0 +# (e) git repo + origin + no-mistakes installed -> refreshed (init invoked) +# (f) no-mistakes not on PATH -> skip, exit 0 +# (g) `no-mistakes init` fails -> warning, exit 0 (non-fatal) +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +NM_GATE="$ROOT/bin/fm-nm-gate.sh" +TMP_ROOT=$(fm_test_tmproot fm-nm-gate) +mkdir -p "$TMP_ROOT" +fm_git_identity fmtest fmtest@example.invalid + +# A fakebin whose `no-mistakes init` records that it ran (so we can prove the +# helper invoked the refresh) and honors FM_FAKE_NM_INIT_RC for the failure case. +make_fakebin() { # -> echoes fakebin path + local dir=$1 fb="$1/fakebin" + mkdir -p "$fb" + cat > "$fb/no-mistakes" <<'SH' +#!/usr/bin/env bash +set -u +if [ "${1:-}" = init ]; then + printf 'init-ran\n' >> "$FM_FAKE_NM_INIT_LOG" + exit "${FM_FAKE_NM_INIT_RC:-0}" +fi +exit 0 +SH + chmod +x "$fb/no-mistakes" + printf '%s\n' "$fb" +} + +# A git repo with one commit; origin added only when requested. +make_repo() { # [origin-bare] + local dir=$1 origin=${2:-} + mkdir -p "$dir" + git -C "$dir" init -q + git -C "$dir" commit -q --allow-empty -m init + if [ -n "$origin" ]; then + git -C "$dir" init -q --bare "$origin" + git -C "$dir" remote add origin "file://$origin" + fi +} + +# (a) no arg -> usage error, exit 2 +out=$("$NM_GATE" 2>&1); rc=$? +expect_code 2 "$rc" "no-arg should exit 2" +assert_contains "$out" "usage:" "no-arg should print usage" + +# (b) too many args -> usage error, exit 2 +out=$("$NM_GATE" a b 2>&1); rc=$? +expect_code 2 "$rc" "too-many-args should exit 2" + +# (c) non-git directory -> skip, exit 0 +NONGIT="$TMP_ROOT/plain" +mkdir -p "$NONGIT" +out=$("$NM_GATE" "$NONGIT" 2>&1); rc=$? +expect_code 0 "$rc" "non-git dir should exit 0" +assert_contains "$out" "skipped" "non-git dir should be skipped" +assert_contains "$out" "not a git repository" "non-git dir reason" + +# (d) git repo without origin -> skip, exit 0 +NOORIGIN="$TMP_ROOT/noorigin" +make_repo "$NOORIGIN" +out=$("$NM_GATE" "$NOORIGIN" 2>&1); rc=$? +expect_code 0 "$rc" "no-origin repo should exit 0" +assert_contains "$out" "no origin remote" "no-origin reason" + +# (e) git repo + origin + fake no-mistakes -> refreshed, init actually invoked +WITHORIGIN="$TMP_ROOT/withorigin" +make_repo "$WITHORIGIN" "$TMP_ROOT/origin.git" +FB=$(make_fakebin "$TMP_ROOT") +export FM_FAKE_NM_INIT_LOG="$TMP_ROOT/init.log" +: > "$FM_FAKE_NM_INIT_LOG" +out=$(PATH="$FB:$PATH" "$NM_GATE" "$WITHORIGIN" 2>&1); rc=$? +expect_code 0 "$rc" "healthy refresh should exit 0" +assert_contains "$out" "refreshed" "healthy path should report refreshed" +assert_grep "init-ran" "$FM_FAKE_NM_INIT_LOG" "no-mistakes init must be invoked" + +# (f) no-mistakes not installed -> skip, exit 0 (PATH without the fakebin or real binary) +out=$(PATH="/usr/bin:/bin" "$NM_GATE" "$WITHORIGIN" 2>&1); rc=$? +expect_code 0 "$rc" "missing no-mistakes should exit 0" +assert_contains "$out" "no-mistakes not installed" "missing-binary reason" + +# (g) no-mistakes init fails -> warning, still exit 0 (non-fatal) +: > "$FM_FAKE_NM_INIT_LOG" +out=$(PATH="$FB:$PATH" FM_FAKE_NM_INIT_RC=1 "$NM_GATE" "$WITHORIGIN" 2>&1); rc=$? +expect_code 0 "$rc" "init failure must stay non-fatal (exit 0)" +assert_contains "$out" "warning" "init failure should warn" + +pass "fm-nm-gate.sh: usage, skip, refresh, and non-fatal failure paths hold"