Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>.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 <branch>` 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.
Expand Down
77 changes: 77 additions & 0 deletions bin/fm-nm-gate.sh
Original file line number Diff line number Diff line change
@@ -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 <branch>) 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 <project-clone-dir>
set -eu

usage() {
echo "usage: fm-nm-gate.sh <project-clone-dir>" >&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
11 changes: 11 additions & 0 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
104 changes: 104 additions & 0 deletions tests/fm-nm-gate.test.sh
Original file line number Diff line number Diff line change
@@ -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() { # <dir> -> 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() { # <dir> [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"
Loading