diff --git a/.github/workflows/no-mistakes-required.yml b/.github/workflows/no-mistakes-required.yml index af5564e865..f5448e194b 100644 --- a/.github/workflows/no-mistakes-required.yml +++ b/.github/workflows/no-mistakes-required.yml @@ -9,6 +9,9 @@ on: permissions: contents: read + # Read-only, and only used to re-read this PR's live body when the event + # payload carries a body no-mistakes has already replaced. + pull-requests: read # GitHub concurrency groups retain at most one pending run, replacing older # pending runs even when cancel-in-progress is false. Give body-bearing events @@ -31,23 +34,35 @@ jobs: PR_BODY: ${{ github.event.pull_request.body }} PR_AUTHOR: ${{ github.event.pull_request.user.login }} PR_NUMBER: ${{ github.event.pull_request.number }} + PR_REPO: ${{ github.repository }} + GH_TOKEN: ${{ github.token }} run: | set -eu marker='Updates from [git push no-mistakes](https://github.com/kunchenguid/no-mistakes)' - if printf '%s' "${PR_BODY:-}" | grep -qF -- "$marker"; then - echo "Found no-mistakes signature in PR #${PR_NUMBER} body." + prefix='' + + # Classify one body snapshot into `verdict` (ok, no-signature, + # no-attestation, incomplete) plus, for incomplete, `incomplete`. + verdict='' + incomplete='' + classify_body() { + snapshot=$1 + verdict='' + incomplete='' + if ! printf '%s' "$snapshot" | grep -qF -- "$marker"; then + verdict=no-signature + return 0 + fi if ! command -v jq >/dev/null 2>&1; then echo "::error::This check requires jq to parse no-mistakes pipeline step attestation, but jq was not found on the runner." >&2 exit 1 fi - prefix='' - body="${PR_BODY:-}" json='' parse_ok=0 - case "$body" in + case "$snapshot" in *"$prefix"*) - rest="${body#*"$prefix"}" + rest="${snapshot#*"$prefix"}" case "$rest" in *"$suffix"*) json="${rest%%"$suffix"*}" @@ -59,6 +74,57 @@ jobs: ;; esac if [ "$parse_ok" -ne 1 ]; then + verdict=no-attestation + return 0 + fi + for required in review test document; do + status=$(printf '%s' "$json" | jq -r --arg step "$required" \ + '([(.steps | arrays | .[]) | select(.step == $step) | .status] | first // empty | select(. != "")) // "missing"') + if [ "$status" != "completed" ]; then + if [ -n "$incomplete" ]; then + incomplete="${incomplete}, " + fi + incomplete="${incomplete}${required}=${status}" + fi + done + if [ -n "$incomplete" ]; then + verdict=incomplete + return 0 + fi + verdict=ok + } + + # no-mistakes pushes the branch and opens the PR before it writes the + # pipeline body, so an opened/synchronize payload can carry a body + # that was already replaced by the time this job starts. Re-read the + # live body before failing, so the gate reports the PR's real state + # instead of a race it cannot influence. Every event is still + # evaluated on its own run against the body that is live for it. + body="${PR_BODY:-}" + attempt=1 + attempts=6 + while :; do + classify_body "$body" + if [ "$verdict" = ok ] || [ "$attempt" -ge "$attempts" ]; then + break + fi + echo "PR #${PR_NUMBER} body is not compliant yet (${verdict}); re-reading the live body (attempt ${attempt} of $((attempts - 1)))." + sleep 10 + attempt=$((attempt + 1)) + if live=$(gh api "repos/${PR_REPO}/pulls/${PR_NUMBER}" --jq '.body // ""' 2>/dev/null); then + body="$live" + else + echo "Could not re-read the PR body from the API; keeping the event payload body." + fi + done + + case "$verdict" in + ok) + echo "Found no-mistakes signature in PR #${PR_NUMBER} body." + echo "Pipeline step attestation is valid: review, test, and document are completed." + exit 0 + ;; + no-attestation) { echo "::error::This repository requires no-mistakes >= 1.46.0; structured pipeline step attestation is missing or unparseable." echo @@ -77,19 +143,8 @@ jobs: echo "PR author: ${PR_AUTHOR}" } >&2 exit 1 - fi - incomplete='' - for required in review test document; do - status=$(printf '%s' "$json" | jq -r --arg step "$required" \ - '([(.steps | arrays | .[]) | select(.step == $step) | .status] | first // empty | select(. != "")) // "missing"') - if [ "$status" != "completed" ]; then - if [ -n "$incomplete" ]; then - incomplete="${incomplete}, " - fi - incomplete="${incomplete}${required}=${status}" - fi - done - if [ -n "$incomplete" ]; then + ;; + incomplete) { echo "::error::Required no-mistakes pipeline steps are not completed: ${incomplete}." echo @@ -103,21 +158,21 @@ jobs: echo "PR author: ${PR_AUTHOR}" } >&2 exit 1 - fi - echo "Pipeline step attestation is valid: review, test, and document are completed." - exit 0 - fi - { - echo "::error::This PR was not raised through no-mistakes." - echo - echo "Contributions to this repository must be submitted via 'git push no-mistakes'." - echo "That pipeline runs the required review/test/lint/CI steps and writes a" - echo "deterministic '## Pipeline' section into the PR body containing:" - echo - echo " $marker" - echo - echo "See CONTRIBUTING.md for setup and the full workflow." - echo - echo "PR author: ${PR_AUTHOR}" - } >&2 - exit 1 + ;; + *) + { + echo "::error::This PR was not raised through no-mistakes." + echo + echo "Contributions to this repository must be submitted via 'git push no-mistakes'." + echo "That pipeline runs the required review/test/lint/CI steps and writes a" + echo "deterministic '## Pipeline' section into the PR body containing:" + echo + echo " $marker" + echo + echo "See CONTRIBUTING.md for setup and the full workflow." + echo + echo "PR author: ${PR_AUTHOR}" + } >&2 + exit 1 + ;; + esac diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 98cc88a5f6..0f28c6bfb7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,6 +11,7 @@ Pushing through it runs an AI-driven review/test/lint pipeline in an isolated wo A GitHub Actions check (`Require no-mistakes`) runs on PRs targeting `main` and fails if the body is missing the deterministic signature that no-mistakes writes. It evaluates every PR opening and body edit independently, so a later edit cannot replace an earlier pending compliance check. +Because no-mistakes pushes the branch and opens the PR before it writes the pipeline body, a run whose event payload lacks that signature re-reads the PR's live body for up to a minute before failing, so the gate never reports a race it cannot influence. GitHub Actions and Dependabot are exempt so their automation keeps working, but regular contributor PRs without the signature will not be reviewed or merged. ## Workflow diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 31d5bbde83..f2f08f7b7c 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -2259,6 +2259,22 @@ elif [ "$KIND" != secondmate ] && [ "$BACKEND" != orca ]; then fi validate_spawn_worktree "treehouse get" "$T" + + if [ -x "$SCRIPT_DIR/fm-treehouse-pool-sweep.sh" ]; then + sweep_rc=0 + FM_CONFIG_OVERRIDE="$CONFIG" \ + "$SCRIPT_DIR/fm-treehouse-pool-sweep.sh" "$WT" || sweep_rc=$? + if [ "$sweep_rc" -ne 0 ]; then + # The refusal deliberately keeps the acquired slot: the sweep refuses + # precisely when the worktree may hold work nothing else references, and + # returning it here would discard exactly that. The slot stays held, and + # window $T stays open, until an operator has looked at the state; say so + # rather than leaving the hold to look like a leak. + echo "error: worktree pool sweep refused worktree $WT (exit $sweep_rc); inspect unsafe state or disable sweep in config/worktree-pool-sweep" >&2 + echo "error: the pool slot for $WT stays held and window $T stays open so its unsafe state is preserved for inspection; return it by hand once its work is safe" >&2 + exit 1 + fi + fi fi if [ "$RELAUNCH" -eq 0 ] && [ "$KIND" != secondmate ]; then freshen_spawn_worktree_base "$WT" || exit 1 diff --git a/bin/fm-treehouse-pool-sweep.sh b/bin/fm-treehouse-pool-sweep.sh new file mode 100755 index 0000000000..f472405e9d --- /dev/null +++ b/bin/fm-treehouse-pool-sweep.sh @@ -0,0 +1,276 @@ +#!/usr/bin/env bash +# fm-treehouse-pool-sweep.sh - Pre-acquire worktree pool safety sweep +# +# This is a MITIGATION (not a fix) for the worktree reuse incident. It inspects +# pooled worktrees before acquisition and refuses to request one when unsafe pool +# state is observed. The upstream invariant is: "No consumer can reuse a worktree +# whose state is unsafe." This mitigation can only observe and refuse; it cannot +# enforce the invariant across all consumers. +# +# Usage: fm-treehouse-pool-sweep.sh +# +# The sweep checks two conditions and refuses on either: +# 1. Dirty worktree: tracked modifications, staged changes, or untracked +# non-ignored files. +# 2. HEAD contains at least one commit not reachable from an approved durable ref: +# - refs/heads/* (local branches) +# - refs/tags/* (tags) +# - refs/firstmate/rescue/* (reserved rescue namespace) +# +# The branch HEAD is attached to does NOT count as one of those durable refs. +# fm-spawn.sh's freshen_spawn_worktree_base hard-resets the checked-out branch +# onto origin's default branch immediately after acquisition, so a commit held +# only by that branch is about to be discarded, not preserved: counting it would +# green-light exactly the unlanded lane work this sweep exists to protect. +# +# Reflogs are NOT refs. A commit reachable only from a reflog is unreferenced. +# +# A question git cannot answer - an unreadable HEAD, a corrupt ref database, an +# unreadable object - refuses under the same exit code as the negative answer it +# resembles, but with its own diagnostic, so the operator is not sent looking for +# uncommitted work or orphaned commits that do not exist. +# +# For refs/remotes/*: they are counted for reachability so an ordinary freshly- +# checked-out pool worktree is not falsely refused, but the case where HEAD's +# commits are covered ONLY by remote-tracking refs (and no local head or tag) is +# classified as unsafe. +# +# Exit codes: +# 0 - Worktree is safe to acquire (or sweep is disabled) +# 1 - Worktree is unsafe: dirty, or its dirty state cannot be determined +# 2 - Worktree is unsafe: HEAD contains commits not reachable from durable refs, +# or that reachability cannot be computed +# 3 - Worktree is unsafe: HEAD covered only by remote-tracking refs (prunable) +# 4 - Worktree does not exist +# 64 - Usage error (no worktree path given) +# +# Activation: +# The sweep is disabled by default. To enable, create: +# $FM_HOME/config/worktree-pool-sweep +# containing "on" (or any non-empty value other than "off"). +# The config dir is $FM_CONFIG_OVERRIDE when set, otherwise $FM_HOME/config, +# and $FM_HOME defaults to the firstmate repo root - the same resolution every +# other firstmate script uses, so an enable written for one home applies to +# that home only. +# A missing file, an empty file, or the value "off" leaves the sweep disabled. +# +# This mitigation is distinct from the upstream Treehouse invariant: +# - MITIGATION: "Firstmate refuses to request a worktree when it observes unsafe pool state." +# - INVARIANT: "No consumer can reuse a worktree whose state is unsafe." +# +# Structural gaps this mitigation cannot close: +# 1. A direct treehouse get by anything other than firstmate bypasses the sweep. +# 2. Another firstmate home can race between sweep and acquire: +# +# T1 Firstmate A sweeps -> safe +# T2 Firstmate B acquires/modifies the same pool +# T3 Firstmate A calls treehouse get +# +# This race can cause the worktree to be unsafe when Firstmate A uses it. +# The eventual Treehouse fix must kill this atomically at allocation time. +set -euo pipefail + +usage() { + # The whole leading comment block, ending at the first line that is not a + # comment, so --help cannot drift away from the header above. + sed -n '2,${/^#/!q;p;}' "$0" | sed 's/^# \{0,1\}//' +} + +if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then + usage + exit 0 +fi + +WT="${1:-}" +if [ -z "$WT" ]; then + echo "error: worktree path required" >&2 + usage >&2 + exit 64 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-$FM_ROOT}" +CONFIG_DIR="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" +SWEEP_CONFIG="$CONFIG_DIR/worktree-pool-sweep" + +is_sweep_enabled() { + if [ -f "$SWEEP_CONFIG" ]; then + local val + val=$(cat "$SWEEP_CONFIG" 2>/dev/null | tr -d '[:space:]') + [ -n "$val" ] && [ "$val" != "off" ] + else + return 1 + fi +} + +if ! is_sweep_enabled; then + exit 0 +fi + +if [ ! -d "$WT" ]; then + exit 4 +fi + +head_is_inspectable() { + local wt=$1 + git -C "$wt" rev-parse --verify --quiet HEAD >/dev/null 2>&1 +} + +is_dirty() { + local wt=$1 st untracked + git -C "$wt" update-index -q --ignore-submodules --refresh >/dev/null 2>&1 || true + + st=0 + git -C "$wt" diff-index --quiet --ignore-submodules HEAD 2>/dev/null || st=$? + if [ "$st" -eq 1 ]; then + return 0 + elif [ "$st" -ne 0 ]; then + return 2 + fi + + st=0 + git -C "$wt" diff-index --quiet --ignore-submodules --cached HEAD 2>/dev/null || st=$? + if [ "$st" -eq 1 ]; then + return 0 + elif [ "$st" -ne 0 ]; then + return 2 + fi + + untracked=$(git -C "$wt" ls-files --others --exclude-standard 2>/dev/null) || return 2 + if [ -n "$untracked" ]; then + return 0 + fi + return 1 +} + +count_refs() { + local wt=$1 pattern=$2 + git -C "$wt" for-each-ref --format='%(refname)' "$pattern" 2>/dev/null | wc -l +} + +# The full ref name of the branch HEAD is attached to; empty output when HEAD is +# detached. Returns 2 when git cannot answer the question. +attached_branch_ref() { + local wt=$1 out rc=0 + out=$(git -C "$wt" symbolic-ref --quiet HEAD 2>/dev/null) || rc=$? + case "$rc" in + 0) printf '%s\n' "$out" ;; + 1) : ;; + *) return 2 ;; + esac +} + +# Every ref that would still hold HEAD's commits after the checked-out branch is +# hard-reset, printed as rev-list exclusions. Remote-tracking refs are included +# (the reset target itself lives there); the attached branch is not. +protecting_refs() { + local wt=$1 attached=$2 all ref + all=$(git -C "$wt" for-each-ref --format='%(refname)' \ + refs/heads refs/tags refs/firstmate/rescue refs/remotes 2>/dev/null) || return 2 + while IFS= read -r ref; do + [ -n "$ref" ] || continue + if [ -n "$attached" ] && [ "$ref" = "$attached" ]; then + continue + fi + printf '^%s\n' "$ref" + done </dev/null) || return 2 + case "$unremoted" in + '' | *[!0-9]*) return 2 ;; + esac + [ "$unremoted" -eq 0 ] +} + +check_head_reachable() { + local wt=$1 + local attached excludes unique_count durable_rc=0 remote_rc=0 + + has_durable_refs "$wt" || durable_rc=$? + if [ "$durable_rc" -eq 2 ]; then + return 5 + fi + if [ "$durable_rc" -ne 0 ]; then + head_covered_by_remotes "$wt" || remote_rc=$? + if [ "$remote_rc" -eq 2 ]; then + return 5 + fi + if [ "$remote_rc" -eq 0 ]; then + return 3 + fi + return 2 + fi + + attached=$(attached_branch_ref "$wt") || return 5 + excludes=$(protecting_refs "$wt" "$attached") || return 5 + if [ -n "$excludes" ]; then + unique_count=$(printf '%s\n' "$excludes" \ + | git -C "$wt" rev-list --count --stdin HEAD 2>/dev/null) || return 5 + else + unique_count=$(git -C "$wt" rev-list --count HEAD 2>/dev/null) || return 5 + fi + case "$unique_count" in + '' | *[!0-9]*) return 5 ;; + esac + if [ "$unique_count" -gt 0 ]; then + return 2 + fi + return 0 +} + +if ! head_is_inspectable "$WT"; then + echo "unsafe: cannot inspect HEAD at $WT" >&2 + exit 1 +fi + +dirty_rc=0 +is_dirty "$WT" || dirty_rc=$? + +if [ "$dirty_rc" -eq 0 ]; then + echo "unsafe: dirty worktree at $WT" >&2 + exit 1 +elif [ "$dirty_rc" -eq 2 ]; then + echo "unsafe: cannot determine whether $WT is dirty" >&2 + exit 1 +fi + +rc=0 +check_head_reachable "$WT" || rc=$? + +case "$rc" in + 2) + echo "unsafe: HEAD contains commits not reachable from durable refs in $WT" >&2 + exit 2 + ;; + 3) + echo "unsafe: HEAD commits covered only by remote-tracking refs (prunable) in $WT" >&2 + exit 3 + ;; + 5) + echo "unsafe: cannot compute HEAD reachability in $WT" >&2 + exit 2 + ;; +esac + +exit 0 diff --git a/docs/configuration.md b/docs/configuration.md index c9d0d293a5..f8a7e8dfd1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -127,6 +127,27 @@ A Secondmate on a remote route is covered the same way: the primary resolves and The presence flag is session-scoped enablement, so it transfers at launch and is left unchanged by live convergence into a running home. See [`trace-context.md`](trace-context.md) for carrier semantics, supported routes, the manual fleet-restart requirement, the session boundary, and safety limits; `bin/fm-trace-context-lib.sh`'s header owns the exact mechanics, and [`verification/trace-context.md`](verification/trace-context.md) records repeatable evidence. +## Worktree pool sweep (config/worktree-pool-sweep) + +The optional local, gitignored `config/worktree-pool-sweep` file enables the pre-acquire worktree pool safety sweep, which is shipped deactivated. +A missing file, an empty file, or the value `off` leaves the sweep inert and acquisition unchanged; any other non-empty value activates it. +`config/` resolves the same way as for every other script - `FM_CONFIG_OVERRIDE` when set, otherwise `$FM_HOME/config` - so the enable is per firstmate home, and `bin/fm-spawn.sh` passes its own resolved config dir down to the sweep. + +When active, `bin/fm-spawn.sh` runs `bin/fm-treehouse-pool-sweep.sh` against the pooled worktree before the spawn uses it and refuses on either unsafe condition: + +- Dirty worktree: tracked modifications, staged changes, or untracked non-ignored files. +- HEAD contains commits not reachable from an approved durable ref: local branches (`refs/heads/*`), tags (`refs/tags/*`), or rescue refs (`refs/firstmate/rescue/*`). Reflogs are not refs, so a commit reachable only from a reflog is unreferenced. + The branch HEAD is attached to is not one of those durable refs: the spawn path hard-resets it onto origin's default branch right after acquisition, so a commit held only by that branch is about to be discarded rather than preserved. + +It also fails closed when git cannot answer a probe - an unreadable HEAD, a corrupt ref database, an unreadable object - refusing under the same exit code as the negative answer it resembles but with its own diagnostic. + +A refusal aborts the spawn with an error naming the worktree, the sweep exit code, and this config file. +The refused pool slot stays held and its task window stays open, and the error says so: the sweep refuses precisely when the worktree may hold work nothing else references, so returning the slot there would discard exactly that work. +An operator returns it by hand once its work is safe; the exit codes, diagnostics, and the remote-tracking-ref reachability rules are owned by `bin/fm-treehouse-pool-sweep.sh`'s header (`bin/fm-treehouse-pool-sweep.sh --help`). + +This is a MITIGATION for the worktree reuse incident, not a fix for the underlying Treehouse invariant that no consumer can reuse an unsafe worktree. +Two structural gaps it cannot close: a direct `treehouse get` by anything other than firstmate bypasses the sweep, and another firstmate home can race between sweep and acquire. + ## Gate defaults (.no-mistakes.yaml) The tracked `.no-mistakes.yaml` sets `test.evidence.store_in_repo: true` and pins `commands.lint` to `bin/fm-lint.sh` so local lint matches CI. diff --git a/tests/fm-spawn-pool-sweep-wiring.test.sh b/tests/fm-spawn-pool-sweep-wiring.test.sh new file mode 100755 index 0000000000..61b73e2ac6 --- /dev/null +++ b/tests/fm-spawn-pool-sweep-wiring.test.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +# tests/fm-spawn-pool-sweep-wiring.test.sh - the pool sweep as fm-spawn sees it. +# +# tests/fm-treehouse-pool-sweep.test.sh covers the sweep script's own verdicts. +# This suite covers the other half of the mitigation: that fm-spawn.sh actually +# consults the sweep on the acquisition path, refuses the spawn when the sweep +# refuses, and ships DEACTIVATED so an unconfigured home spawns exactly as before. +# +# It drives the real spawn path with a fake terminal against a synthetic scratch +# pool under mktemp -d; the real ~/.treehouse pool and `treehouse get` are never +# touched. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +SPAWN="$ROOT/bin/fm-spawn.sh" +TMP_ROOT=$(fm_test_tmproot fm-spawn-pool-sweep-wiring) + +# An ambient config/home override would outrank the per-case values below and +# make the sweep read a config dir no fixture ever wrote. +unset FM_CONFIG_OVERRIDE +unset FM_ROOT_OVERRIDE + +make_spawn_fakebin() { + local dir=$1 fakebin + fakebin=$(fm_fakebin "$dir") + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +set -u +case "$*" in + *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:?FM_FAKE_PANE_PATH unset}"; exit 0 ;; +esac +case "${1:-}" in + display-message) printf 'firstmate\n'; exit 0 ;; + list-windows|has-session|new-session|new-window|kill-window|send-keys) exit 0 ;; +esac +exit 0 +SH + chmod +x "$fakebin/tmux" + fm_fake_exit0 "$fakebin" treehouse + printf '%s\n' "$fakebin" +} + +# Builds a project + origin + one pooled worktree, exactly as the pool-base +# freshen suite does, then leaves the pool CLEAN but parked on a commit that no +# branch, tag or rescue ref reaches - the shape the sweep classifies unsafe(2). +# Clean-but-unreachable is deliberate: a dirty pool is already refused later by +# the base-freshen check, so it could not tell us whether the sweep ran at all. +make_case() { + local name=$1 id=$2 case_dir home project origin pool fakebin initial orphan + case_dir="$TMP_ROOT/$name" + home="$case_dir/home" + project="$case_dir/project" + origin="$case_dir/origin.git" + pool="$case_dir/pool" + fakebin=$(make_spawn_fakebin "$case_dir/fake") + + mkdir -p "$home/data/$id" "$home/projects" "$home/state" "$home/config" + printf 'codex\n' > "$home/config/crew-harness" + printf 'brief for %s\n' "$id" > "$home/data/$id/brief.md" + touch "$home/state/.last-watcher-beat" + + git init --quiet -b main "$project" + printf 'base\n' > "$project/README.md" + git -C "$project" add README.md + git -C "$project" -c user.name='Firstmate Tests' -c user.email='tests@example.invalid' commit -qm initial + git clone --quiet --bare "$project" "$origin" + git -C "$project" remote add origin "file://$origin" + initial=$(git -C "$project" rev-parse HEAD) + git -C "$project" worktree add --quiet --detach "$pool" "$initial" + + # Abandoned work left behind in the pool: committed, so the worktree is clean, + # but reachable from nothing durable once the detached HEAD moves on. + printf 'abandoned lane work\n' > "$pool/abandoned.txt" + git -C "$pool" add abandoned.txt + git -C "$pool" -c user.name='Firstmate Tests' -c user.email='tests@example.invalid' commit -qm 'abandoned work' + orphan=$(git -C "$pool" rev-parse HEAD) + + printf '%s\n' "$home|$project|$pool|$fakebin|$orphan" +} + +read_case_record() { + IFS='|' read -r HOME_DIR PROJECT_DIR POOL_DIR FAKEBIN_DIR ORPHAN_SHA <&1 +} + +test_shipped_deactivated() { + local rec id out status + id='sweep-wiring-default-r1' + rec=$(make_case shipped-default "$id") + read_case_record "$rec" + [ ! -e "$HOME_DIR/config/worktree-pool-sweep" ] \ + || fail "fixture pre-enabled the sweep; the shipped default is no config file" + + out=$(run_spawn "$id" --mode no-mistakes --yolo off) + status=$? + expect_code 0 "$status" "an unconfigured home must spawn exactly as before the sweep existed" + assert_contains "$out" "spawned $id" "spawn did not report success with the sweep deactivated" + case "$out" in + *"pool sweep refused"*) fail "the sweep refused a spawn in a home that never enabled it" ;; + esac + [ "$(git -C "$POOL_DIR" rev-parse HEAD)" != "$ORPHAN_SHA" ] \ + || fail "fixture did not prove the spawn actually proceeded past the sweep" + if [ "${FM_TEST_EVIDENCE:-0}" = 1 ]; then + printf '# deactivated default: %s\n' "$(printf '%s\n' "$out" | tail -n 1)" + fi + pass "the sweep ships deactivated: an unconfigured home spawns on an unsafe pool" +} + +test_enabled_sweep_refuses_the_spawn() { + local rec id out status retention + id='sweep-wiring-enabled-r2' + rec=$(make_case enabled-refusal "$id") + read_case_record "$rec" + printf 'on\n' > "$HOME_DIR/config/worktree-pool-sweep" + + out=$(run_spawn "$id" --mode no-mistakes --yolo off) + status=$? + [ "$status" -ne 0 ] || fail "spawn succeeded despite the sweep refusing the pooled worktree" + assert_contains "$out" "pool sweep refused worktree" \ + "spawn did not surface the sweep's refusal to the operator" + assert_contains "$out" "(exit 2)" \ + "spawn did not report the sweep's unreachable-HEAD verdict" + assert_contains "$out" "config/worktree-pool-sweep" \ + "the refusal did not tell the operator where to disable the sweep" + # Returning the slot here would discard the very work the sweep refused over, + # so the hold is deliberate. The operator has to be told, or an acquired slot + # sitting on unsafe state reads as a leak. + retention=$(printf '%s\n' "$out" | grep 'stays held' | tail -n 1) + [ -n "$retention" ] \ + || fail "the refusal did not tell the operator the pool slot is deliberately retained" + assert_contains "$retention" "$POOL_DIR" \ + "the retention notice did not name the held worktree" + assert_contains "$retention" "window" \ + "the retention notice did not point at the task window left open for inspection" + [ "$(git -C "$POOL_DIR" rev-parse HEAD)" = "$ORPHAN_SHA" ] \ + || fail "spawn moved the pooled worktree off the unreachable work it refused" + assert_grep 'abandoned lane work' "$POOL_DIR/abandoned.txt" \ + "spawn discarded the abandoned pool work while refusing it" + if [ "${FM_TEST_EVIDENCE:-0}" = 1 ]; then + printf '# enabled refusal: %s\n' "$(printf '%s\n' "$out" | grep 'pool sweep refused' | tail -n 1)" + fi + pass "an enabled sweep refuses the spawn before it can reuse an unsafe pooled worktree" +} + +test_off_value_keeps_the_spawn_path_open() { + local rec id out status + id='sweep-wiring-off-r3' + rec=$(make_case explicit-off "$id") + read_case_record "$rec" + printf 'off\n' > "$HOME_DIR/config/worktree-pool-sweep" + + out=$(run_spawn "$id" --mode no-mistakes --yolo off) + status=$? + expect_code 0 "$status" "an explicit off must leave the acquisition path unchanged" + case "$out" in + *"pool sweep refused"*) fail "the sweep refused a spawn in a home that set it off" ;; + esac + pass "an explicit off leaves the spawn acquisition path unchanged" +} + +test_shipped_deactivated +test_enabled_sweep_refuses_the_spawn +test_off_value_keeps_the_spawn_path_open + +echo "# all fm-spawn-pool-sweep-wiring tests passed" diff --git a/tests/fm-treehouse-pool-sweep.test.sh b/tests/fm-treehouse-pool-sweep.test.sh new file mode 100755 index 0000000000..dc96013897 --- /dev/null +++ b/tests/fm-treehouse-pool-sweep.test.sh @@ -0,0 +1,632 @@ +#!/usr/bin/env bash +# tests/fm-treehouse-pool-sweep.test.sh - Test suite for worktree pool sweep. +# +# Tests the pre-acquire worktree pool safety sweep. Runs against synthetic scratch +# pools under mktemp -d, never touching the real ~/.treehouse pool or invoking +# treehouse get. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +SWEEP="$ROOT/bin/fm-treehouse-pool-sweep.sh" + +# The sweep resolves its config dir as FM_CONFIG_OVERRIDE, else $FM_HOME/config. +# An ambient export of either would outrank the per-test FM_HOME below and make +# these assertions read a config dir the fixture never wrote. +unset FM_CONFIG_OVERRIDE +unset FM_ROOT_OVERRIDE + +fm_create_bare_repo() { + local path=$1 + mkdir -p "$path" + git init --bare "$path" 2>/dev/null +} + +fm_create_test_repo() { + local path=$1 + mkdir -p "$path" + git init "$path" 2>/dev/null + git -C "$path" symbolic-ref HEAD refs/heads/main + git -C "$path" config user.email "test@example.com" + git -C "$path" config user.name "Test" +} + +fm_config_sweep_on() { + local config_dir=$1 + mkdir -p "$config_dir" + echo "on" > "$config_dir/worktree-pool-sweep" +} + +fm_config_sweep_off() { + local config_dir=$1 + mkdir -p "$config_dir" + echo "off" > "$config_dir/worktree-pool-sweep" +} + +# The fixture must be one the sweep REFUSES when enabled, otherwise "exit 0" +# proves only that the worktree was safe, not that the sweep stayed deactivated. +fm_create_unsafe_dirty_repo() { + local path=$1 + fm_create_test_repo "$path" + ( cd "$path" || exit 1 + touch file.txt + git add file.txt + git commit -m "initial" 2>/dev/null + echo "modified" > file.txt ) +} + +test_disabled_by_default() { + local tmp config_dir rc + tmp=$(fm_test_tmproot sweep-disabled) + config_dir="$tmp/config" + mkdir -p "$config_dir" + [ ! -e "$config_dir/worktree-pool-sweep" ] \ + || fail "disabled by default: fixture must not create the config file" + + fm_create_unsafe_dirty_repo "$tmp/repo" + + FM_HOME="$tmp" "$SWEEP" "$tmp/repo" >/dev/null 2>&1 + rc=$? + [ "$rc" -eq 0 ] || fail "disabled by default: expected exit 0 on an unsafe worktree, got $rc" + + fm_config_sweep_on "$config_dir" + FM_HOME="$tmp" "$SWEEP" "$tmp/repo" >/dev/null 2>&1 + rc=$? + [ "$rc" -eq 1 ] || fail "disabled by default: fixture is not actually unsafe (enabled sweep gave $rc)" + pass "sweep is disabled by default even for an unsafe worktree" +} + +# The sweep runs as a child of fm-spawn.sh, which exports no FM_ROOT. Resolution +# must follow the repo-wide FM_CONFIG_OVERRIDE / $FM_HOME/config convention, so +# these fixtures poison both the old knob and $HOME to prove neither is consulted. +test_fm_home_config_activates_sweep() { + local tmp config_dir rc + tmp=$(fm_test_tmproot sweep-fm-home) + config_dir="$tmp/config" + fm_config_sweep_on "$config_dir" + + fm_create_unsafe_dirty_repo "$tmp/repo" + + HOME="$tmp/decoy-home" FM_ROOT="$tmp/decoy-root" FM_HOME="$tmp" \ + "$SWEEP" "$tmp/repo" >/dev/null 2>&1 + rc=$? + [ "$rc" -eq 1 ] || fail "FM_HOME config: expected exit 1, got $rc" + pass "an enable under \$FM_HOME/config activates the sweep" +} + +test_config_override_activates_sweep() { + local tmp config_dir rc + tmp=$(fm_test_tmproot sweep-config-override) + config_dir="$tmp/elsewhere" + fm_config_sweep_on "$config_dir" + + fm_create_unsafe_dirty_repo "$tmp/repo" + + HOME="$tmp/decoy-home" FM_ROOT="$tmp/decoy-root" FM_HOME="$tmp" \ + FM_CONFIG_OVERRIDE="$config_dir" "$SWEEP" "$tmp/repo" >/dev/null 2>&1 + rc=$? + [ "$rc" -eq 1 ] || fail "FM_CONFIG_OVERRIDE: expected exit 1, got $rc" + + HOME="$tmp/decoy-home" FM_HOME="$tmp" "$SWEEP" "$tmp/repo" >/dev/null 2>&1 + rc=$? + [ "$rc" -eq 0 ] || fail "FM_CONFIG_OVERRIDE: enable leaked outside the override dir (got $rc)" + pass "FM_CONFIG_OVERRIDE selects the config dir the sweep reads" +} + +test_off_value_disables_sweep() { + local tmp config_dir rc + tmp=$(fm_test_tmproot sweep-off-value) + config_dir="$tmp/config" + fm_config_sweep_off "$config_dir" + + fm_create_unsafe_dirty_repo "$tmp/repo" + + FM_HOME="$tmp" "$SWEEP" "$tmp/repo" >/dev/null 2>&1 + rc=$? + [ "$rc" -eq 0 ] || fail "off value: expected exit 0 on an unsafe worktree, got $rc" + pass "config value 'off' disables the sweep" +} + +test_refuses_dirty_worktree() { + local tmp config_dir + tmp=$(fm_test_tmproot sweep-dirty) + config_dir="$tmp/config" + fm_config_sweep_on "$config_dir" + + fm_create_test_repo "$tmp/repo" + cd "$tmp/repo" || exit 1 + touch file.txt + git add file.txt + git commit -m "initial" 2>/dev/null + echo "modified" > file.txt + + err=$(FM_HOME="$tmp" "$SWEEP" "$tmp/repo" 2>&1 >/dev/null) + rc=$? + [ "$rc" -eq 1 ] || fail "dirty worktree: expected exit 1, got $rc" + assert_contains "$err" "unsafe: dirty worktree" "dirty worktree: missing diagnostic on stderr" + pass "refuses dirty worktree with a diagnostic" +} + +test_refuses_staged_changes() { + local tmp config_dir + tmp=$(fm_test_tmproot sweep-staged) + config_dir="$tmp/config" + fm_config_sweep_on "$config_dir" + + fm_create_test_repo "$tmp/repo" + cd "$tmp/repo" || exit 1 + touch file.txt + git add file.txt + git commit -m "initial" 2>/dev/null + echo "staged" > staged.txt + git add staged.txt + + FM_HOME="$tmp" "$SWEEP" "$tmp/repo" >/dev/null 2>&1 + rc=$? + [ "$rc" -eq 1 ] || fail "staged changes: expected exit 1, got $rc" + pass "refuses staged changes against a real HEAD" +} + +test_refuses_staged_change_restored_in_worktree() { + local tmp config_dir rc + tmp=$(fm_test_tmproot sweep-staged-restored) + config_dir="$tmp/config" + fm_config_sweep_on "$config_dir" + + fm_create_test_repo "$tmp/repo" + cd "$tmp/repo" || exit 1 + printf 'original\n' > file.txt + git add file.txt + git commit -m "initial" 2>/dev/null + printf 'staged\n' > file.txt + git add file.txt + printf 'original\n' > file.txt + git update-index --refresh >/dev/null 2>&1 + [ -n "$(git diff --cached --name-only)" ] \ + || fail "staged-then-restored: fixture left no staged delta" + + FM_HOME="$tmp" "$SWEEP" "$tmp/repo" >/dev/null 2>&1 + rc=$? + [ "$rc" -eq 1 ] || fail "staged-then-restored: expected exit 1, got $rc" + pass "refuses an index that differs from HEAD even when the worktree matches HEAD" +} + +# A pool worktree returned by teardown has fresh mtimes, so its index stat data +# is stale while its content still matches HEAD. That is clean, not dirty, and +# must agree with the `git status --porcelain` gate fm-spawn.sh applies later. +test_allows_stat_dirty_but_clean_worktree() { + local tmp config_dir rc + tmp=$(fm_test_tmproot sweep-stat-dirty) + config_dir="$tmp/config" + fm_config_sweep_on "$config_dir" + + fm_create_test_repo "$tmp/repo" + cd "$tmp/repo" || exit 1 + printf 'content\n' > file.txt + git add file.txt + git commit -m "initial" 2>/dev/null + # A tag, so HEAD is durably reachable independently of the branch it is + # attached to (which the sweep discounts). Without it the case would exit on + # the reachability verdict and prove nothing about the dirty probe. + git tag pool-base 2>/dev/null + touch -t 203001010101 file.txt + [ -z "$(git status --porcelain)" ] \ + || fail "stat-dirty: fixture is genuinely dirty, not merely stat-dirty" + + FM_HOME="$tmp" "$SWEEP" "$tmp/repo" >/dev/null 2>&1 + rc=$? + [ "$rc" -eq 0 ] || fail "stat-dirty: expected exit 0 for a stat-only mtime change, got $rc" + pass "allows a stat-dirty worktree whose content matches HEAD" +} + +# A HEAD git cannot resolve (unborn, or a worktree whose repo is unreadable) +# makes the dirty probe exit 128. That must still refuse, but the operator is +# told the real condition instead of being sent looking for uncommitted work. +test_refuses_uninspectable_head_with_its_own_diagnostic() { + local tmp config_dir rc err + tmp=$(fm_test_tmproot sweep-unborn-head) + config_dir="$tmp/config" + fm_config_sweep_on "$config_dir" + + fm_create_test_repo "$tmp/repo" + cd "$tmp/repo" || exit 1 + git rev-parse --verify --quiet HEAD >/dev/null 2>&1 \ + && fail "unborn HEAD: fixture already has a commit" + + err=$(FM_HOME="$tmp" "$SWEEP" "$tmp/repo" 2>&1 >/dev/null) + rc=$? + [ "$rc" -eq 1 ] || fail "unborn HEAD: expected exit 1, got $rc" + assert_contains "$err" "cannot inspect HEAD" \ + "unborn HEAD: missing the cannot-inspect diagnostic" + case "$err" in + *"dirty worktree"*) fail "unborn HEAD: reported as dirty, which it is not" ;; + esac + pass "refuses an uninspectable HEAD with its own diagnostic, not 'dirty'" +} + +test_refuses_untracked_files() { + local tmp config_dir + tmp=$(fm_test_tmproot sweep-untracked) + config_dir="$tmp/config" + fm_config_sweep_on "$config_dir" + + fm_create_test_repo "$tmp/repo" + cd "$tmp/repo" || exit 1 + touch file.txt + git add file.txt + git commit -m "initial" 2>/dev/null + touch untracked.txt + + FM_HOME="$tmp" "$SWEEP" "$tmp/repo" >/dev/null 2>&1 + rc=$? + [ "$rc" -eq 1 ] || fail "untracked files: expected exit 1, got $rc" + pass "refuses untracked files" +} + +test_allows_branch_reachable_head() { + local tmp config_dir + tmp=$(fm_test_tmproot sweep-branch-reachable) + config_dir="$tmp/config" + fm_config_sweep_on "$config_dir" + + fm_create_test_repo "$tmp/repo" + cd "$tmp/repo" || exit 1 + touch file.txt + git add file.txt + git commit -m "initial" 2>/dev/null + git checkout -b feature 2>/dev/null + touch feature.txt + git add feature.txt + git commit -m "feature work" 2>/dev/null + git checkout main 2>/dev/null + + FM_HOME="$tmp" "$SWEEP" "$tmp/repo" >/dev/null 2>&1 + rc=$? + [ "$rc" -eq 0 ] || fail "branch reachable: expected exit 0, got $rc" + pass "allows branch-reachable HEAD" +} + +test_allows_tag_reachable_head() { + local tmp config_dir + tmp=$(fm_test_tmproot sweep-tag-reachable) + config_dir="$tmp/config" + fm_config_sweep_on "$config_dir" + + fm_create_test_repo "$tmp/repo" + cd "$tmp/repo" || exit 1 + touch file.txt + git add file.txt + git commit -m "initial" 2>/dev/null + git tag v1.0.0 2>/dev/null + + FM_HOME="$tmp" "$SWEEP" "$tmp/repo" >/dev/null 2>&1 + rc=$? + [ "$rc" -eq 0 ] || fail "tag reachable: expected exit 0, got $rc" + pass "allows tag-reachable HEAD" +} + +test_allows_detached_head_fully_in_main() { + local tmp config_dir + tmp=$(fm_test_tmproot sweep-detached-main) + config_dir="$tmp/config" + fm_config_sweep_on "$config_dir" + + fm_create_test_repo "$tmp/repo" + cd "$tmp/repo" || exit 1 + touch file.txt + git add file.txt + git commit -m "initial" 2>/dev/null + main_commit=$(git rev-parse HEAD) + git checkout "$main_commit" 2>/dev/null + + FM_HOME="$tmp" "$SWEEP" "$tmp/repo" >/dev/null 2>&1 + rc=$? + [ "$rc" -eq 0 ] || fail "detached in main: expected exit 0, got $rc" + pass "allows detached HEAD fully contained in main" +} + +# The pool worktree is still sitting on the lane branch that carries its only +# copy of some committed work. That branch is not protection: the spawn path +# hard-resets it onto origin's default branch moments later, so counting it +# would green-light discarding the work. +test_refuses_head_held_only_by_the_checked_out_branch() { + local tmp config_dir rc err lane_commit + tmp=$(fm_test_tmproot sweep-attached-branch) + config_dir="$tmp/config" + fm_config_sweep_on "$config_dir" + + fm_create_bare_repo "$tmp/origin" + fm_create_test_repo "$tmp/repo" + cd "$tmp/repo" || exit 1 + git remote add origin "$tmp/origin" + touch file.txt + git add file.txt + git commit -m "initial" 2>/dev/null + git push -u origin main 2>/dev/null + git checkout -b fm/lane 2>/dev/null + printf 'unlanded\n' > lane.txt + git add lane.txt + git commit -m "lane work" 2>/dev/null + lane_commit=$(git rev-parse HEAD) + [ "$(git rev-parse --abbrev-ref HEAD)" = "fm/lane" ] \ + || fail "attached branch: fixture is not checked out on the lane branch" + [ -n "$lane_commit" ] || fail "attached branch: fixture has no lane commit" + + err=$(FM_HOME="$tmp" "$SWEEP" "$tmp/repo" 2>&1 >/dev/null) + rc=$? + [ "$rc" -eq 2 ] || fail "attached branch: expected exit 2, got $rc" + assert_contains "$err" "not reachable from durable refs" \ + "attached branch: missing the unreachable-HEAD diagnostic" + pass "refuses HEAD whose only ref is the branch about to be hard-reset" +} + +test_refuses_reflog_only_reachability() { + local tmp config_dir + tmp=$(fm_test_tmproot sweep-reflog-only) + config_dir="$tmp/config" + fm_config_sweep_on "$config_dir" + + fm_create_test_repo "$tmp/repo" + cd "$tmp/repo" || exit 1 + touch file.txt + git add file.txt + git commit -m "initial" 2>/dev/null + git checkout -b feature 2>/dev/null + touch feature.txt + git add feature.txt + git commit -m "feature work" 2>/dev/null + feature_commit=$(git rev-parse HEAD) + git checkout "$feature_commit" 2>/dev/null + git branch -D feature 2>/dev/null || true + + err=$(FM_HOME="$tmp" "$SWEEP" "$tmp/repo" 2>&1 >/dev/null) + rc=$? + [ "$rc" -eq 2 ] || fail "reflog only: expected exit 2, got $rc" + assert_contains "$err" "not reachable from durable refs" \ + "reflog only: missing diagnostic on stderr" + pass "refuses reflog-only reachability with a diagnostic" +} + +test_historical_reproduction() { + local tmp config_dir + tmp=$(fm_test_tmproot sweep-historical) + config_dir="$tmp/config" + fm_config_sweep_on "$config_dir" + + fm_create_test_repo "$tmp/repo" + cd "$tmp/repo" || exit 1 + touch file.txt + git add file.txt + git commit -m "initial" 2>/dev/null + + git checkout -b fm/lane-branch 2>/dev/null + touch lane-work.txt + git add lane-work.txt + git commit -m "lane work" 2>/dev/null + + git rebase main 2>/dev/null || true + + touch rebased-work.txt + git add rebased-work.txt + git commit -m "work after rebase" 2>/dev/null + rebased_commit=$(git rev-parse HEAD) + + git checkout "$rebased_commit" 2>/dev/null + git update-ref -d refs/heads/fm/lane-branch 2>/dev/null || true + + FM_HOME="$tmp" "$SWEEP" "$tmp/repo" >/dev/null 2>&1 + rc=$? + [ "$rc" -eq 2 ] || fail "historical reproduction: expected exit 2, got $rc" + pass "historical reproduction: refuses orphaned commits after branch deletion" +} + +test_refuses_remote_only_refs() { + local tmp config_dir + tmp=$(fm_test_tmproot sweep-remote-only) + config_dir="$tmp/config" + fm_config_sweep_on "$config_dir" + + fm_create_bare_repo "$tmp/origin" + fm_create_test_repo "$tmp/repo" + cd "$tmp/repo" || exit 1 + git remote add origin "$tmp/origin" + touch file.txt + git add file.txt + git commit -m "initial" 2>/dev/null + git push -u origin main 2>/dev/null + origin_main=$(git rev-parse origin/main) + git checkout "$origin_main" 2>/dev/null + git branch -D main 2>/dev/null || true + git remote prune origin 2>/dev/null + + err=$(FM_HOME="$tmp" "$SWEEP" "$tmp/repo" 2>&1 >/dev/null) + rc=$? + [ "$rc" -eq 3 ] || fail "remote only: expected exit 3, got $rc" + assert_contains "$err" "covered only by remote-tracking refs" \ + "remote only: missing diagnostic on stderr" + pass "refuses HEAD covered only by remote-tracking refs with a diagnostic" +} + +# Exit 3 claims the commits survive on a remote-tracking ref. When the only refs +# are remote but HEAD is not contained in any of them, the honest answer is 2. +# With no refs at all, exit 3 must not be claimed: there is no remote-tracking +# ref the commits could be recovered from. +test_refuses_head_when_no_refs_exist_at_all() { + local tmp config_dir rc err + tmp=$(fm_test_tmproot sweep-no-refs) + config_dir="$tmp/config" + fm_config_sweep_on "$config_dir" + + fm_create_test_repo "$tmp/repo" + cd "$tmp/repo" || exit 1 + touch file.txt + git add file.txt + git commit -m "initial" 2>/dev/null + head_commit=$(git rev-parse HEAD) + git checkout "$head_commit" 2>/dev/null + git branch -D main 2>/dev/null + [ -z "$(git for-each-ref --format='%(refname)')" ] \ + || fail "no refs: fixture still has refs" + + err=$(FM_HOME="$tmp" "$SWEEP" "$tmp/repo" 2>&1 >/dev/null) + rc=$? + [ "$rc" -eq 2 ] || fail "no refs: expected exit 2, got $rc" + assert_contains "$err" "not reachable from durable refs" \ + "no refs: must not claim remote-tracking coverage" + pass "refuses HEAD as unreachable when no refs exist at all" +} + +test_refuses_orphan_head_with_unrelated_remote_ref() { + local tmp config_dir rc err + tmp=$(fm_test_tmproot sweep-orphan-vs-remote) + config_dir="$tmp/config" + fm_config_sweep_on "$config_dir" + + fm_create_bare_repo "$tmp/origin" + fm_create_test_repo "$tmp/repo" + cd "$tmp/repo" || exit 1 + git remote add origin "$tmp/origin" + touch file.txt + git add file.txt + git commit -m "initial" 2>/dev/null + git push -u origin main 2>/dev/null + git checkout -b orphan 2>/dev/null + touch never-pushed.txt + git add never-pushed.txt + git commit -m "never pushed" 2>/dev/null + orphan_commit=$(git rev-parse HEAD) + git checkout "$orphan_commit" 2>/dev/null + git branch -D orphan 2>/dev/null + git branch -D main 2>/dev/null + [ "$(git rev-list --count HEAD --not --remotes)" -gt 0 ] \ + || fail "orphan vs remote: fixture HEAD is contained in a remote ref" + + err=$(FM_HOME="$tmp" "$SWEEP" "$tmp/repo" 2>&1 >/dev/null) + rc=$? + [ "$rc" -eq 2 ] || fail "orphan vs remote: expected exit 2, got $rc" + assert_contains "$err" "not reachable from durable refs" \ + "orphan vs remote: must not claim remote-tracking coverage it never verified" + pass "refuses an orphan HEAD as unreachable, not as remote-covered" +} + +test_allows_with_local_branch() { + local tmp config_dir + tmp=$(fm_test_tmproot sweep-local-with-remote) + config_dir="$tmp/config" + fm_config_sweep_on "$config_dir" + + fm_create_bare_repo "$tmp/origin" + fm_create_test_repo "$tmp/repo" + cd "$tmp/repo" || exit 1 + git remote add origin "$tmp/origin" + touch file.txt + git add file.txt + git commit -m "initial" 2>/dev/null + git push -u origin main 2>/dev/null + git checkout -b feature 2>/dev/null + touch feature.txt + git add feature.txt + git commit -m "feature" 2>/dev/null + git push -u origin feature 2>/dev/null + git checkout main 2>/dev/null + git branch -D feature 2>/dev/null + + FM_HOME="$tmp" "$SWEEP" "$tmp/repo" >/dev/null 2>&1 + rc=$? + [ "$rc" -eq 0 ] || fail "local with remote: expected exit 0, got $rc" + pass "allows HEAD when local branch exists alongside remote" +} + +# A durable ref pointing at an object the shared store no longer has makes +# rev-list exit 128. The sweep must treat an unanswerable reachability question +# as unsafe rather than green-lighting the worktree. +test_refuses_when_reachability_cannot_be_computed() { + local tmp config_dir rc git_dir err + tmp=$(fm_test_tmproot sweep-broken-ref) + config_dir="$tmp/config" + fm_config_sweep_on "$config_dir" + + fm_create_test_repo "$tmp/repo" + cd "$tmp/repo" || exit 1 + touch file.txt + git add file.txt + git commit -m "initial" 2>/dev/null + git_dir=$(git rev-parse --git-dir) + mkdir -p "$git_dir/refs/heads" + printf '%s\n' "0000000000000000000000000000000000000001" \ + > "$git_dir/refs/heads/dangling" + git rev-list --count HEAD --not --branches >/dev/null 2>&1 \ + && fail "broken ref: fixture did not actually break rev-list" + + err=$(FM_HOME="$tmp" "$SWEEP" "$tmp/repo" 2>&1 >/dev/null) + rc=$? + [ "$rc" -eq 2 ] || fail "broken ref: expected exit 2, got $rc" + assert_contains "$err" "cannot compute HEAD reachability" \ + "broken ref: missing the cannot-compute diagnostic" + case "$err" in + *"contains commits not reachable"*) + fail "broken ref: reported as orphaned commits, but the question was unanswerable" ;; + esac + pass "refuses an uncomputable reachability question with its own diagnostic" +} + +test_nonexistent_worktree() { + local tmp config_dir + tmp=$(fm_test_tmproot sweep-nonexistent) + config_dir="$tmp/config" + fm_config_sweep_on "$config_dir" + + FM_HOME="$tmp" "$SWEEP" "$tmp/nonexistent" >/dev/null 2>&1 + rc=$? + [ "$rc" -eq 4 ] || fail "nonexistent: expected exit 4, got $rc" + pass "exits 4 for nonexistent worktree" +} + +test_missing_argument_is_a_usage_error() { + local tmp config_dir rc + tmp=$(fm_test_tmproot sweep-usage) + config_dir="$tmp/config" + fm_config_sweep_on "$config_dir" + + FM_HOME="$tmp" "$SWEEP" >/dev/null 2>&1 + rc=$? + [ "$rc" -eq 64 ] || fail "missing argument: expected exit 64, got $rc" + [ "$rc" -ne 4 ] || fail "missing argument must not collide with the nonexistent-worktree code" + pass "exits 64 for a missing worktree argument" +} + +test_help_text() { + local out + out=$("$SWEEP" --help 2>&1) || true + assert_contains "$out" "Pre-acquire worktree pool safety sweep" "help missing description" + assert_contains "$out" "MITIGATION" "help missing MITIGATION note" + assert_contains "$out" "INVARIANT" "help missing INVARIANT note" + assert_contains "$out" "config/worktree-pool-sweep" "help missing config location" + pass "help text contains required information" +} + +test_disabled_by_default +test_off_value_disables_sweep +test_fm_home_config_activates_sweep +test_config_override_activates_sweep +test_refuses_dirty_worktree +test_refuses_staged_changes +test_refuses_staged_change_restored_in_worktree +test_allows_stat_dirty_but_clean_worktree +test_refuses_uninspectable_head_with_its_own_diagnostic +test_refuses_untracked_files +test_allows_branch_reachable_head +test_allows_tag_reachable_head +test_allows_detached_head_fully_in_main +test_refuses_head_held_only_by_the_checked_out_branch +test_refuses_reflog_only_reachability +test_historical_reproduction +test_refuses_remote_only_refs +test_refuses_head_when_no_refs_exist_at_all +test_refuses_orphan_head_with_unrelated_remote_ref +test_allows_with_local_branch +test_refuses_when_reachability_cannot_be_computed +test_nonexistent_worktree +test_missing_argument_is_a_usage_error +test_help_text diff --git a/tests/no-mistakes-required-workflow.test.sh b/tests/no-mistakes-required-workflow.test.sh new file mode 100755 index 0000000000..d17b46c0d8 --- /dev/null +++ b/tests/no-mistakes-required-workflow.test.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +# Behavior of the "PR must be raised via no-mistakes" compliance gate. +# +# The contract under test is the executable step body of +# .github/workflows/no-mistakes-required.yml: given a pull_request event payload +# body (PR_BODY) and the PR's live body, it must decide whether the PR carries +# the no-mistakes signature plus a parseable v1 pipeline attestation whose +# review/test/document steps are completed. +# +# The workflow is parsed into its YAML object and the real step script is run as +# the runner runs it, with gh/sleep shimmed; no assertion looks at the workflow +# text itself. +# +# Regression origin: PR #2827. no-mistakes pushes the branch (and opens the PR) +# before it writes the pipeline body, so the opened/synchronize payload carried a +# pre-attestation body and this gate failed against a body that was already +# replaced seconds later. Runs 32619561064 (opened) and 32622077051 (synchronize) +# both failed that way while the following `edited` run passed on the same head. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +command -v jq >/dev/null 2>&1 || { echo "skip: jq not found (the gate parses attestation with jq)"; exit 0; } +command -v python3 >/dev/null 2>&1 || { echo "skip: python3 not found (needed to parse the workflow YAML)"; exit 0; } +python3 -c 'import yaml' >/dev/null 2>&1 || { echo "skip: python3 yaml module not found (needed to parse the workflow YAML)"; exit 0; } + +TMP_ROOT=$(fm_test_tmproot no-mistakes-required) +FAKEBIN=$(fm_fakebin "$TMP_ROOT") + +WORKFLOW="$ROOT/.github/workflows/no-mistakes-required.yml" +STEP="$TMP_ROOT/step.sh" + +# Extract the step's `run:` script from the parsed workflow, keeping the job's +# `if` guard and expression-bearing `env:` out of it: those are GitHub's to +# evaluate, the script body is the runner's. +python3 - "$WORKFLOW" "$STEP" <<'PY' || fail "could not extract the compliance step from the workflow" +import sys + +import yaml + +workflow = yaml.safe_load(open(sys.argv[1])) +steps = workflow["jobs"]["check"]["steps"] +run = [s for s in steps if "run" in s and "no-mistakes signature" in s.get("name", "")] +assert len(run) == 1, run +open(sys.argv[2], "w").write(run[0]["run"]) +PY + +# The live body the shimmed `gh api` answers with, and a counter proving whether +# the gate went back to the API at all. +LIVE_BODY="$TMP_ROOT/live-body.txt" +GH_CALLS="$TMP_ROOT/gh-calls" +: > "$GH_CALLS" + +cat > "$FAKEBIN/gh" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$GH_CALLS" +if [ -n "${FAKE_GH_FAIL:-}" ]; then + echo "gh: simulated API failure" >&2 + exit 1 +fi +cat "$LIVE_BODY" +SH +chmod +x "$FAKEBIN/gh" + +# Keep the retry loop's backoff out of the suite's wall clock. +cat > "$FAKEBIN/sleep" <<'SH' +#!/usr/bin/env bash +exit 0 +SH +chmod +x "$FAKEBIN/sleep" + +MARKER='Updates from [git push no-mistakes](https://github.com/kunchenguid/no-mistakes)' + +# body_with +body_with() { + printf '## Pipeline\n\n%s\n\n\n' \ + "$MARKER" "$1" "$2" "$3" +} + +COMPLIANT=$(body_with completed completed completed) +SIGNED_NO_ATTESTATION="## Pipeline + +$MARKER +" + +# run_gate : run the step in the current shell, leaving +# its combined output in OUT and its exit code in RC. +OUT='' +RC=0 +run_gate() { + printf '%s' "$2" > "$LIVE_BODY" + : > "$GH_CALLS" + PATH="$FAKEBIN:$PATH" \ + LIVE_BODY="$LIVE_BODY" GH_CALLS="$GH_CALLS" FAKE_GH_FAIL="${FAKE_GH_FAIL:-}" \ + PR_BODY="$1" PR_AUTHOR=someone PR_NUMBER=2827 PR_REPO=kunchenguid/firstmate \ + bash "$STEP" >"$TMP_ROOT/out.txt" 2>&1 + RC=$? + OUT=$(cat "$TMP_ROOT/out.txt") +} + +gh_call_count() { + local count + count=$(grep -c . "$GH_CALLS" 2>/dev/null) || count=0 + printf '%s\n' "$count" +} + +# --- a compliant event payload passes without going back to the API --------- + +run_gate "$COMPLIANT" "$COMPLIANT" +expect_code 0 "$RC" "compliant payload body" +assert_contains "$OUT" "Pipeline step attestation is valid" "compliant payload body reports success" +[ "$(gh_call_count)" = 0 ] || fail "a compliant payload body must not re-read the live body" +pass "a compliant event payload passes without re-reading the PR" + +# --- the push-then-write race: payload body is stale, live body is compliant -- + +run_gate "" "$COMPLIANT" +expect_code 0 "$RC" "empty payload body with a compliant live body" +assert_contains "$OUT" "Pipeline step attestation is valid" "stale empty payload is re-read" +[ "$(gh_call_count)" -ge 1 ] || fail "a non-compliant payload body must be re-read from the API" +pass "an opened-event body written before the pipeline section passes once the live body lands" + +run_gate "$SIGNED_NO_ATTESTATION" "$COMPLIANT" +expect_code 0 "$RC" "signed-but-unattested payload with a compliant live body" +assert_contains "$OUT" "Pipeline step attestation is valid" "stale signed payload is re-read" +pass "a synchronize-event body that predates the attestation comment passes once the live body lands" + +# --- genuinely non-compliant PRs still fail --------------------------------- + +run_gate "no pipeline here" "no pipeline here" +expect_code 1 "$RC" "unsigned body" +assert_contains "$OUT" "This PR was not raised through no-mistakes." "unsigned body names the gate" +pass "a body that never gains the signature is refused" + +run_gate "$SIGNED_NO_ATTESTATION" "$SIGNED_NO_ATTESTATION" +expect_code 1 "$RC" "signed body with no attestation comment" +assert_contains "$OUT" "structured pipeline step attestation is missing or unparseable" \ + "missing attestation names the version floor" +pass "a signature without a parseable attestation is refused" + +SKIPPED=$(body_with skipped completed completed) +run_gate "$SKIPPED" "$SKIPPED" +expect_code 1 "$RC" "attestation with a skipped required step" +assert_contains "$OUT" "review=skipped" "skipped required step is named" +pass "an attestation whose required step only skipped is refused" + +# --- an unreadable API must not turn a refusal into a pass ------------------ + +FAKE_GH_FAIL=1 +run_gate "no pipeline here" "$COMPLIANT" +FAKE_GH_FAIL= +expect_code 1 "$RC" "unsigned body when the API re-read fails" +assert_contains "$OUT" "This PR was not raised through no-mistakes." "API failure falls back to the payload verdict" +pass "an unreadable API leaves the payload verdict standing rather than passing" + +echo "# all no-mistakes-required workflow tests passed"