From 2f8e25a9fb02af8214e8f3844c61507aa90374ea Mon Sep 17 00:00:00 2001 From: Sam Sherpa Date: Thu, 27 Aug 2026 09:28:41 -0700 Subject: [PATCH 01/12] fix(spawn): support remote-less local projects --- bin/fm-project-mode.sh | 3 +- bin/fm-spawn.sh | 104 ++++++++++++------ docs/architecture.md | 2 +- tests/fm-spawn-pool-base-freshen.test.sh | 128 ++++++++++++++++++++++- 4 files changed, 202 insertions(+), 35 deletions(-) diff --git a/bin/fm-project-mode.sh b/bin/fm-project-mode.sh index 3046202f23f..7baeacdfe6e 100755 --- a/bin/fm-project-mode.sh +++ b/bin/fm-project-mode.sh @@ -9,7 +9,8 @@ # bin/fm-brief.sh, bin/fm-spawn.sh, and bin/fm-promote.sh (AGENTS.md section 7). # The consumers are bin/fm-fleet-sync.sh (skip local-only clones), # bin/fm-home-seed.sh (refuse local-only seeding, run no-mistakes init), and -# bin/fm-spawn.sh's advisory registry-deviation notice. +# bin/fm-spawn.sh (registered-local-only fresh-base eligibility and the advisory +# registry-deviation notice). # # Registry line format (data/projects.md): # - - (added ) -> no-mistakes off (legacy default) diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 9158fce64df..d2e9256c107 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -134,10 +134,12 @@ # default-branch commit when safe; skipped syncs warn and launch unchanged. # Ship/scout spawns refuse to launch unless the resolved task path is a real # git worktree root distinct from the primary project checkout. -# Before a fresh ship or scout worker starts, its clean task worktree fetches -# origin, resolves the current remote default branch, and resets to its tip. -# An unreachable origin, unresolved default branch, or non-clean worktree -# refuses the spawn rather than risking a PR based on stale history. +# Before a fresh ship or scout worker starts, its clean task worktree refreshes +# from origin's current default branch. A registered local-only project's scout +# or local-only ship instead refreshes from the local default branch only when +# the repository has no configured remotes. Any configured remote keeps the +# origin freshness guard, and an unreachable origin, unresolved default branch, +# or non-clean worktree refuses the spawn rather than risking stale history. # A slot whose only deviation is a stale submodule gitlink is refused by that # same clean check, but is reported as a stale checkout naming each submodule # and both pins; nothing is converged or removed, and no remedy is suggested. @@ -1780,8 +1782,19 @@ delivery_rigor_rank() { # -> 3 (most rigor) .. 1 (least); 0 = not a task # fm-brief.sh records a ship brief's mode as a fixed "Delivery contract: mode=" # line. A spawn that disagrees would launch a worker whose instructions and whose # recorded task delivery differ, which is the exact drift this contract prevents. -if [ "$KIND" = ship ]; then +PROJECT_POSTURE= +ALLOW_REMOTELESS_BASE=no +if [ "$KIND" != secondmate ]; then PROJ_NAME=$(basename "$PROJ_ABS") + PROJECT_POSTURE=$("$FM_ROOT/bin/fm-project-mode.sh" --raw "$PROJ_NAME" 2>/dev/null | cut -d' ' -f1) || PROJECT_POSTURE= + if [ "$PROJECT_POSTURE" = local-only ]; then + case "$KIND:$MODE" in + scout:|ship:local-only) ALLOW_REMOTELESS_BASE=yes ;; + esac + fi +fi + +if [ "$KIND" = ship ]; then BRIEF_MODE=$(sed -n 's/^Delivery contract: mode=\([^ ]*\).*$/\1/p' "$BRIEF" | head -n 1) if [ -z "$BRIEF_MODE" ]; then echo "warning: $BRIEF records no delivery contract line (scaffolded before ship briefs recorded one); launching on the explicit --mode $MODE - confirm its definition of done matches" >&2 @@ -1794,10 +1807,9 @@ if [ "$KIND" = ship ]; then # unregistered project resolves to the same no-mistakes standing default, which # is why the notice names the standing posture rather than the registry line. A # conditional policy is excluded: both of its legs are legitimate classifications. - STANDING_MODE=$("$FM_ROOT/bin/fm-project-mode.sh" --raw "$PROJ_NAME" 2>/dev/null | cut -d' ' -f1) || STANDING_MODE= - if [ -n "$STANDING_MODE" ] && [ "$STANDING_MODE" != no-mistakes-prod-only ] \ - && [ "$(delivery_rigor_rank "$MODE")" -lt "$(delivery_rigor_rank "$STANDING_MODE")" ]; then - echo "notice: $ID ships mode=$MODE while the standing posture for $PROJ_NAME is $STANDING_MODE - less rigor than the captain's standing posture; proceed only on a current explicit captain instruction or an intake judgment you can state" >&2 + if [ -n "$PROJECT_POSTURE" ] && [ "$PROJECT_POSTURE" != no-mistakes-prod-only ] \ + && [ "$(delivery_rigor_rank "$MODE")" -lt "$(delivery_rigor_rank "$PROJECT_POSTURE")" ]; then + echo "notice: $ID ships mode=$MODE while the standing posture for $PROJ_NAME is $PROJECT_POSTURE - less rigor than the captain's standing posture; proceed only on a current explicit captain instruction or an intake judgment you can state" >&2 fi fi @@ -1892,29 +1904,59 @@ EOF printf '%s' "$lines" >&2 } -freshen_spawn_worktree_base() { # - local worktree=$1 default target expected actual status - if ! git -C "$worktree" fetch --quiet origin; then - echo "error: could not fetch origin for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 - return 1 - fi - if ! git -C "$worktree" remote set-head origin --auto >/dev/null 2>&1; then - echo "error: could not resolve origin's current default branch for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 - return 1 - fi - default=$(default_branch "$worktree") || { - echo "error: could not determine origin's default branch for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 +freshen_spawn_worktree_base() { # + local worktree=$1 project=$2 allow_remoteless=$3 default target reset_target expected actual status remotes + remotes=$(git -C "$worktree" remote 2>/dev/null) || { + echo "error: could not inspect configured remotes for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 return 1 } - target="origin/$default" - if ! git -C "$worktree" fetch --quiet origin "+refs/heads/$default:refs/remotes/origin/$default"; then - echo "error: could not fetch '$target' for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 - return 1 + if printf '%s\n' "$remotes" | grep -qx origin; then + if ! git -C "$worktree" fetch --quiet origin; then + echo "error: could not fetch origin for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 + return 1 + fi + if ! git -C "$worktree" remote set-head origin --auto >/dev/null 2>&1; then + echo "error: could not resolve origin's current default branch for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 + return 1 + fi + default=$(default_branch "$worktree") || { + echo "error: could not determine origin's default branch for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 + return 1 + } + target="origin/$default" + reset_target=$target + if ! git -C "$worktree" fetch --quiet origin "+refs/heads/$default:refs/remotes/origin/$default"; then + echo "error: could not fetch '$target' for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 + return 1 + fi + expected=$(git -C "$worktree" rev-parse --verify --quiet "$target^{commit}" 2>/dev/null) || { + echo "error: '$target' is not a commit for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 + return 1 + } + else + if [ -n "$remotes" ]; then + echo "error: pooled worktree '$worktree' has configured remotes but no origin remote; refusing to launch without the required origin freshness guard" >&2 + return 1 + fi + if [ "$allow_remoteless" != yes ]; then + echo "error: pooled worktree '$worktree' has no origin remote; only a registered local-only project's scout or local-only ship may launch without one" >&2 + return 1 + fi + default=$(default_branch "$project") || { + echo "error: could not determine the local default branch for remote-less project '$project'; refusing to launch from a potentially stale base" >&2 + return 1 + } + target="local $default" + expected=$(git -C "$project" rev-parse --verify --quiet "refs/heads/$default^{commit}" 2>/dev/null) || { + echo "error: local default branch '$default' is not a commit for remote-less project '$project'; refusing to launch from a potentially stale base" >&2 + return 1 + } + if ! git -C "$worktree" cat-file -e "$expected^{commit}" 2>/dev/null; then + echo "error: local default branch '$default' is unavailable in pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 + return 1 + fi + reset_target=$expected fi - expected=$(git -C "$worktree" rev-parse --verify --quiet "$target^{commit}" 2>/dev/null) || { - echo "error: '$target' is not a commit for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 - return 1 - } status=$(git -C "$worktree" -c core.quotePath=false status --porcelain) || { echo "error: could not inspect pooled worktree '$worktree' before refreshing its base" >&2 return 1 @@ -1927,7 +1969,7 @@ freshen_spawn_worktree_base() { # fi return 1 fi - if ! git -C "$worktree" reset --hard "$target" >/dev/null; then + if ! git -C "$worktree" reset --hard "$reset_target" >/dev/null; then echo "error: could not reset pooled worktree '$worktree' to '$target'; refusing to launch from a potentially stale base" >&2 return 1 fi @@ -2470,7 +2512,7 @@ elif [ "$KIND" != secondmate ] && [ "$BACKEND" != orca ]; then validate_spawn_worktree "treehouse get" "$T" fi if [ "$RELAUNCH" -eq 0 ] && [ "$KIND" != secondmate ]; then - freshen_spawn_worktree_base "$WT" || exit 1 + freshen_spawn_worktree_base "$WT" "$PROJ_ABS" "$ALLOW_REMOTELESS_BASE" || exit 1 fi # Per-task temp root: /tmp/fm-/ with Go's build temp nested at gotmp/. Go won't diff --git a/docs/architecture.md b/docs/architecture.md index 758988e3bac..296767c6c34 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -273,7 +273,7 @@ The mode is passed explicitly to `bin/fm-brief.sh`, and both values are passed e A ship brief records its mode as a fixed machine-readable line and the spawn refuses to launch on a different one, so the worker's instructions and the recorded task delivery cannot diverge. `bin/fm-dod-lib.sh` is the one owner of that mode's definition of done, rendered both into a generated ship brief and into the ship instructions a promoted scout receives, so a promoted worker cannot be handed a weaker contract than a briefed one. `data/projects.md` records each project's standing posture and optional `+yolo` merge flag as the captain's default and as context for that decision, including the conditional `no-mistakes-prod-only` policy; a ship spawn that drops below the registered rigor prints a deviation notice and continues. -`bin/fm-project-mode.sh` remains the one registry parser for the mechanical consumers that have no task in hand: fleet sync's `local-only` skip and home seeding's refusal and no-mistakes initialization. +`bin/fm-project-mode.sh` remains the one registry parser for fleet sync's `local-only` skip, home seeding's refusal and no-mistakes initialization, and spawn's registered-local-only fresh-base eligibility and advisory. When a selected delivery path calls for a diff, `bin/fm-review-diff.sh` refreshes the authoritative base and, when task meta records `pr=`, always fetches and compares against `refs/pull//head` by default (recorded `pr_head=` is only an offline fallback) before falling back to the local branch with a warning. Where a no-mistakes pipeline stores evidence in the repo, it publishes that PR-viewable validation evidence to an orphan evidence branch that shares no history with code branches, so it never enters the crew branch or the default branch. This repo uses that setting, and its own `.no-mistakes/` directory remains local state that stays gitignored and is rejected by CI if tracked; [`configuration.md`](configuration.md) owns the setting. diff --git a/tests/fm-spawn-pool-base-freshen.test.sh b/tests/fm-spawn-pool-base-freshen.test.sh index 492d4ebeabc..73a42cc207e 100755 --- a/tests/fm-spawn-pool-base-freshen.test.sh +++ b/tests/fm-spawn-pool-base-freshen.test.sh @@ -4,8 +4,8 @@ # A treehouse pool can return a clean detached worktree whose origin/main was # advanced after the worktree was allocated. # These tests drive the real spawn path with a fake terminal, then prove it -# starts the worker from the fetched origin/main tip or stops when origin is -# unreachable. +# starts remote-backed work from the fetched origin tip, starts eligible +# remote-less work from the current local default branch, or refuses safely. set -u # shellcheck source=tests/fixtures.sh @@ -52,6 +52,34 @@ $1 EOF } +make_remoteless_case() { + local name=$1 id=$2 posture=$3 case_dir home project pool fakebin initial + case_dir="$TMP_ROOT/$name" + home="$case_dir/home" + project="$case_dir/project" + 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 -- '- project [%s] - test project (added 2026-08-08)\n' "$posture" > "$home/data/projects.md" + 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 + initial=$(git -C "$project" rev-parse HEAD) + git -C "$project" worktree add --quiet --detach "$pool" "$initial" + + printf 'must survive a newly spawned local branch\n' > "$project/advanced-local.txt" + git -C "$project" add advanced-local.txt + git -C "$project" -c user.name='Firstmate Tests' -c user.email='tests@example.invalid' commit -qm advance-local + + printf '%s\n' "$case_dir|$home|$project|$pool|$fakebin|$initial|main" +} + run_spawn() { local id=$1 shift @@ -158,6 +186,98 @@ test_direct_pr_and_scout_refresh_before_launch() { pass "direct-PR ships and scouts both refresh stale pooled worktrees before launch" } +test_registered_remoteless_project_spawns_scout_and_local_ship() { + local rec id out status current + id='pool-remoteless-scout-r12' + rec=$(make_remoteless_case remoteless-success "$id" local-only) + read_case_record "$rec" + [ -z "$(git -C "$PROJECT_DIR" remote)" ] || fail "remote-less fixture unexpectedly has a configured remote" + current=$(git -C "$PROJECT_DIR" rev-parse refs/heads/main) + [ "$current" != "$INITIAL_SHA" ] || fail "remote-less fixture did not advance its local default branch" + + out=$(run_spawn "$id" --scout) + status=$? + expect_code 0 "$status" "a registered local-only project should spawn a scout without origin" + assert_contains "$out" "spawned $id" "remote-less scout did not report success" + [ "$(git -C "$POOL_DIR" rev-parse HEAD)" = "$current" ] \ + || fail "remote-less scout did not refresh to the current local default branch" + assert_grep 'must survive a newly spawned local branch' "$POOL_DIR/advanced-local.txt" \ + "remote-less scout omitted current local-default content" + + id='pool-remoteless-ship-r12' + mkdir -p "$HOME_DIR/data/$id" + printf 'Delivery contract: mode=local-only\n' > "$HOME_DIR/data/$id/brief.md" + out=$(run_spawn "$id" --mode local-only --yolo off) + status=$? + expect_code 0 "$status" "a registered local-only project should spawn a local-only ship without origin" + assert_contains "$out" "spawned $id" "remote-less local-only ship did not report success" + assert_grep 'mode=local-only' "$HOME_DIR/state/$id.meta" \ + "remote-less ship did not record its local-only delivery contract" + [ "$(git -C "$POOL_DIR" rev-parse HEAD)" = "$current" ] \ + || fail "remote-less local-only ship did not stay on the current local default branch" + pass "registered remote-less projects refresh locally for scouts and local-only ships" +} + +test_remote_backed_postures_refuse_missing_origin() { + local posture slug rec id out status before + for posture in no-mistakes direct-PR no-mistakes-prod-only; do + case "$posture" in + no-mistakes) slug=no-mistakes ;; + direct-PR) slug=direct-pr ;; + no-mistakes-prod-only) slug=prod-only ;; + esac + id="pool-missing-origin-${slug}-r13" + rec=$(make_remoteless_case "missing-origin-$slug" "$id" "$posture") + read_case_record "$rec" + before=$(git -C "$POOL_DIR" rev-parse HEAD) + + out=$(run_spawn "$id" --scout) + status=$? + [ "$status" -ne 0 ] || fail "$posture scout spawned without its required origin" + assert_contains "$out" "has no origin remote" \ + "$posture missing-origin refusal did not name the project contract violation" + [ "$(git -C "$POOL_DIR" rev-parse HEAD)" = "$before" ] \ + || fail "$posture missing-origin refusal moved the pooled worktree" + done + pass "every remote-backed project posture still requires origin for scouts" +} + +test_remoteless_project_refuses_pr_ship_mode() { + local rec id out status before + id='pool-remoteless-pr-ship-r14' + rec=$(make_remoteless_case remoteless-pr-ship "$id" local-only) + read_case_record "$rec" + printf 'Delivery contract: mode=direct-PR\n' > "$HOME_DIR/data/$id/brief.md" + before=$(git -C "$POOL_DIR" rev-parse HEAD) + + out=$(run_spawn "$id" --mode direct-PR --yolo off) + status=$? + [ "$status" -ne 0 ] || fail "a PR-backed ship spawned without origin from a local-only project" + assert_contains "$out" "has no origin remote" \ + "PR-backed ship missing-origin refusal did not name the required origin" + [ "$(git -C "$POOL_DIR" rev-parse HEAD)" = "$before" ] \ + || fail "PR-backed ship missing-origin refusal moved the pooled worktree" + pass "a local-only project does not make a PR-backed ship eligible for a remote-less base" +} + +test_configured_failing_origin_never_falls_back_to_local_base() { + local rec id out status before + id='pool-local-failing-origin-r15' + rec=$(make_remoteless_case local-failing-origin "$id" local-only) + read_case_record "$rec" + git -C "$PROJECT_DIR" remote add origin "file://$CASE_DIR/missing-origin.git" + before=$(git -C "$POOL_DIR" rev-parse HEAD) + + out=$(run_spawn "$id" --scout) + status=$? + [ "$status" -ne 0 ] || fail "a configured but failing origin fell back to the local default branch" + assert_contains "$out" "could not fetch origin" \ + "configured failing origin was not kept on the origin freshness guard" + [ "$(git -C "$POOL_DIR" rev-parse HEAD)" = "$before" ] \ + || fail "failing-origin refusal moved the pooled worktree" + pass "a configured failing origin never degrades into remote-less local refresh" +} + test_dirty_pool_refuses_without_discarding_work() { local rec id out status before id='pool-dirty-refusal-r4' @@ -428,6 +548,10 @@ test_stale_pin_beside_other_dirt_reports_one_verdict() { test_stale_pool_base_refreshes_before_branching test_non_main_default_branch_refreshes_before_branching test_direct_pr_and_scout_refresh_before_launch +test_registered_remoteless_project_spawns_scout_and_local_ship +test_remote_backed_postures_refuse_missing_origin +test_remoteless_project_refuses_pr_ship_mode +test_configured_failing_origin_never_falls_back_to_local_base test_dirty_pool_refuses_without_discarding_work test_unresolved_remote_default_refuses_pool test_unreachable_origin_refuses_stale_pool_base From 6b3997721d23a640ddd98ac8a35f65bc9cbbabc3 Mon Sep 17 00:00:00 2001 From: Sam Sherpa Date: Thu, 27 Aug 2026 10:09:46 -0700 Subject: [PATCH 02/12] no-mistakes(review): Harden remote-less spawn and PR promotion safeguards --- bin/fm-ff-lib.sh | 19 +++--- bin/fm-promote.sh | 19 ++++++ bin/fm-spawn.sh | 44 ++++++++++---- docs/architecture.md | 2 +- docs/scripts.md | 2 +- tests/fm-spawn-pool-base-freshen.test.sh | 32 +++++++++- tests/fm-task-delivery.test.sh | 75 ++++++++++++++++++++++-- 7 files changed, 166 insertions(+), 27 deletions(-) diff --git a/bin/fm-ff-lib.sh b/bin/fm-ff-lib.sh index 77d87cf6a9c..8efa54d9f21 100644 --- a/bin/fm-ff-lib.sh +++ b/bin/fm-ff-lib.sh @@ -34,13 +34,8 @@ first_line() { printf '%s\n' "$1" | sed -n '1s/[[:space:]]\{1,\}/ /g;1p' } -default_branch() { - local dir=$1 ref branch - ref=$(git -C "$dir" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true) - if [ -n "$ref" ]; then - echo "${ref#origin/}" - return 0 - fi +local_default_branch() { + local dir=$1 branch for branch in main master; do if git -C "$dir" show-ref --verify --quiet "refs/heads/$branch"; then echo "$branch" @@ -50,6 +45,16 @@ default_branch() { return 1 } +default_branch() { + local dir=$1 ref + ref=$(git -C "$dir" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true) + if [ -n "$ref" ]; then + echo "${ref#origin/}" + return 0 + fi + local_default_branch "$dir" +} + # Resolve the PRIMARY checkout's current default-branch commit - the local-HEAD # sync target every secondmate follows. Reads the default branch *ref* rather than # HEAD, so even a primary stranded on a feature branch (the worktree tangle of diff --git a/bin/fm-promote.sh b/bin/fm-promote.sh index 39f1c4cb999..3116da0b2ba 100755 --- a/bin/fm-promote.sh +++ b/bin/fm-promote.sh @@ -120,6 +120,25 @@ fm_lock_acquire_wait "$META_LOCK" META_LOCK_HELD=1 [ -f "$META" ] || { echo "error: no meta for task $ID at $META" >&2; exit 1; } grep -qx 'kind=scout' "$META" || { echo "error: task $ID is not a scout task (kind=scout not in meta)" >&2; exit 1; } +if [ "$MODE" != local-only ]; then + PROMOTE_WT=$(fmx_meta_get "$META" worktree) + [ -n "$PROMOTE_WT" ] || { + echo "error: scout task $ID has no recorded worktree; refusing to create a PR-backed task contract" >&2 + exit 1 + } + PROMOTE_REMOTES=$(git -C "$PROMOTE_WT" remote 2>/dev/null) || { + echo "error: could not inspect configured remotes for scout worktree '$PROMOTE_WT'; refusing to create a PR-backed task contract" >&2 + exit 1 + } + if ! printf '%s\n' "$PROMOTE_REMOTES" | grep -qx origin; then + echo "error: scout worktree '$PROMOTE_WT' has no origin remote; refusing to create a PR-backed task contract" >&2 + exit 1 + fi + if ! git -C "$PROMOTE_WT" fetch --quiet origin; then + echo "error: could not fetch origin for scout worktree '$PROMOTE_WT'; refusing to create a PR-backed task contract" >&2 + exit 1 + fi +fi # The promoted worker must receive the same delivery contract an ordinary ship # brief carries, so the mode-specific Definition of done is rendered from its diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index d2e9256c107..cc5e2608013 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -1784,6 +1784,7 @@ delivery_rigor_rank() { # -> 3 (most rigor) .. 1 (least); 0 = not a task # recorded task delivery differ, which is the exact drift this contract prevents. PROJECT_POSTURE= ALLOW_REMOTELESS_BASE=no +REQUIRE_TASK_ORIGIN=no if [ "$KIND" != secondmate ]; then PROJ_NAME=$(basename "$PROJ_ABS") PROJECT_POSTURE=$("$FM_ROOT/bin/fm-project-mode.sh" --raw "$PROJ_NAME" 2>/dev/null | cut -d' ' -f1) || PROJECT_POSTURE= @@ -1792,6 +1793,9 @@ if [ "$KIND" != secondmate ]; then scout:|ship:local-only) ALLOW_REMOTELESS_BASE=yes ;; esac fi + case "$KIND:$MODE" in + ship:no-mistakes|ship:direct-PR) REQUIRE_TASK_ORIGIN=yes ;; + esac fi if [ "$KIND" = ship ]; then @@ -1904,28 +1908,44 @@ EOF printf '%s' "$lines" >&2 } -freshen_spawn_worktree_base() { # - local worktree=$1 project=$2 allow_remoteless=$3 default target reset_target expected actual status remotes - remotes=$(git -C "$worktree" remote 2>/dev/null) || { +freshen_spawn_worktree_base() { # + local worktree=$1 project=$2 allow_remoteless=$3 require_task_origin=$4 + local default target reset_target expected actual status worktree_remotes project_remotes remote_dir + worktree_remotes=$(git -C "$worktree" remote 2>/dev/null) || { echo "error: could not inspect configured remotes for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 return 1 } - if printf '%s\n' "$remotes" | grep -qx origin; then - if ! git -C "$worktree" fetch --quiet origin; then + project_remotes=$(git -C "$project" remote 2>/dev/null) || { + echo "error: could not inspect configured remotes for project '$project'; refusing to launch from a potentially stale base" >&2 + return 1 + } + if [ "$require_task_origin" = yes ] \ + && ! printf '%s\n' "$worktree_remotes" | grep -qx origin; then + echo "error: pooled worktree '$worktree' has no origin remote; a PR-backed task contract requires origin" >&2 + return 1 + fi + remote_dir= + if printf '%s\n' "$worktree_remotes" | grep -qx origin; then + remote_dir=$worktree + elif printf '%s\n' "$project_remotes" | grep -qx origin; then + remote_dir=$project + fi + if [ -n "$remote_dir" ]; then + if ! git -C "$remote_dir" fetch --quiet origin; then echo "error: could not fetch origin for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 return 1 fi - if ! git -C "$worktree" remote set-head origin --auto >/dev/null 2>&1; then + if ! git -C "$remote_dir" remote set-head origin --auto >/dev/null 2>&1; then echo "error: could not resolve origin's current default branch for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 return 1 fi - default=$(default_branch "$worktree") || { + default=$(default_branch "$remote_dir") || { echo "error: could not determine origin's default branch for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 return 1 } target="origin/$default" reset_target=$target - if ! git -C "$worktree" fetch --quiet origin "+refs/heads/$default:refs/remotes/origin/$default"; then + if ! git -C "$remote_dir" fetch --quiet origin "+refs/heads/$default:refs/remotes/origin/$default"; then echo "error: could not fetch '$target' for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 return 1 fi @@ -1934,15 +1954,15 @@ freshen_spawn_worktree_base() { # return 1 } else - if [ -n "$remotes" ]; then - echo "error: pooled worktree '$worktree' has configured remotes but no origin remote; refusing to launch without the required origin freshness guard" >&2 + if [ -n "$worktree_remotes" ] || [ -n "$project_remotes" ]; then + echo "error: project '$project' or pooled worktree '$worktree' has configured remotes but no origin remote; refusing to launch without the required origin freshness guard" >&2 return 1 fi if [ "$allow_remoteless" != yes ]; then echo "error: pooled worktree '$worktree' has no origin remote; only a registered local-only project's scout or local-only ship may launch without one" >&2 return 1 fi - default=$(default_branch "$project") || { + default=$(local_default_branch "$project") || { echo "error: could not determine the local default branch for remote-less project '$project'; refusing to launch from a potentially stale base" >&2 return 1 } @@ -2512,7 +2532,7 @@ elif [ "$KIND" != secondmate ] && [ "$BACKEND" != orca ]; then validate_spawn_worktree "treehouse get" "$T" fi if [ "$RELAUNCH" -eq 0 ] && [ "$KIND" != secondmate ]; then - freshen_spawn_worktree_base "$WT" "$PROJ_ABS" "$ALLOW_REMOTELESS_BASE" || exit 1 + freshen_spawn_worktree_base "$WT" "$PROJ_ABS" "$ALLOW_REMOTELESS_BASE" "$REQUIRE_TASK_ORIGIN" || exit 1 fi # Per-task temp root: /tmp/fm-/ with Go's build temp nested at gotmp/. Go won't diff --git a/docs/architecture.md b/docs/architecture.md index 296767c6c34..05996a415cb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -185,7 +185,7 @@ Codex App support is recorded in `docs/codex-app-backend.md`; it is not selectab Crewmates never intentionally touch your project clone; [treehouse](https://github.com/kunchenguid/treehouse) pools clean worktrees for tmux, herdr, zellij, and cmux tasks, while Orca creates its own worktrees for `backend=orca`. For ship and scout work, `fm-spawn.sh` refuses to launch unless the resolved task path is a real git worktree root that is distinct from the project primary checkout. -`fm-spawn.sh` also owns the base-freshness boundary for every fresh ship and scout: no worker starts until its clean task worktree matches the fetched tip of origin's resolved default branch, and any unsafe or unverifiable base stops the spawn. +`fm-spawn.sh` also owns the base-freshness boundary for every fresh ship and scout: remote-backed work matches the fetched tip of origin's resolved default branch, while an eligible registered local-only project with no configured remotes matches its local default branch; any unsafe or unverifiable base stops the spawn. Its header owns the exact refusal mechanics, while `tests/fm-spawn-pool-base-freshen.test.sh` owns the portable regression coverage. The firstmate repo has one extra exposure because it can dispatch crewmates to work on itself. diff --git a/docs/scripts.md b/docs/scripts.md index dff1c06341e..9e5c6d1e59e 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -64,7 +64,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `backends/orca.sh` | Experimental Orca backend adapter owning both worktree and terminal | | `backends/cmux.sh` | Experimental cmux session-provider adapter | | `fm-config-push.sh` | Push declared inherited local material to live local or remote secondmates and send the placement-specific config reread when changed | -| `fm-project-mode.sh` | Resolve a project's registered delivery posture from `data/projects.md` for fleet sync and home seeding | +| `fm-project-mode.sh` | Resolve a project's registered delivery posture for fleet sync, home seeding, and spawn eligibility/advisories | | `fm-merge-local.sh` | Fast-forward a `local-only` project's local default branch after approval | | `fm-review-diff.sh` | Review a crewmate branch or resolved PR head against the authoritative base | | `fm-marker-lib.sh` | Compatibility entry point for the from-firstmate carrier owned by `fm-operational-input.sh` | diff --git a/tests/fm-spawn-pool-base-freshen.test.sh b/tests/fm-spawn-pool-base-freshen.test.sh index 73a42cc207e..99198fb49fb 100755 --- a/tests/fm-spawn-pool-base-freshen.test.sh +++ b/tests/fm-spawn-pool-base-freshen.test.sh @@ -218,6 +218,27 @@ test_registered_remoteless_project_spawns_scout_and_local_ship() { pass "registered remote-less projects refresh locally for scouts and local-only ships" } +test_remoteless_base_ignores_stale_origin_head_refs() { + local rec id out status current + id='pool-remoteless-stale-origin-head-r13' + rec=$(make_remoteless_case remoteless-stale-origin-head "$id" local-only) + read_case_record "$rec" + current=$(git -C "$PROJECT_DIR" rev-parse refs/heads/main) + git -C "$PROJECT_DIR" branch trunk "$INITIAL_SHA" + git -C "$PROJECT_DIR" update-ref refs/remotes/origin/trunk "$INITIAL_SHA" + git -C "$PROJECT_DIR" symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/trunk + [ -z "$(git -C "$PROJECT_DIR" remote)" ] || fail "stale-ref fixture unexpectedly configured a remote" + + out=$(run_spawn "$id" --scout) + status=$? + expect_code 0 "$status" "stale remote-tracking refs should not block a remote-less scout" + [ "$(git -C "$POOL_DIR" rev-parse HEAD)" = "$current" ] \ + || fail "remote-less scout selected a stale origin/HEAD branch instead of local main" + assert_grep 'must survive a newly spawned local branch' "$POOL_DIR/advanced-local.txt" \ + "remote-less scout omitted current local-main content" + pass "remote-less base resolution ignores stale origin tracking refs" +} + test_remote_backed_postures_refuse_missing_origin() { local posture slug rec id out status before for posture in no-mistakes direct-PR no-mistakes-prod-only; do @@ -265,7 +286,13 @@ test_configured_failing_origin_never_falls_back_to_local_base() { id='pool-local-failing-origin-r15' rec=$(make_remoteless_case local-failing-origin "$id" local-only) read_case_record "$rec" - git -C "$PROJECT_DIR" remote add origin "file://$CASE_DIR/missing-origin.git" + git -C "$PROJECT_DIR" config extensions.worktreeConfig true + git -C "$PROJECT_DIR" config --worktree remote.origin.url "file://$CASE_DIR/missing-origin.git" + git -C "$PROJECT_DIR" config --worktree remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*' + [ "$(git -C "$PROJECT_DIR" remote)" = origin ] \ + || fail "fixture did not configure origin in the authoritative project" + [ -z "$(git -C "$POOL_DIR" remote)" ] \ + || fail "fixture leaked the project worktree's origin into the pooled worktree" before=$(git -C "$POOL_DIR" rev-parse HEAD) out=$(run_spawn "$id" --scout) @@ -275,7 +302,7 @@ test_configured_failing_origin_never_falls_back_to_local_base() { "configured failing origin was not kept on the origin freshness guard" [ "$(git -C "$POOL_DIR" rev-parse HEAD)" = "$before" ] \ || fail "failing-origin refusal moved the pooled worktree" - pass "a configured failing origin never degrades into remote-less local refresh" + pass "a project-worktree origin never degrades into remote-less local refresh" } test_dirty_pool_refuses_without_discarding_work() { @@ -549,6 +576,7 @@ test_stale_pool_base_refreshes_before_branching test_non_main_default_branch_refreshes_before_branching test_direct_pr_and_scout_refresh_before_launch test_registered_remoteless_project_spawns_scout_and_local_ship +test_remoteless_base_ignores_stale_origin_head_refs test_remote_backed_postures_refuse_missing_origin test_remoteless_project_refuses_pr_ship_mode test_configured_failing_origin_never_falls_back_to_local_base diff --git a/tests/fm-task-delivery.test.sh b/tests/fm-task-delivery.test.sh index 3373671fc21..194b4db6fa8 100755 --- a/tests/fm-task-delivery.test.sh +++ b/tests/fm-task-delivery.test.sh @@ -202,13 +202,24 @@ EOF # Promotion is where a scout's ship contract is finally decided, so it requires the # same explicit values and writes them into the task's durable record. test_promote_requires_and_records_the_delivery_contract() { - local home meta out status blocked_data instructions_path + local home meta out status blocked_data instructions_path project worktree origin home="$TMP_ROOT/promote/home" + project="$TMP_ROOT/promote/project" + worktree="$TMP_ROOT/promote/worktree" + origin="$TMP_ROOT/promote/origin.git" mkdir -p "$home/state" + 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" + git -C "$project" worktree add --quiet --detach "$worktree" HEAD meta="$home/state/promote-d1.meta" write_scout_meta() { - printf 'window=fm-promote-d1\nkind=scout\nworktree=/tmp/wt\n' > "$meta" + printf 'window=fm-promote-d1\nkind=scout\nworktree=%s\nproject=%s\n' \ + "$worktree" "$project" > "$meta" } write_scout_meta @@ -269,10 +280,20 @@ test_promote_requires_and_records_the_delivery_contract() { # prints against a capturing fm-send.sh, and asserts on the message the worker would # actually receive - for every supported mode. test_promotion_delivers_the_real_definition_of_done() { - local home meta out sendroot payload mode id brief_dod delivered_dod + local home meta out sendroot payload mode id brief_dod delivered_dod project worktree origin home="$TMP_ROOT/promote-dod/home" sendroot="$TMP_ROOT/promote-dod/sendroot" + project="$TMP_ROOT/promote-dod/project" + worktree="$TMP_ROOT/promote-dod/worktree" + origin="$TMP_ROOT/promote-dod/origin.git" mkdir -p "$home/state" "$sendroot/bin" + 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" + git -C "$project" worktree add --quiet --detach "$worktree" HEAD cat > "$sendroot/bin/fm-send.sh" <<'STUB' #!/usr/bin/env bash # Capture the message a promoted worker would receive, instead of steering one. @@ -283,7 +304,8 @@ STUB for mode in no-mistakes direct-PR local-only; do id="promote-dod-$(printf '%s' "$mode" | tr '[:upper:]' '[:lower:]')" meta="$home/state/$id.meta" - printf 'window=fm-%s\nkind=scout\nworktree=/tmp/wt\n' "$id" > "$meta" + printf 'window=fm-%s\nkind=scout\nworktree=%s\nproject=%s\n' \ + "$id" "$worktree" "$project" > "$meta" out=$(FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" "$PROMOTE" "$id" --mode "$mode" --yolo off 2>&1) \ || fail "$mode: promotion should succeed" @@ -346,6 +368,50 @@ STUB pass "fm-promote: a promoted worker receives the same mode-specific delivery contract a briefed one does" } +test_promote_requires_origin_for_pr_backed_contracts() { + local home meta out status project worktree mode + home="$TMP_ROOT/promote-origin/home" + project="$TMP_ROOT/promote-origin/project" + worktree="$TMP_ROOT/promote-origin/worktree" + mkdir -p "$home/state" + 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 -C "$project" worktree add --quiet --detach "$worktree" HEAD + meta="$home/state/promote-origin-d2.meta" + printf 'window=fm-promote-origin-d2\nkind=scout\nworktree=%s\nproject=%s\n' \ + "$worktree" "$project" > "$meta" + + for mode in direct-PR no-mistakes; do + out=$(FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" \ + "$PROMOTE" promote-origin-d2 --mode "$mode" --yolo off 2>&1) + status=$? + [ "$status" -ne 0 ] || fail "$mode promotion created a PR-backed contract without origin" + assert_contains "$out" "has no origin remote" \ + "$mode promotion did not name its missing origin" + assert_grep 'kind=scout' "$meta" "$mode missing-origin refusal changed the scout contract" + done + + git -C "$project" remote add origin "file://$TMP_ROOT/promote-origin/missing.git" + out=$(FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" \ + "$PROMOTE" promote-origin-d2 --mode direct-PR --yolo off 2>&1) + status=$? + [ "$status" -ne 0 ] || fail "promotion created a PR-backed contract with a failing origin" + assert_contains "$out" "could not fetch origin" \ + "PR-backed promotion did not verify that origin was fetchable" + assert_grep 'kind=scout' "$meta" "failing-origin refusal changed the scout contract" + + git -C "$project" remote remove origin + out=$(FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" \ + "$PROMOTE" promote-origin-d2 --mode local-only --yolo off 2>&1) + status=$? + expect_code 0 "$status" "local-only promotion should not require origin" + assert_grep 'kind=ship' "$meta" "remote-less local-only promotion did not restore ship protection" + assert_grep 'mode=local-only' "$meta" "remote-less local-only promotion did not record its contract" + pass "fm-promote: only PR-backed promotions require a fetchable origin" +} + # The registry parser survives for the mechanical consumers only. It accepts the # conditional policy, maps it to its most rigorous leg for them, and exposes the # raw annotation for the one caller that must tell a policy from a flat mode. @@ -387,5 +453,6 @@ test_spawn_notices_a_rigor_downgrade_against_the_registry test_scout_records_no_delivery_posture test_promote_requires_and_records_the_delivery_contract test_promotion_delivers_the_real_definition_of_done +test_promote_requires_origin_for_pr_backed_contracts test_project_mode_maps_the_conditional_policy echo "# all fm-task-delivery tests passed" From 51a6038f4091f1c22aea46b0c8e8d8cabf4de92d Mon Sep 17 00:00:00 2001 From: Sam Sherpa Date: Fri, 28 Aug 2026 11:59:24 -0700 Subject: [PATCH 03/12] no-mistakes(review): Unify lifecycle base resolution and promotion origin validation --- bin/fm-ff-lib.sh | 19 ++---- bin/fm-merge-local.sh | 27 +++----- bin/fm-project-mode.sh | 4 +- bin/fm-promote.sh | 40 ++++++----- bin/fm-review-diff.sh | 48 +++++++------- bin/fm-spawn.sh | 71 +++----------------- bin/fm-tangle-lib.sh | 118 ++++++++++++++++++++++++++++++--- bin/fm-teardown.sh | 47 ++++++------- tests/fm-review-diff.test.sh | 67 ++++++++++++++----- tests/fm-task-delivery.test.sh | 75 ++++++++++++++++++++- tests/fm-teardown.test.sh | 25 +++++++ 11 files changed, 346 insertions(+), 195 deletions(-) diff --git a/bin/fm-ff-lib.sh b/bin/fm-ff-lib.sh index 8efa54d9f21..917523eb5d1 100644 --- a/bin/fm-ff-lib.sh +++ b/bin/fm-ff-lib.sh @@ -27,6 +27,8 @@ SUB_HOME_MARKER="${SUB_HOME_MARKER:-.fm-secondmate-home}" # shellcheck source=bin/fm-secondmate-registry-lib.sh . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/fm-secondmate-registry-lib.sh" +# shellcheck source=bin/fm-tangle-lib.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/fm-tangle-lib.sh" # --- helpers --------------------------------------------------------------- @@ -35,24 +37,11 @@ first_line() { } local_default_branch() { - local dir=$1 branch - for branch in main master; do - if git -C "$dir" show-ref --verify --quiet "refs/heads/$branch"; then - echo "$branch" - return 0 - fi - done - return 1 + fm_local_default_branch "$@" } default_branch() { - local dir=$1 ref - ref=$(git -C "$dir" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true) - if [ -n "$ref" ]; then - echo "${ref#origin/}" - return 0 - fi - local_default_branch "$dir" + fm_default_branch "$@" } # Resolve the PRIMARY checkout's current default-branch commit - the local-HEAD diff --git a/bin/fm-merge-local.sh b/bin/fm-merge-local.sh index 70ac9b7be2c..505e4f7a355 100755 --- a/bin/fm-merge-local.sh +++ b/bin/fm-merge-local.sh @@ -22,35 +22,28 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" # no-op in homes without a branch actor). # shellcheck source=bin/fm-lease-lib.sh . "$SCRIPT_DIR/fm-lease-lib.sh" +# shellcheck source=bin/fm-tangle-lib.sh +. "$SCRIPT_DIR/fm-tangle-lib.sh" fm_lease_forbid_branch "local-only landing (fm-merge-local)" ID=${1:?usage: fm-merge-local.sh } META="$STATE/$ID.meta" [ -f "$META" ] || { echo "error: no meta for task $ID at $META" >&2; exit 1; } PROJ=$(grep '^project=' "$META" | cut -d= -f2-) +WT=$(grep '^worktree=' "$META" | cut -d= -f2-) MODE=$(grep '^mode=' "$META" | cut -d= -f2- || true) [ "$MODE" = local-only ] || { echo "error: task $ID is mode=$MODE, not local-only; merge PR tasks with bin/fm-pr-merge.sh after approval" >&2; exit 1; } - -default_branch() { - local ref branch - ref=$(git -C "$PROJ" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true) - if [ -n "$ref" ]; then - echo "${ref#origin/}" - return 0 - fi - for branch in main master; do - if git -C "$PROJ" show-ref --verify --quiet "refs/heads/$branch"; then - echo "$branch" - return 0 - fi - done - return 1 -} +[ -n "$PROJ" ] || { echo "error: task $ID has no recorded authoritative project" >&2; exit 1; } +[ -n "$WT" ] || { echo "error: task $ID has no recorded worktree" >&2; exit 1; } BRANCH="fm/$ID" git -C "$PROJ" rev-parse --verify --quiet "refs/heads/$BRANCH" >/dev/null || { echo "error: branch $BRANCH does not exist in $PROJ" >&2; exit 1; } -DEFAULT=$(default_branch) || { echo "error: cannot determine default branch for $PROJ; expected origin/HEAD, main, or master" >&2; exit 1; } +if ! fm_project_base_resolve "$PROJ" "$WT" yes no; then + echo "error: $FM_PROJECT_BASE_ERROR; cannot merge safely" >&2 + exit 1 +fi +DEFAULT=$FM_PROJECT_BASE_BRANCH # The project's main checkout must be on its default branch and clean, so the # fast-forward lands predictably (firstmate never writes here otherwise). diff --git a/bin/fm-project-mode.sh b/bin/fm-project-mode.sh index 7baeacdfe6e..d25bf4fefd8 100755 --- a/bin/fm-project-mode.sh +++ b/bin/fm-project-mode.sh @@ -8,9 +8,9 @@ # yolo are resolved by firstmate at intake and passed explicitly to # bin/fm-brief.sh, bin/fm-spawn.sh, and bin/fm-promote.sh (AGENTS.md section 7). # The consumers are bin/fm-fleet-sync.sh (skip local-only clones), -# bin/fm-home-seed.sh (refuse local-only seeding, run no-mistakes init), and +# bin/fm-home-seed.sh (refuse local-only seeding, run no-mistakes init), # bin/fm-spawn.sh (registered-local-only fresh-base eligibility and the advisory -# registry-deviation notice). +# registry-deviation notice), and bin/fm-review-diff.sh (the same eligibility). # # Registry line format (data/projects.md): # - - (added ) -> no-mistakes off (legacy default) diff --git a/bin/fm-promote.sh b/bin/fm-promote.sh index 3116da0b2ba..f27132325a2 100755 --- a/bin/fm-promote.sh +++ b/bin/fm-promote.sh @@ -37,6 +37,8 @@ DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" . "$SCRIPT_DIR/fm-secondmate-parent-lib.sh" # shellcheck source=bin/fm-secondmate-registry-lib.sh . "$SCRIPT_DIR/fm-secondmate-registry-lib.sh" +# shellcheck source=bin/fm-tangle-lib.sh +. "$SCRIPT_DIR/fm-tangle-lib.sh" MODE= YOLO= @@ -120,24 +122,26 @@ fm_lock_acquire_wait "$META_LOCK" META_LOCK_HELD=1 [ -f "$META" ] || { echo "error: no meta for task $ID at $META" >&2; exit 1; } grep -qx 'kind=scout' "$META" || { echo "error: task $ID is not a scout task (kind=scout not in meta)" >&2; exit 1; } -if [ "$MODE" != local-only ]; then - PROMOTE_WT=$(fmx_meta_get "$META" worktree) - [ -n "$PROMOTE_WT" ] || { - echo "error: scout task $ID has no recorded worktree; refusing to create a PR-backed task contract" >&2 - exit 1 - } - PROMOTE_REMOTES=$(git -C "$PROMOTE_WT" remote 2>/dev/null) || { - echo "error: could not inspect configured remotes for scout worktree '$PROMOTE_WT'; refusing to create a PR-backed task contract" >&2 - exit 1 - } - if ! printf '%s\n' "$PROMOTE_REMOTES" | grep -qx origin; then - echo "error: scout worktree '$PROMOTE_WT' has no origin remote; refusing to create a PR-backed task contract" >&2 - exit 1 - fi - if ! git -C "$PROMOTE_WT" fetch --quiet origin; then - echo "error: could not fetch origin for scout worktree '$PROMOTE_WT'; refusing to create a PR-backed task contract" >&2 - exit 1 - fi +PROMOTE_WT=$(fmx_meta_get "$META" worktree) +PROMOTE_PROJECT=$(fmx_meta_get "$META" project) +[ -n "$PROMOTE_WT" ] || { + echo "error: scout task $ID has no recorded worktree; refusing to create a delivery contract" >&2 + exit 1 +} +[ -n "$PROMOTE_PROJECT" ] || { + echo "error: scout task $ID has no recorded authoritative project; refusing to create a delivery contract" >&2 + exit 1 +} +PROMOTE_ALLOW_REMOTELESS=no +PROMOTE_REQUIRE_TASK_ORIGIN=yes +if [ "$MODE" = local-only ]; then + PROMOTE_ALLOW_REMOTELESS=yes + PROMOTE_REQUIRE_TASK_ORIGIN=no +fi +if ! fm_project_base_resolve "$PROMOTE_PROJECT" "$PROMOTE_WT" \ + "$PROMOTE_ALLOW_REMOTELESS" "$PROMOTE_REQUIRE_TASK_ORIGIN"; then + echo "error: $FM_PROJECT_BASE_ERROR; refusing to create a $MODE task contract" >&2 + exit 1 fi # The promoted worker must receive the same delivery contract an ordinary ship diff --git a/bin/fm-review-diff.sh b/bin/fm-review-diff.sh index 06e0efb5bd7..e6bde739f44 100755 --- a/bin/fm-review-diff.sh +++ b/bin/fm-review-diff.sh @@ -18,6 +18,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +# shellcheck source=bin/fm-tangle-lib.sh +. "$SCRIPT_DIR/fm-tangle-lib.sh" "$FM_ROOT/bin/fm-guard.sh" || true usage() { @@ -49,23 +51,21 @@ PROJ=$(grep '^project=' "$META" | cut -d= -f2-) [ -d "$WT" ] || { echo "error: worktree for task $ID is missing: $WT" >&2; exit 1; } [ -d "$PROJ" ] || { echo "error: project for task $ID is missing: $PROJ" >&2; exit 1; } -default_branch() { - local ref branch - ref=$(git -C "$PROJ" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true) - if [ -n "$ref" ]; then - echo "${ref#origin/}" - return 0 - fi - for branch in main master; do - if git -C "$PROJ" show-ref --verify --quiet "refs/heads/$branch"; then - echo "$branch" - return 0 - fi - done - return 1 -} - -DEFAULT=$(default_branch) || { echo "error: cannot determine default branch for $PROJ; expected origin/HEAD, main, or master" >&2; exit 1; } +TASK_KIND=$(grep '^kind=' "$META" | tail -1 | cut -d= -f2- || true) +[ -n "$TASK_KIND" ] || TASK_KIND=ship +TASK_MODE=$(grep '^mode=' "$META" | tail -1 | cut -d= -f2- || true) +ALLOW_REMOTELESS=no +if [ "$TASK_KIND" = scout ] || [ "$TASK_MODE" = local-only ]; then + PROJECT_POSTURE=$("$FM_ROOT/bin/fm-project-mode.sh" --raw "$(basename "$PROJ")" 2>/dev/null | cut -d' ' -f1) || PROJECT_POSTURE= + [ "$PROJECT_POSTURE" != local-only ] || ALLOW_REMOTELESS=yes +fi +REQUIRE_TASK_ORIGIN=yes +[ "$TASK_MODE" != local-only ] || REQUIRE_TASK_ORIGIN=no +if ! fm_project_base_resolve "$PROJ" "$WT" "$ALLOW_REMOTELESS" "$REQUIRE_TASK_ORIGIN"; then + echo "error: $FM_PROJECT_BASE_ERROR; refusing to review against an unverified base" >&2 + exit 1 +fi +DEFAULT=$FM_PROJECT_BASE_BRANCH BRANCH="fm/$ID" if ! git -C "$WT" rev-parse --verify --quiet "refs/heads/$BRANCH" >/dev/null; then @@ -133,26 +133,24 @@ if [ -n "$PR_URL" ]; then fi fi -if git -C "$PROJ" remote get-url origin >/dev/null 2>&1; then - # Update the remote-tracking ref itself; a bare single-branch fetch can leave - # origin/ stale on some Git versions and only refresh FETCH_HEAD. - git -C "$WT" fetch origin "+refs/heads/$DEFAULT:refs/remotes/origin/$DEFAULT" --quiet +BASE_REF=$FM_PROJECT_BASE_COMMIT +if [ "$FM_PROJECT_BASE_KIND" = origin ]; then BASE="origin/$DEFAULT" else BASE="$DEFAULT" fi -git -C "$WT" rev-parse --verify --quiet "$BASE^{commit}" >/dev/null || { echo "error: base $BASE does not exist in $WT" >&2; exit 1; } +git -C "$WT" rev-parse --verify --quiet "$BASE_REF^{commit}" >/dev/null || { echo "error: base $BASE does not exist in $WT" >&2; exit 1; } git -C "$WT" rev-parse --verify --quiet "$COMPARE_REF^{commit}" >/dev/null || { echo "error: compare ref $COMPARE_REF does not resolve in $WT" >&2; exit 1; } echo "diff base: $BASE" -if git -C "$WT" diff --quiet "$BASE...$COMPARE_REF" --; then +if git -C "$WT" diff --quiet "$BASE_REF...$COMPARE_REF" --; then echo "no changes vs $BASE" exit 0 fi -git -C "$WT" diff --stat "$BASE...$COMPARE_REF" -- +git -C "$WT" diff --stat "$BASE_REF...$COMPARE_REF" -- if ! "$STAT_ONLY"; then echo - git -C "$WT" diff "$BASE...$COMPARE_REF" -- + git -C "$WT" diff "$BASE_REF...$COMPARE_REF" -- fi diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index cc5e2608013..231e3e867fc 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -1910,72 +1910,17 @@ EOF freshen_spawn_worktree_base() { # local worktree=$1 project=$2 allow_remoteless=$3 require_task_origin=$4 - local default target reset_target expected actual status worktree_remotes project_remotes remote_dir - worktree_remotes=$(git -C "$worktree" remote 2>/dev/null) || { - echo "error: could not inspect configured remotes for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 - return 1 - } - project_remotes=$(git -C "$project" remote 2>/dev/null) || { - echo "error: could not inspect configured remotes for project '$project'; refusing to launch from a potentially stale base" >&2 - return 1 - } - if [ "$require_task_origin" = yes ] \ - && ! printf '%s\n' "$worktree_remotes" | grep -qx origin; then - echo "error: pooled worktree '$worktree' has no origin remote; a PR-backed task contract requires origin" >&2 + local target reset_target expected actual status + if ! fm_project_base_resolve "$project" "$worktree" "$allow_remoteless" "$require_task_origin"; then + echo "error: $FM_PROJECT_BASE_ERROR; refusing to launch from a potentially stale base" >&2 return 1 fi - remote_dir= - if printf '%s\n' "$worktree_remotes" | grep -qx origin; then - remote_dir=$worktree - elif printf '%s\n' "$project_remotes" | grep -qx origin; then - remote_dir=$project - fi - if [ -n "$remote_dir" ]; then - if ! git -C "$remote_dir" fetch --quiet origin; then - echo "error: could not fetch origin for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 - return 1 - fi - if ! git -C "$remote_dir" remote set-head origin --auto >/dev/null 2>&1; then - echo "error: could not resolve origin's current default branch for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 - return 1 - fi - default=$(default_branch "$remote_dir") || { - echo "error: could not determine origin's default branch for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 - return 1 - } - target="origin/$default" - reset_target=$target - if ! git -C "$remote_dir" fetch --quiet origin "+refs/heads/$default:refs/remotes/origin/$default"; then - echo "error: could not fetch '$target' for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 - return 1 - fi - expected=$(git -C "$worktree" rev-parse --verify --quiet "$target^{commit}" 2>/dev/null) || { - echo "error: '$target' is not a commit for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 - return 1 - } + expected=$FM_PROJECT_BASE_COMMIT + reset_target=$expected + if [ "$FM_PROJECT_BASE_KIND" = origin ]; then + target="origin/$FM_PROJECT_BASE_BRANCH" else - if [ -n "$worktree_remotes" ] || [ -n "$project_remotes" ]; then - echo "error: project '$project' or pooled worktree '$worktree' has configured remotes but no origin remote; refusing to launch without the required origin freshness guard" >&2 - return 1 - fi - if [ "$allow_remoteless" != yes ]; then - echo "error: pooled worktree '$worktree' has no origin remote; only a registered local-only project's scout or local-only ship may launch without one" >&2 - return 1 - fi - default=$(local_default_branch "$project") || { - echo "error: could not determine the local default branch for remote-less project '$project'; refusing to launch from a potentially stale base" >&2 - return 1 - } - target="local $default" - expected=$(git -C "$project" rev-parse --verify --quiet "refs/heads/$default^{commit}" 2>/dev/null) || { - echo "error: local default branch '$default' is not a commit for remote-less project '$project'; refusing to launch from a potentially stale base" >&2 - return 1 - } - if ! git -C "$worktree" cat-file -e "$expected^{commit}" 2>/dev/null; then - echo "error: local default branch '$default' is unavailable in pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 - return 1 - fi - reset_target=$expected + target="local $FM_PROJECT_BASE_BRANCH" fi status=$(git -C "$worktree" -c core.quotePath=false status --porcelain) || { echo "error: could not inspect pooled worktree '$worktree' before refreshing its base" >&2 diff --git a/bin/fm-tangle-lib.sh b/bin/fm-tangle-lib.sh index a8554fbd60a..79b20ab6ce1 100644 --- a/bin/fm-tangle-lib.sh +++ b/bin/fm-tangle-lib.sh @@ -17,15 +17,10 @@ # default branch. Detached HEAD on the default is fine; a feature branch in a # primary checkout is the alarm. -# Resolve the default branch name of the git repo at : prefer origin/HEAD, -# then fall back to a local main/master. Echoes the name, or returns 1. -fm_default_branch() { - local dir=$1 ref branch - ref=$(git -C "$dir" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true) - if [ -n "$ref" ]; then - printf '%s\n' "${ref#origin/}" - return 0 - fi +# Resolve the default branch name of a local-only repository without consulting +# remote-tracking refs. Echoes the name, or returns 1. +fm_local_default_branch() { + local dir=$1 branch for branch in main master; do if git -C "$dir" show-ref --verify --quiet "refs/heads/$branch"; then printf '%s\n' "$branch" @@ -35,6 +30,111 @@ fm_default_branch() { return 1 } +# Resolve the default branch name of the git repo at : use origin/HEAD when +# recorded, then a local main/master. Lifecycle operations that require a fresh +# validated origin use fm_project_base_resolve below. +fm_default_branch() { + local dir=$1 remotes ref + remotes=$(git -C "$dir" remote 2>/dev/null) || return 1 + if [ -n "$remotes" ] && printf '%s\n' "$remotes" | grep -qx origin; then + ref=$(git -C "$dir" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true) + case "$ref" in + origin/?*) printf '%s\n' "${ref#origin/}"; return 0 ;; + esac + fi + fm_local_default_branch "$dir" +} + +FM_PROJECT_BASE_KIND= +FM_PROJECT_BASE_BRANCH= +FM_PROJECT_BASE_REF= +FM_PROJECT_BASE_COMMIT= +FM_PROJECT_BASE_ERROR= + +fm_project_base_resolve() { + local project=$1 worktree=$2 allow_remoteless=${3:-no} require_task_origin=${4:-no} + local project_remotes worktree_remotes remote_dir remote_head default ref commit + FM_PROJECT_BASE_KIND= + FM_PROJECT_BASE_BRANCH= + FM_PROJECT_BASE_REF= + FM_PROJECT_BASE_COMMIT= + FM_PROJECT_BASE_ERROR= + + worktree_remotes=$(git -C "$worktree" remote 2>/dev/null) || { + FM_PROJECT_BASE_ERROR="could not inspect configured remotes for task worktree '$worktree'" + return 1 + } + project_remotes=$(git -C "$project" remote 2>/dev/null) || { + FM_PROJECT_BASE_ERROR="could not inspect configured remotes for authoritative project '$project'" + return 1 + } + + if [ -z "$worktree_remotes" ] && [ -z "$project_remotes" ]; then + if [ "$allow_remoteless" != yes ]; then + FM_PROJECT_BASE_ERROR="task worktree '$worktree' has no origin remote; this lifecycle requires a valid origin" + return 1 + fi + default=$(fm_local_default_branch "$project") || { + FM_PROJECT_BASE_ERROR="could not determine the local default branch for remote-less project '$project'" + return 1 + } + ref="refs/heads/$default" + commit=$(git -C "$project" rev-parse --verify --quiet "$ref^{commit}" 2>/dev/null) || { + FM_PROJECT_BASE_ERROR="local default branch '$default' is not a commit for remote-less project '$project'" + return 1 + } + git -C "$worktree" cat-file -e "$commit^{commit}" 2>/dev/null || { + FM_PROJECT_BASE_ERROR="local default branch '$default' is unavailable in task worktree '$worktree'" + return 1 + } + FM_PROJECT_BASE_KIND=local + else + if [ "$require_task_origin" = yes ] \ + && ! printf '%s\n' "$worktree_remotes" | grep -qx origin; then + FM_PROJECT_BASE_ERROR="task worktree '$worktree' has no origin remote; a PR-backed task contract requires origin" + return 1 + fi + remote_dir= + if printf '%s\n' "$worktree_remotes" | grep -qx origin; then + remote_dir=$worktree + elif printf '%s\n' "$project_remotes" | grep -qx origin; then + remote_dir=$project + else + FM_PROJECT_BASE_ERROR="project '$project' or task worktree '$worktree' has configured remotes but no origin remote" + return 1 + fi + remote_head=$(git -C "$remote_dir" ls-remote --symref origin HEAD 2>/dev/null) || { + FM_PROJECT_BASE_ERROR="could not fetch origin for task worktree '$worktree'" + return 1 + } + default=$(printf '%s\n' "$remote_head" \ + | sed -n 's/^ref: refs\/heads\/\(.*\)[[:space:]]HEAD$/\1/p' \ + | head -1) + [ -n "$default" ] || { + FM_PROJECT_BASE_ERROR="could not resolve origin's current default branch for task worktree '$worktree'" + return 1 + } + ref="refs/remotes/origin/$default" + git -C "$remote_dir" fetch --quiet origin "+refs/heads/$default:$ref" || { + FM_PROJECT_BASE_ERROR="could not fetch 'origin/$default' for task worktree '$worktree'" + return 1 + } + git -C "$remote_dir" symbolic-ref refs/remotes/origin/HEAD "$ref" 2>/dev/null || { + FM_PROJECT_BASE_ERROR="could not record origin's current default branch for task worktree '$worktree'" + return 1 + } + commit=$(git -C "$worktree" rev-parse --verify --quiet "$ref^{commit}" 2>/dev/null) || { + FM_PROJECT_BASE_ERROR="'origin/$default' is not a commit for task worktree '$worktree'" + return 1 + } + FM_PROJECT_BASE_KIND=origin + fi + + FM_PROJECT_BASE_BRANCH=$default + FM_PROJECT_BASE_REF=$ref + FM_PROJECT_BASE_COMMIT=$commit +} + # If the git checkout at is tangled - on a NAMED branch that is not its # default branch - echo the offending branch name and return 0. For every healthy # state (not a git work tree, detached HEAD, or already on the default branch) diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index ad9e042ba11..9d3d42cf3e3 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -189,6 +189,8 @@ SUB_HOME_PARENT_MARKER=".fm-secondmate-parent" . "$SCRIPT_DIR/fm-pending-reply-lib.sh" # shellcheck source=bin/fm-nm-run-lib.sh . "$SCRIPT_DIR/fm-nm-run-lib.sh" +# shellcheck source=bin/fm-tangle-lib.sh +. "$SCRIPT_DIR/fm-tangle-lib.sh" if [ "$#" -lt 1 ] || ! fm_task_id_path_safe "$1"; then echo "error: invalid teardown request" >&2 exit 2 @@ -886,22 +888,6 @@ elif [ "$FORCE" != "--force" ] && fm_pf_relay_active "$FM_HOME"; then PUBLIC_FOLLOWUP_RELAY_ACTIVE=1 fi -default_branch() { - local ref branch - ref=$(git -C "$PROJ" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true) - if [ -n "$ref" ]; then - echo "${ref#origin/}" - return 0 - fi - for branch in main master; do - if git -C "$PROJ" show-ref --verify --quiet "refs/heads/$branch"; then - echo "$branch" - return 0 - fi - done - return 1 -} - meta_value() { local meta=$1 key=$2 fm_meta_get "$meta" "$key" @@ -1127,16 +1113,13 @@ pr_is_merged() { # "added". Returns non-zero when inconclusive (no default ref, or a merge conflict), # so the caller refuses rather than guesses. content_in_default() { - local name ref default_tree merged_tree - name=$(default_branch) || return 1 - if git -C "$WT" remote get-url origin >/dev/null 2>&1; then - git -C "$WT" fetch --quiet origin "+refs/heads/$name:refs/remotes/origin/$name" >/dev/null 2>&1 || return 1 - ref="refs/remotes/origin/$name" - elif git -C "$WT" rev-parse --quiet --verify "refs/heads/$name" >/dev/null 2>&1; then - ref="refs/heads/$name" - else - return 1 + local allow_remoteless=no require_task_origin=yes ref default_tree merged_tree + if [ "$MODE" = local-only ]; then + allow_remoteless=yes + require_task_origin=no fi + fm_project_base_resolve "$PROJ" "$WT" "$allow_remoteless" "$require_task_origin" || return 1 + ref=$FM_PROJECT_BASE_COMMIT default_tree=$(git -C "$WT" rev-parse --quiet --verify "$ref^{tree}" 2>/dev/null) || return 1 [ -n "$default_tree" ] || return 1 merged_tree=$(git -C "$WT" merge-tree --write-tree "$ref" HEAD 2>/dev/null) || return 1 @@ -1412,7 +1395,7 @@ teardown_treehouse_return() { } validate_worktree_teardown_safety() { - local dirty_raw dirty unpushed_raw unpushed DEFAULT unmerged_raw unmerged branch + local dirty_raw dirty unpushed_raw unpushed DEFAULT default_commit unmerged_raw unmerged branch [ -d "$WT" ] || return 0 [ "$FORCE" != "--force" ] || return 0 case "$KIND" in @@ -1440,8 +1423,16 @@ validate_worktree_teardown_safety() { unpushed=$(printf '%s\n' "$unpushed_raw" | head -5) if [ -n "$unpushed" ] && [ "$MODE" = local-only ]; then - DEFAULT=$(default_branch) || { echo "REFUSED: cannot determine default branch for $PROJ; expected origin/HEAD, main, or master." >&2; return 1; } - if ! unmerged_raw=$(git -C "$WT" log --oneline HEAD --not "$DEFAULT" -- 2>/dev/null); then + if ! fm_project_base_resolve "$PROJ" "$WT" yes no; then + echo "REFUSED: $FM_PROJECT_BASE_ERROR." >&2 + return 1 + fi + DEFAULT=$FM_PROJECT_BASE_BRANCH + default_commit=$(git -C "$PROJ" rev-parse --verify --quiet "refs/heads/$DEFAULT^{commit}" 2>/dev/null) || { + echo "REFUSED: local default branch $DEFAULT does not resolve in $PROJ." >&2 + return 1 + } + if ! unmerged_raw=$(git -C "$WT" log --oneline HEAD --not "$default_commit" -- 2>/dev/null); then if worktree_safety_blocked_by_lock "commits not on $DEFAULT"; then return "$TEARDOWN_WORKTREE_SAFETY_LOCK_BLOCKED" fi diff --git a/tests/fm-review-diff.test.sh b/tests/fm-review-diff.test.sh index 2193772b9d9..8c1475e6977 100755 --- a/tests/fm-review-diff.test.sh +++ b/tests/fm-review-diff.test.sh @@ -8,8 +8,9 @@ # (a) pr= + reachable pr_head=, no remote pull ref -> offline fallback to recorded SHA # (b) pr= without pr_head= -> fetch refs/pull//head and diff that # (c) pr= absent -> unchanged worktree-branch diff -# (d) pr= present but PR head unreachable -> fallback to local branch + warning -# (e) pr= + STALE recorded pr_head= + newer remote pull head -> must use fetched head +# (d) PR-backed task with no origin -> refuse the unverified base +# (e) remote-less local-only task with stale origin/HEAD -> compare against local main +# (f) pr= + STALE recorded pr_head= + newer remote pull head -> must use fetched head # (this is the class that bit reviewers holding merges over "missing" fixes) set -u @@ -71,6 +72,7 @@ run_review_diff() { local case_dir=$1 shift FM_ROOT_OVERRIDE="$ROOT" \ + FM_HOME="$case_dir/home" \ FM_STATE_OVERRIDE="$case_dir/state" \ "$REVIEW_DIFF" "$@" } @@ -148,29 +150,62 @@ test_no_pr_meta_uses_local_branch() { pass "fm-review-diff without pr= keeps the worktree-branch diff" } -test_unreachable_pr_head_falls_back_with_warning() { - local case_dir out err - case_dir=$(make_case fetch-fallback) +test_pr_backed_review_refuses_without_origin() { + local case_dir out status + case_dir=$(make_case fetch-refusal) stale_and_pr_commits "$case_dir" git -C "$case_dir/wt" remote remove origin write_task_meta "$case_dir" \ + "kind=ship" \ + "mode=no-mistakes" \ "pr=https://github.com/example/repo/pull/9" \ "pr_head=deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" - set +e - out=$(run_review_diff "$case_dir" task-x1 2> "$case_dir/stderr") - set -e - err=$(cat "$case_dir/stderr") - - assert_contains "$err" 'warning: PR head unavailable; diff may lag the open PR' \ - "fetch-fallback: must warn when PR head cannot be resolved" - assert_contains "$out" '+stale-local' "fetch-fallback: should fall back to the local branch diff" - assert_not_contains "$out" '+pr-fixed' "fetch-fallback: must not invent a PR head diff offline" - pass "fm-review-diff falls back to local branch with a warning when PR head is unreachable" + out=$(run_review_diff "$case_dir" task-x1 2>&1) + status=$? + [ "$status" -ne 0 ] || fail "PR-backed review fell back to local history without origin" + assert_contains "$out" 'requires a valid origin' \ + "PR-backed review did not explain its missing-origin refusal" + pass "fm-review-diff refuses a PR-backed review without valid origin" +} + +test_remoteless_review_ignores_stale_origin_head() { + local case_dir out initial + case_dir="$TMP_ROOT/remoteless-stale-default" + mkdir -p "$case_dir/state" "$case_dir/home/data" + printf '%s\n' '- project [local-only] - fixture (added 2026-01-01)' \ + > "$case_dir/home/data/projects.md" + git init -q -b main "$case_dir/project" + printf 'base\n' > "$case_dir/project/feature.txt" + git -C "$case_dir/project" add feature.txt + git -C "$case_dir/project" commit -qm baseline + initial=$(git -C "$case_dir/project" rev-parse HEAD) + git -C "$case_dir/project" branch trunk "$initial" + git -C "$case_dir/project" update-ref refs/remotes/origin/trunk "$initial" + git -C "$case_dir/project" symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/trunk + printf 'current main\n' > "$case_dir/project/main.txt" + git -C "$case_dir/project" add main.txt + git -C "$case_dir/project" commit -qm main-advance + git -C "$case_dir/project" worktree add -q -b fm/task-x1 "$case_dir/wt" main + printf 'local change\n' > "$case_dir/wt/feature.txt" + git -C "$case_dir/wt" add feature.txt + git -C "$case_dir/wt" commit -qm local-change + write_task_meta "$case_dir" "kind=ship" "mode=local-only" + + out=$(run_review_diff "$case_dir" task-x1 2> "$case_dir/stderr") \ + || fail "remote-less review failed: $(cat "$case_dir/stderr")" + assert_contains "$out" 'diff base: main' \ + "remote-less review selected stale origin/HEAD instead of main" + assert_contains "$out" '+local change' \ + "remote-less review omitted the task change" + assert_not_contains "$out" '+current main' \ + "remote-less review compared against stale trunk" + pass "fm-review-diff uses the local default when stale remote refs remain" } test_pr_meta_uses_pr_head_not_stale_local test_pr_meta_fetches_pull_head_without_recorded_sha test_stale_recorded_pr_head_loses_to_fetched_pull_head test_no_pr_meta_uses_local_branch -test_unreachable_pr_head_falls_back_with_warning +test_pr_backed_review_refuses_without_origin +test_remoteless_review_ignores_stale_origin_head diff --git a/tests/fm-task-delivery.test.sh b/tests/fm-task-delivery.test.sh index 194b4db6fa8..e4e44c6965e 100755 --- a/tests/fm-task-delivery.test.sh +++ b/tests/fm-task-delivery.test.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Behavior tests for the explicit per-task delivery contract (AGENTS.md section 7) -# across bin/fm-spawn.sh, bin/fm-promote.sh, and bin/fm-project-mode.sh. +# across spawn, promotion, local landing, and project-mode resolution. # # A ship task's delivery mode and yolo posture are firstmate's decision at intake, # so the tools refuse to guess: the spawn and a scout promotion require both flags, @@ -21,6 +21,7 @@ SPAWN="$ROOT/bin/fm-spawn.sh" BRIEF="$ROOT/bin/fm-brief.sh" PROMOTE="$ROOT/bin/fm-promote.sh" PROJECT_MODE="$ROOT/bin/fm-project-mode.sh" +MERGE_LOCAL="$ROOT/bin/fm-merge-local.sh" TMP_ROOT=$(fm_test_tmproot fm-task-delivery) # A home with one registered project, one project directory, and a fake tmux that @@ -402,14 +403,83 @@ test_promote_requires_origin_for_pr_backed_contracts() { "PR-backed promotion did not verify that origin was fetchable" assert_grep 'kind=scout' "$meta" "failing-origin refusal changed the scout contract" + out=$(FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" \ + "$PROMOTE" promote-origin-d2 --mode local-only --yolo off 2>&1) + status=$? + [ "$status" -ne 0 ] || fail "local-only promotion treated a failing configured origin as remote-less" + assert_contains "$out" "could not fetch origin" \ + "local-only promotion bypassed validation of its configured origin" + assert_grep 'kind=scout' "$meta" "local-only failing-origin refusal changed the scout contract" + + git clone --quiet --bare "$project" "$TMP_ROOT/promote-origin/unresolved.git" + git -C "$TMP_ROOT/promote-origin/unresolved.git" symbolic-ref HEAD refs/heads/missing-default + git -C "$project" remote set-url origin "file://$TMP_ROOT/promote-origin/unresolved.git" + out=$(FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" \ + "$PROMOTE" promote-origin-d2 --mode no-mistakes --yolo off 2>&1) + status=$? + [ "$status" -ne 0 ] || fail "promotion accepted an origin whose HEAD names no branch" + assert_contains "$out" "could not resolve origin's current default branch" \ + "PR-backed promotion did not validate origin's advertised default branch" + assert_grep 'kind=scout' "$meta" "unresolved-default refusal changed the scout contract" + git -C "$project" remote remove origin + git -C "$project" config extensions.worktreeConfig true + git -C "$project" config --worktree remote.backup.url "file://$TMP_ROOT/promote-origin/backup.git" + git -C "$project" config --worktree remote.backup.fetch '+refs/heads/*:refs/remotes/backup/*' + [ "$(git -C "$project" remote)" = backup ] \ + || fail "authoritative-project-only remote fixture was not configured" + [ -z "$(git -C "$worktree" remote)" ] \ + || fail "authoritative-project-only remote leaked into the task worktree" + out=$(FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" \ + "$PROMOTE" promote-origin-d2 --mode local-only --yolo off 2>&1) + status=$? + [ "$status" -ne 0 ] || fail "local-only promotion used fallback without proving the project had no remotes" + assert_contains "$out" "configured remotes but no origin" \ + "local-only promotion did not inspect the authoritative project's remotes" + assert_grep 'kind=scout' "$meta" "project-remote refusal changed the scout contract" + git -C "$project" config --worktree --remove-section remote.backup + out=$(FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" \ "$PROMOTE" promote-origin-d2 --mode local-only --yolo off 2>&1) status=$? expect_code 0 "$status" "local-only promotion should not require origin" assert_grep 'kind=ship' "$meta" "remote-less local-only promotion did not restore ship protection" assert_grep 'mode=local-only' "$meta" "remote-less local-only promotion did not record its contract" - pass "fm-promote: only PR-backed promotions require a fetchable origin" + pass "fm-promote: every configured origin is validated and remote-less fallback proves both repositories" +} + +test_local_landing_ignores_stale_remote_tracking_default() { + local home project worktree meta out initial + home="$TMP_ROOT/merge-local/home" + project="$TMP_ROOT/merge-local/project" + worktree="$TMP_ROOT/merge-local/worktree" + mkdir -p "$home/state" + 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 + initial=$(git -C "$project" rev-parse HEAD) + git -C "$project" branch trunk "$initial" + git -C "$project" update-ref refs/remotes/origin/trunk "$initial" + git -C "$project" symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/trunk + git -C "$project" worktree add --quiet -b fm/merge-local-e1 "$worktree" main + printf 'landed locally\n' > "$worktree/local.txt" + git -C "$worktree" add local.txt + git -C "$worktree" -c user.name='Firstmate Tests' -c user.email='tests@example.invalid' commit -qm local-change + meta="$home/state/merge-local-e1.meta" + printf 'window=fm-merge-local-e1\nkind=ship\nmode=local-only\nworktree=%s\nproject=%s\n' \ + "$worktree" "$project" > "$meta" + + out=$(FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" \ + "$MERGE_LOCAL" merge-local-e1 2>&1) \ + || fail "remote-less local landing failed: $out" + assert_contains "$out" "into local main" \ + "local landing selected the stale remote-tracking default" + assert_grep 'landed locally' "$project/local.txt" \ + "local landing did not fast-forward main" + [ "$(git -C "$project" rev-parse trunk)" = "$initial" ] \ + || fail "local landing moved stale trunk" + pass "fm-merge-local: remote-less landing ignores stale remote-tracking defaults" } # The registry parser survives for the mechanical consumers only. It accepts the @@ -454,5 +524,6 @@ test_scout_records_no_delivery_posture test_promote_requires_and_records_the_delivery_contract test_promotion_delivers_the_real_definition_of_done test_promote_requires_origin_for_pr_backed_contracts +test_local_landing_ignores_stale_remote_tracking_default test_project_mode_maps_the_conditional_policy echo "# all fm-task-delivery tests passed" diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index fe0131ce479..1723b440a2a 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -664,6 +664,30 @@ test_local_only_merged_to_local_main_allows() { pass "local-only worktree with work merged into local main is torn down (no regression)" } +test_remoteless_local_cleanup_ignores_stale_origin_head() { + local case_dir rc wt_head initial + case_dir=$(make_case remoteless-stale-default) + write_meta "$case_dir" local-only ship + initial=$(git -C "$case_dir/project" rev-parse main) + wt_commit "$case_dir" "merged remote-less work" + wt_head=$(git -C "$case_dir/wt" rev-parse HEAD) + git -C "$case_dir/project" remote remove origin + git -C "$case_dir/project" branch trunk "$initial" + git -C "$case_dir/project" update-ref refs/remotes/origin/trunk "$initial" + git -C "$case_dir/project" symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/trunk + git -C "$case_dir/project" update-ref refs/heads/main "$wt_head" + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 0 "$rc" "remote-less stale-default: cleanup should recognize work landed on local main" + ! grep -q REFUSED "$case_dir/stderr" \ + || fail "remote-less stale-default: teardown selected stale origin/HEAD" + pass "local-only teardown ignores stale remote-tracking defaults without remotes" +} + test_no_mistakes_origin_remote_allows() { local case_dir rc case_dir=$(make_case nm-origin) @@ -2610,6 +2634,7 @@ test_teardown_closes_the_backlog_item_itself test_teardown_manual_backend_leaves_the_backlog_to_the_operator test_local_only_truly_unpushed_refuses test_local_only_merged_to_local_main_allows +test_remoteless_local_cleanup_ignores_stale_origin_head test_no_mistakes_origin_remote_allows test_no_mistakes_truly_unpushed_refuses test_local_only_force_overrides_unpushed From 1fa7c3f32431accae6df624a31298b14264fcd13 Mon Sep 17 00:00:00 2001 From: Sam Sherpa Date: Fri, 28 Aug 2026 12:16:13 -0700 Subject: [PATCH 04/12] no-mistakes(review): Gate remote-less lifecycle and protect cleanup --- bin/fm-project-mode.sh | 6 ++--- bin/fm-promote.sh | 2 +- bin/fm-review-diff.sh | 9 ++++--- bin/fm-tangle-lib.sh | 22 +++++++++++++++- bin/fm-teardown.sh | 47 +++++++++++++++++++--------------- docs/architecture.md | 2 +- docs/scripts.md | 2 +- tests/fm-review-diff.test.sh | 27 ++++++++++++++++++- tests/fm-task-delivery.test.sh | 16 ++++++++++-- tests/fm-teardown.test.sh | 29 ++++++++++++++++++++- 10 files changed, 126 insertions(+), 36 deletions(-) diff --git a/bin/fm-project-mode.sh b/bin/fm-project-mode.sh index d25bf4fefd8..37b8d69ae18 100755 --- a/bin/fm-project-mode.sh +++ b/bin/fm-project-mode.sh @@ -8,9 +8,9 @@ # yolo are resolved by firstmate at intake and passed explicitly to # bin/fm-brief.sh, bin/fm-spawn.sh, and bin/fm-promote.sh (AGENTS.md section 7). # The consumers are bin/fm-fleet-sync.sh (skip local-only clones), -# bin/fm-home-seed.sh (refuse local-only seeding, run no-mistakes init), -# bin/fm-spawn.sh (registered-local-only fresh-base eligibility and the advisory -# registry-deviation notice), and bin/fm-review-diff.sh (the same eligibility). +# bin/fm-home-seed.sh (refuse local-only seeding, run no-mistakes init), and the +# shared lifecycle base resolver (registered-local-only eligibility for spawn, +# promotion, review, local landing, and cleanup). # # Registry line format (data/projects.md): # - - (added ) -> no-mistakes off (legacy default) diff --git a/bin/fm-promote.sh b/bin/fm-promote.sh index f27132325a2..a75b59056d1 100755 --- a/bin/fm-promote.sh +++ b/bin/fm-promote.sh @@ -14,7 +14,7 @@ # contract is decided: --mode and --yolo are REQUIRED and written into the meta # alongside the kind= flip. Firstmate resolves both at promotion time, having just # read the scout's report (AGENTS.md section 7); data/projects.md holds the -# captain's standing posture as context, and this script never looks it up. +# captain's standing posture as context and gates remote-less local-only eligibility. # no-mistakes-prod-only is a registry policy rather than a task mode and is refused. # Usage: fm-promote.sh --mode --yolo set -eu diff --git a/bin/fm-review-diff.sh b/bin/fm-review-diff.sh index e6bde739f44..37c13061b30 100755 --- a/bin/fm-review-diff.sh +++ b/bin/fm-review-diff.sh @@ -56,11 +56,12 @@ TASK_KIND=$(grep '^kind=' "$META" | tail -1 | cut -d= -f2- || true) TASK_MODE=$(grep '^mode=' "$META" | tail -1 | cut -d= -f2- || true) ALLOW_REMOTELESS=no if [ "$TASK_KIND" = scout ] || [ "$TASK_MODE" = local-only ]; then - PROJECT_POSTURE=$("$FM_ROOT/bin/fm-project-mode.sh" --raw "$(basename "$PROJ")" 2>/dev/null | cut -d' ' -f1) || PROJECT_POSTURE= - [ "$PROJECT_POSTURE" != local-only ] || ALLOW_REMOTELESS=yes + ALLOW_REMOTELESS=yes fi -REQUIRE_TASK_ORIGIN=yes -[ "$TASK_MODE" != local-only ] || REQUIRE_TASK_ORIGIN=no +REQUIRE_TASK_ORIGIN=no +case "$TASK_MODE" in + no-mistakes|direct-PR) REQUIRE_TASK_ORIGIN=yes ;; +esac if ! fm_project_base_resolve "$PROJ" "$WT" "$ALLOW_REMOTELESS" "$REQUIRE_TASK_ORIGIN"; then echo "error: $FM_PROJECT_BASE_ERROR; refusing to review against an unverified base" >&2 exit 1 diff --git a/bin/fm-tangle-lib.sh b/bin/fm-tangle-lib.sh index 79b20ab6ce1..2e700da0515 100644 --- a/bin/fm-tangle-lib.sh +++ b/bin/fm-tangle-lib.sh @@ -51,9 +51,21 @@ FM_PROJECT_BASE_REF= FM_PROJECT_BASE_COMMIT= FM_PROJECT_BASE_ERROR= +fm_registered_project_posture() { + local project=$1 script_dir line posture + script_dir=$(CDPATH='' cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd) || return 1 + line=$("$script_dir/fm-project-mode.sh" --raw "$(basename "$project")" 2>/dev/null) || return 1 + posture=${line%% *} + case "$posture" in + no-mistakes|direct-PR|local-only|no-mistakes-prod-only) ;; + *) return 1 ;; + esac + printf '%s\n' "$posture" +} + fm_project_base_resolve() { local project=$1 worktree=$2 allow_remoteless=${3:-no} require_task_origin=${4:-no} - local project_remotes worktree_remotes remote_dir remote_head default ref commit + local project_remotes worktree_remotes remote_dir remote_head default ref commit posture FM_PROJECT_BASE_KIND= FM_PROJECT_BASE_BRANCH= FM_PROJECT_BASE_REF= @@ -74,6 +86,14 @@ fm_project_base_resolve() { FM_PROJECT_BASE_ERROR="task worktree '$worktree' has no origin remote; this lifecycle requires a valid origin" return 1 fi + posture=$(fm_registered_project_posture "$project") || { + FM_PROJECT_BASE_ERROR="could not resolve the registered posture for project '$(basename "$project")'" + return 1 + } + if [ "$posture" != local-only ]; then + FM_PROJECT_BASE_ERROR="registered $posture project '$(basename "$project")' requires a valid origin" + return 1 + fi default=$(fm_local_default_branch "$project") || { FM_PROJECT_BASE_ERROR="could not determine the local default branch for remote-less project '$project'" return 1 diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 9d3d42cf3e3..320d83ba0e4 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -1422,33 +1422,38 @@ validate_worktree_teardown_safety() { fi unpushed=$(printf '%s\n' "$unpushed_raw" | head -5) - if [ -n "$unpushed" ] && [ "$MODE" = local-only ]; then + if [ "$MODE" = local-only ]; then if ! fm_project_base_resolve "$PROJ" "$WT" yes no; then echo "REFUSED: $FM_PROJECT_BASE_ERROR." >&2 return 1 fi - DEFAULT=$FM_PROJECT_BASE_BRANCH - default_commit=$(git -C "$PROJ" rev-parse --verify --quiet "refs/heads/$DEFAULT^{commit}" 2>/dev/null) || { - echo "REFUSED: local default branch $DEFAULT does not resolve in $PROJ." >&2 - return 1 - } - if ! unmerged_raw=$(git -C "$WT" log --oneline HEAD --not "$default_commit" -- 2>/dev/null); then - if worktree_safety_blocked_by_lock "commits not on $DEFAULT"; then - return "$TEARDOWN_WORKTREE_SAFETY_LOCK_BLOCKED" + if [ "$FM_PROJECT_BASE_KIND" = local ] || [ -n "$unpushed" ]; then + DEFAULT=$FM_PROJECT_BASE_BRANCH + default_commit=$(git -C "$PROJ" rev-parse --verify --quiet "refs/heads/$DEFAULT^{commit}" 2>/dev/null) || { + echo "REFUSED: local default branch $DEFAULT does not resolve in $PROJ." >&2 + return 1 + } + if ! unmerged_raw=$(git -C "$WT" log --oneline HEAD --not "$default_commit" -- 2>/dev/null); then + if worktree_safety_blocked_by_lock "commits not on $DEFAULT"; then + return "$TEARDOWN_WORKTREE_SAFETY_LOCK_BLOCKED" + fi + echo "REFUSED: cannot inspect worktree $WT for commits not on $DEFAULT." >&2 + echo "Restore the git index state, or get the captain's explicit OK to discard, then --force." >&2 + return 1 fi - echo "REFUSED: cannot inspect worktree $WT for commits not on $DEFAULT." >&2 - echo "Restore the git index state, or get the captain's explicit OK to discard, then --force." >&2 - return 1 - fi - unmerged=$(printf '%s\n' "$unmerged_raw" | head -5) - if [ -n "$dirty" ] || [ -n "$unmerged" ]; then - echo "REFUSED: local-only worktree $WT has work not yet merged into $DEFAULT and not on any remote." >&2 - [ -n "$dirty" ] && echo "uncommitted changes present" >&2 - [ -n "$unmerged" ] && printf 'commits not yet on %s:\n%s\n' "$DEFAULT" "$unmerged" >&2 - echo "Merge the branch into local $DEFAULT first (bin/fm-merge-local.sh after the captain approves), or push to a fork/remote, or get the captain's explicit OK to discard, then --force." >&2 - return 1 + unmerged=$(printf '%s\n' "$unmerged_raw" | head -5) + if [ -n "$dirty" ] || [ -n "$unmerged" ]; then + echo "REFUSED: local-only worktree $WT has work not yet merged into $DEFAULT and not on any remote." >&2 + [ -n "$dirty" ] && echo "uncommitted changes present" >&2 + [ -n "$unmerged" ] && printf 'commits not yet on %s:\n%s\n' "$DEFAULT" "$unmerged" >&2 + echo "Merge the branch into local $DEFAULT first (bin/fm-merge-local.sh after the captain approves), or push to a fork/remote, or get the captain's explicit OK to discard, then --force." >&2 + return 1 + fi + return 0 fi - elif [ -n "$dirty" ]; then + fi + + if [ -n "$dirty" ]; then echo "REFUSED: worktree $WT has uncommitted changes." >&2 echo "uncommitted changes present" >&2 echo "Commit them (or get the captain's explicit OK to discard, then --force)." >&2 diff --git a/docs/architecture.md b/docs/architecture.md index 05996a415cb..3b13dea221b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -273,7 +273,7 @@ The mode is passed explicitly to `bin/fm-brief.sh`, and both values are passed e A ship brief records its mode as a fixed machine-readable line and the spawn refuses to launch on a different one, so the worker's instructions and the recorded task delivery cannot diverge. `bin/fm-dod-lib.sh` is the one owner of that mode's definition of done, rendered both into a generated ship brief and into the ship instructions a promoted scout receives, so a promoted worker cannot be handed a weaker contract than a briefed one. `data/projects.md` records each project's standing posture and optional `+yolo` merge flag as the captain's default and as context for that decision, including the conditional `no-mistakes-prod-only` policy; a ship spawn that drops below the registered rigor prints a deviation notice and continues. -`bin/fm-project-mode.sh` remains the one registry parser for fleet sync's `local-only` skip, home seeding's refusal and no-mistakes initialization, and spawn's registered-local-only fresh-base eligibility and advisory. +`bin/fm-project-mode.sh` remains the one registry parser for fleet sync, home seeding, delivery advisories, and registered-local-only eligibility across spawn, promotion, review, local landing, and cleanup. When a selected delivery path calls for a diff, `bin/fm-review-diff.sh` refreshes the authoritative base and, when task meta records `pr=`, always fetches and compares against `refs/pull//head` by default (recorded `pr_head=` is only an offline fallback) before falling back to the local branch with a warning. Where a no-mistakes pipeline stores evidence in the repo, it publishes that PR-viewable validation evidence to an orphan evidence branch that shares no history with code branches, so it never enters the crew branch or the default branch. This repo uses that setting, and its own `.no-mistakes/` directory remains local state that stays gitignored and is rejected by CI if tracked; [`configuration.md`](configuration.md) owns the setting. diff --git a/docs/scripts.md b/docs/scripts.md index 9e5c6d1e59e..f7f4e8d646e 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -64,7 +64,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `backends/orca.sh` | Experimental Orca backend adapter owning both worktree and terminal | | `backends/cmux.sh` | Experimental cmux session-provider adapter | | `fm-config-push.sh` | Push declared inherited local material to live local or remote secondmates and send the placement-specific config reread when changed | -| `fm-project-mode.sh` | Resolve a project's registered delivery posture for fleet sync, home seeding, and spawn eligibility/advisories | +| `fm-project-mode.sh` | Resolve a project's registered delivery posture for fleet sync, home seeding, lifecycle eligibility, and advisories | | `fm-merge-local.sh` | Fast-forward a `local-only` project's local default branch after approval | | `fm-review-diff.sh` | Review a crewmate branch or resolved PR head against the authoritative base | | `fm-marker-lib.sh` | Compatibility entry point for the from-firstmate carrier owned by `fm-operational-input.sh` | diff --git a/tests/fm-review-diff.test.sh b/tests/fm-review-diff.test.sh index 8c1475e6977..f1b794adef6 100755 --- a/tests/fm-review-diff.test.sh +++ b/tests/fm-review-diff.test.sh @@ -10,7 +10,8 @@ # (c) pr= absent -> unchanged worktree-branch diff # (d) PR-backed task with no origin -> refuse the unverified base # (e) remote-less local-only task with stale origin/HEAD -> compare against local main -# (f) pr= + STALE recorded pr_head= + newer remote pull head -> must use fetched head +# (f) scout with origin only in the authoritative checkout -> review from that origin +# (g) pr= + STALE recorded pr_head= + newer remote pull head -> must use fetched head # (this is the class that bit reviewers holding merges over "missing" fixes) set -u @@ -203,9 +204,33 @@ test_remoteless_review_ignores_stale_origin_head() { pass "fm-review-diff uses the local default when stale remote refs remain" } +test_scout_review_uses_authoritative_project_origin() { + local case_dir out origin_url + case_dir=$(make_case scout-project-origin) + printf 'scout change\n' > "$case_dir/wt/feature.txt" + git -C "$case_dir/wt" add feature.txt + git -C "$case_dir/wt" commit -qm scout-change + origin_url=$(git -C "$case_dir/project" remote get-url origin) + git -C "$case_dir/project" remote remove origin + git -C "$case_dir/project" config extensions.worktreeConfig true + git -C "$case_dir/project" config --worktree remote.origin.url "$origin_url" + [ "$(git -C "$case_dir/project" remote)" = origin ] \ + || fail "scout fixture did not retain origin in the authoritative project" + [ -z "$(git -C "$case_dir/wt" remote)" ] \ + || fail "scout fixture leaked project-only origin into the task worktree" + write_task_meta "$case_dir" "kind=scout" + + out=$(run_review_diff "$case_dir" task-x1 2> "$case_dir/stderr") \ + || fail "scout review rejected the authoritative project origin: $(cat "$case_dir/stderr")" + assert_contains "$out" '+scout change' \ + "scout review did not compare the local branch from the authoritative origin base" + pass "fm-review-diff lets scouts use the authoritative project origin" +} + test_pr_meta_uses_pr_head_not_stale_local test_pr_meta_fetches_pull_head_without_recorded_sha test_stale_recorded_pr_head_loses_to_fetched_pull_head test_no_pr_meta_uses_local_branch test_pr_backed_review_refuses_without_origin test_remoteless_review_ignores_stale_origin_head +test_scout_review_uses_authoritative_project_origin diff --git a/tests/fm-task-delivery.test.sh b/tests/fm-task-delivery.test.sh index e4e44c6965e..a85a83bbd02 100755 --- a/tests/fm-task-delivery.test.sh +++ b/tests/fm-task-delivery.test.sh @@ -374,7 +374,8 @@ test_promote_requires_origin_for_pr_backed_contracts() { home="$TMP_ROOT/promote-origin/home" project="$TMP_ROOT/promote-origin/project" worktree="$TMP_ROOT/promote-origin/worktree" - mkdir -p "$home/state" + mkdir -p "$home/state" "$home/data" + printf '%s\n' '- project [local-only] - fixture (added 2026-01-01)' > "$home/data/projects.md" git init --quiet -b main "$project" printf 'base\n' > "$project/README.md" git -C "$project" add README.md @@ -439,6 +440,16 @@ test_promote_requires_origin_for_pr_backed_contracts() { assert_grep 'kind=scout' "$meta" "project-remote refusal changed the scout contract" git -C "$project" config --worktree --remove-section remote.backup + printf '%s\n' '- project [no-mistakes] - fixture (added 2026-01-01)' > "$home/data/projects.md" + out=$(FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" \ + "$PROMOTE" promote-origin-d2 --mode local-only --yolo off 2>&1) + status=$? + [ "$status" -ne 0 ] || fail "no-mistakes project promoted through the remote-less local-only path" + assert_contains "$out" "registered no-mistakes project 'project' requires a valid origin" \ + "promotion did not gate remote-less fallback on the registered project posture" + assert_grep 'kind=scout' "$meta" "posture-gated refusal changed the scout contract" + + printf '%s\n' '- project [local-only] - fixture (added 2026-01-01)' > "$home/data/projects.md" out=$(FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" \ "$PROMOTE" promote-origin-d2 --mode local-only --yolo off 2>&1) status=$? @@ -453,7 +464,8 @@ test_local_landing_ignores_stale_remote_tracking_default() { home="$TMP_ROOT/merge-local/home" project="$TMP_ROOT/merge-local/project" worktree="$TMP_ROOT/merge-local/worktree" - mkdir -p "$home/state" + mkdir -p "$home/state" "$home/data" + printf '%s\n' '- project [local-only] - fixture (added 2026-01-01)' > "$home/data/projects.md" git init --quiet -b main "$project" printf 'base\n' > "$project/README.md" git -C "$project" add README.md diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index 1723b440a2a..8a3c2de4522 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -676,9 +676,11 @@ test_remoteless_local_cleanup_ignores_stale_origin_head() { git -C "$case_dir/project" update-ref refs/remotes/origin/trunk "$initial" git -C "$case_dir/project" symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/trunk git -C "$case_dir/project" update-ref refs/heads/main "$wt_head" + mkdir -p "$case_dir/home/data" + printf '%s\n' '- project [local-only] - fixture (added 2026-01-01)' > "$case_dir/home/data/projects.md" set +e - run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + FM_HOME="$case_dir/home" run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" rc=$? set -e @@ -688,6 +690,30 @@ test_remoteless_local_cleanup_ignores_stale_origin_head() { pass "local-only teardown ignores stale remote-tracking defaults without remotes" } +test_remoteless_local_cleanup_refuses_stale_task_ref() { + local case_dir rc wt_head + case_dir=$(make_case remoteless-stale-task-ref) + write_meta "$case_dir" local-only ship + wt_commit "$case_dir" "unlanded remote-less work" + wt_head=$(git -C "$case_dir/wt" rev-parse HEAD) + git -C "$case_dir/project" remote remove origin + git -C "$case_dir/project" update-ref refs/remotes/origin/fm/task-x1 "$wt_head" + mkdir -p "$case_dir/home/data" + printf '%s\n' '- project [local-only] - fixture (added 2026-01-01)' > "$case_dir/home/data/projects.md" + + set +e + FM_HOME="$case_dir/home" run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "remote-less stale-task-ref: cleanup should refuse unlanded work" + grep -q 'not yet merged into main' "$case_dir/stderr" \ + || fail "remote-less stale-task-ref: teardown trusted a stale remote-tracking task ref" + assert_present "$case_dir/wt" \ + "remote-less stale-task-ref: teardown removed the worktree containing unlanded work" + pass "local-only teardown distrusts stale task refs without remotes" +} + test_no_mistakes_origin_remote_allows() { local case_dir rc case_dir=$(make_case nm-origin) @@ -2635,6 +2661,7 @@ test_teardown_manual_backend_leaves_the_backlog_to_the_operator test_local_only_truly_unpushed_refuses test_local_only_merged_to_local_main_allows test_remoteless_local_cleanup_ignores_stale_origin_head +test_remoteless_local_cleanup_refuses_stale_task_ref test_no_mistakes_origin_remote_allows test_no_mistakes_truly_unpushed_refuses test_local_only_force_overrides_unpushed From 7403d6e0987602c233e8fa8e17fda43346246b61 Mon Sep 17 00:00:00 2001 From: Sam Sherpa Date: Fri, 28 Aug 2026 12:34:16 -0700 Subject: [PATCH 05/12] no-mistakes(review): Fix promotion fixtures and preserve fork-backed cleanup --- bin/fm-teardown.sh | 25 ++++++++++++++++---- tests/fm-public-followup.test.sh | 40 ++++++++++++++++++++++++++++---- tests/fm-teardown.test.sh | 20 ++++++++++++++++ 3 files changed, 77 insertions(+), 8 deletions(-) diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 320d83ba0e4..2ec79148a69 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -1396,6 +1396,7 @@ teardown_treehouse_return() { validate_worktree_teardown_safety() { local dirty_raw dirty unpushed_raw unpushed DEFAULT default_commit unmerged_raw unmerged branch + local project_remotes worktree_remotes require_local_check [ -d "$WT" ] || return 0 [ "$FORCE" != "--force" ] || return 0 case "$KIND" in @@ -1423,11 +1424,27 @@ validate_worktree_teardown_safety() { unpushed=$(printf '%s\n' "$unpushed_raw" | head -5) if [ "$MODE" = local-only ]; then - if ! fm_project_base_resolve "$PROJ" "$WT" yes no; then - echo "REFUSED: $FM_PROJECT_BASE_ERROR." >&2 - return 1 + require_local_check=0 + if [ -n "$unpushed" ]; then + require_local_check=1 + else + project_remotes=$(git -C "$PROJ" remote 2>/dev/null) || { + echo "REFUSED: cannot inspect configured remotes for authoritative project $PROJ." >&2 + return 1 + } + worktree_remotes=$(git -C "$WT" remote 2>/dev/null) || { + echo "REFUSED: cannot inspect configured remotes for task worktree $WT." >&2 + return 1 + } + if [ -z "$project_remotes" ] && [ -z "$worktree_remotes" ]; then + require_local_check=1 + fi fi - if [ "$FM_PROJECT_BASE_KIND" = local ] || [ -n "$unpushed" ]; then + if [ "$require_local_check" = 1 ]; then + if ! fm_project_base_resolve "$PROJ" "$WT" yes no; then + echo "REFUSED: $FM_PROJECT_BASE_ERROR." >&2 + return 1 + fi DEFAULT=$FM_PROJECT_BASE_BRANCH default_commit=$(git -C "$PROJ" rev-parse --verify --quiet "refs/heads/$DEFAULT^{commit}" 2>/dev/null) || { echo "REFUSED: local default branch $DEFAULT does not resolve in $PROJ." >&2 diff --git a/tests/fm-public-followup.test.sh b/tests/fm-public-followup.test.sh index 20cf1c3783d..57610e62754 100755 --- a/tests/fm-public-followup.test.sh +++ b/tests/fm-public-followup.test.sh @@ -93,6 +93,22 @@ EOF printf '%s\n' "$home" } +register_local_only_project() { + local home=$1 project=$2 + printf -- '- %s [local-only] - fixture (added 2026-01-01)\n' \ + "$(basename "$project")" > "$home/data/projects.md" +} + +make_local_only_project() { + local home=$1 name=$2 project="$1/projects/$2" worktree="$1/projects/$2-worktree" + git init --quiet -b main "$project" + git -C "$project" -c user.name='Firstmate Tests' -c user.email='tests@example.invalid' \ + commit --quiet --allow-empty -m fixture + git -C "$project" worktree add --quiet --detach "$worktree" HEAD + register_local_only_project "$home" "$project" + printf '%s|%s\n' "$project" "$worktree" +} + run_pf() { # local home=$1 shift @@ -824,6 +840,7 @@ test_secondmate_teardown_durable_record_with_unknown_field_succeeds() { ln -s "$parent" "$parent_alias" fm_write_meta "$parent/state/mate.meta" "kind=secondmate" "home=$child" fm_git_init_commit "$child/projects/worktree" + register_local_only_project "$child" "$child/projects/worktree" printf 'manual\n' > "$child/config/backlog-backend" fm_write_meta "$child/state/work-clean.meta" \ "window=firstmate:fm-work-clean" "endpoint_task_id=work-clean" \ @@ -856,6 +873,7 @@ test_secondmate_teardown_rejects_conflicting_live_and_durable_parent_bindings() assert_local_secondmate_parent_record "$child" "$parent_resolved" fm_write_meta "$durable_parent/state/mate.meta" "kind=secondmate" "home=$child" fm_git_init_commit "$child/projects/worktree" + register_local_only_project "$child" "$child/projects/worktree" printf 'manual\n' > "$child/config/backlog-backend" fm_write_meta "$child/state/work-conflict.meta" \ "window=firstmate:fm-work-conflict" "endpoint_task_id=work-conflict" \ @@ -950,6 +968,7 @@ test_secondmate_teardown_rejects_nul_bearing_durable_parent_record() { assert_local_secondmate_parent_record "$child" "$parent_resolved" fm_write_meta "$parent/state/mate.meta" "kind=secondmate" "home=$child" fm_git_init_commit "$child/projects/worktree" + register_local_only_project "$child" "$child/projects/worktree" printf 'manual\n' > "$child/config/backlog-backend" fm_write_meta "$child/state/work-child.meta" \ "window=firstmate:fm-work-child" "endpoint_task_id=work-child" \ @@ -981,6 +1000,7 @@ test_relay_disabled_unmarked_teardown_skips_public_path() { local home tasks_log out rc home=$(make_home teardown-disabled-unmarked relay-off) fm_git_init_commit "$home/projects/worktree" + register_local_only_project "$home" "$home/projects/worktree" tasks_log="$home/tasks-axi.log"; : > "$tasks_log" printf 'manual\n' > "$home/config/backlog-backend" cat > "$home/fakebin/tasks-axi" <<'SH' @@ -1013,6 +1033,7 @@ test_relay_disabled_parent_allows_marked_child_teardown() { parent=$(make_home teardown-disabled-parent relay-off) child=$(make_home teardown-disabled-child relay-off) fm_git_init_commit "$child/projects/worktree" + register_local_only_project "$child" "$child/projects/worktree" printf '%s\n' disabled-mate > "$child/.fm-secondmate-home" printf -- '- disabled-mate - synthetic (home: %s; scope: synthetic; projects: ; added 2026-07-30)\n' \ "$child" > "$parent/data/secondmates.md" @@ -2157,10 +2178,14 @@ test_x_request_teardown_warns_when_final_unposted() { } test_secondmate_promotion_uses_teardown_parent_resolution() { - local parent stale child remote_child out + local parent stale child remote_child out target project worktree parent=$(make_home promote-parent) stale=$(make_home promote-stale-parent) child=$(make_home promote-child relay-off) + target=$(make_local_only_project "$child" promote-local) + IFS='|' read -r project worktree < "$child/.fm-secondmate-home" printf 'schema=fm-secondmate-parent.v1\nroute=local\nparent_home=%s\n' \ "$stale" > "$child/.fm-secondmate-parent" @@ -2176,7 +2201,8 @@ test_secondmate_promotion_uses_teardown_parent_resolution() { "$stale/state/public-followup/registry/pf-stale" fm_write_meta "$child/state/promote-conflict.meta" \ - "window=firstmate:fm-promote-conflict" "kind=scout" + "window=firstmate:fm-promote-conflict" "kind=scout" \ + "worktree=$worktree" "project=$project" out=$(PATH="$child/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$child" \ FM_STATE_OVERRIDE="$child/state" FM_PUBLIC_FOLLOWUP_PRIMARY_HOME="$parent" \ "$PROMOTE" promote-conflict --mode local-only --yolo off 2>&1) \ @@ -2190,7 +2216,8 @@ test_secondmate_promotion_uses_teardown_parent_resolution() { rm -f "$child/.fm-secondmate-parent" fm_write_meta "$child/state/promote-legacy.meta" \ - "window=firstmate:fm-promote-legacy" "kind=scout" + "window=firstmate:fm-promote-legacy" "kind=scout" \ + "worktree=$worktree" "project=$project" out=$(PATH="$child/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$child" \ FM_STATE_OVERRIDE="$child/state" FM_PUBLIC_FOLLOWUP_PRIMARY_HOME="$parent" \ "$PROMOTE" promote-legacy --mode local-only --yolo off 2>&1) \ @@ -2201,12 +2228,17 @@ test_secondmate_promotion_uses_teardown_parent_resolution() { "legacy parent recovery must print the rechain hint" remote_child=$(make_home promote-remote-child relay-off) + target=$(make_local_only_project "$remote_child" promote-local) + IFS='|' read -r project worktree < "$remote_child/.fm-secondmate-home" printf 'schema=fm-secondmate-parent.v1\nroute=remote\nparent_host=remote.example\n' \ > "$remote_child/.fm-secondmate-parent" printf 'FMX_PAIRING_TOKEN=child-local-token\n' > "$remote_child/.env" fm_write_meta "$remote_child/state/promote-remote.meta" \ - "window=firstmate:fm-promote-remote" "kind=scout" + "window=firstmate:fm-promote-remote" "kind=scout" \ + "worktree=$worktree" "project=$project" out=$(PATH="$remote_child/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$remote_child" \ FM_STATE_OVERRIDE="$remote_child/state" \ "$PROMOTE" promote-remote --mode local-only --yolo off 2>&1) \ diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index 8a3c2de4522..d015cd2d8a9 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -585,6 +585,25 @@ test_local_only_fork_remote_allows() { pass "local-only worktree with HEAD on a fork remote is torn down and the home summary is refreshed" } +test_remoteless_local_only_fork_remote_allows() { + local case_dir rc + case_dir=$(make_case remoteless-fork-allow) + write_meta "$case_dir" local-only ship + wt_commit "$case_dir" "fix the thing" + git -C "$case_dir/project" remote remove origin + add_fork_with_pushed_branch "$case_dir" + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 0 "$rc" "remote-less fork-allow: teardown should accept remote containment without origin" + ! grep -q REFUSED "$case_dir/stderr" \ + || fail "remote-less fork-allow: teardown required origin despite fork containment" + pass "local-only teardown preserves fork containment without origin" +} + test_teardown_closes_the_backlog_item_itself() { local case_dir out case_dir=$(make_case tasks-axi-close) @@ -2656,6 +2675,7 @@ EOF } test_local_only_fork_remote_allows +test_remoteless_local_only_fork_remote_allows test_teardown_closes_the_backlog_item_itself test_teardown_manual_backend_leaves_the_backlog_to_the_operator test_local_only_truly_unpushed_refuses From 204c007554ed694002ba5de6fe853d022a9fbbcb Mon Sep 17 00:00:00 2001 From: Sam Sherpa Date: Fri, 28 Aug 2026 12:43:40 -0700 Subject: [PATCH 06/12] no-mistakes(review): Default legacy reviews to PR-backed origin validation --- bin/fm-review-diff.sh | 9 +++++---- tests/fm-review-diff.test.sh | 30 ++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/bin/fm-review-diff.sh b/bin/fm-review-diff.sh index 37c13061b30..29fce2e2d22 100755 --- a/bin/fm-review-diff.sh +++ b/bin/fm-review-diff.sh @@ -54,14 +54,15 @@ PROJ=$(grep '^project=' "$META" | cut -d= -f2-) TASK_KIND=$(grep '^kind=' "$META" | tail -1 | cut -d= -f2- || true) [ -n "$TASK_KIND" ] || TASK_KIND=ship TASK_MODE=$(grep '^mode=' "$META" | tail -1 | cut -d= -f2- || true) +if [ -z "$TASK_MODE" ] && [ "$TASK_KIND" != scout ]; then + TASK_MODE=no-mistakes +fi ALLOW_REMOTELESS=no +REQUIRE_TASK_ORIGIN=yes if [ "$TASK_KIND" = scout ] || [ "$TASK_MODE" = local-only ]; then ALLOW_REMOTELESS=yes + REQUIRE_TASK_ORIGIN=no fi -REQUIRE_TASK_ORIGIN=no -case "$TASK_MODE" in - no-mistakes|direct-PR) REQUIRE_TASK_ORIGIN=yes ;; -esac if ! fm_project_base_resolve "$PROJ" "$WT" "$ALLOW_REMOTELESS" "$REQUIRE_TASK_ORIGIN"; then echo "error: $FM_PROJECT_BASE_ERROR; refusing to review against an unverified base" >&2 exit 1 diff --git a/tests/fm-review-diff.test.sh b/tests/fm-review-diff.test.sh index f1b794adef6..a75174deffa 100755 --- a/tests/fm-review-diff.test.sh +++ b/tests/fm-review-diff.test.sh @@ -13,6 +13,7 @@ # (f) scout with origin only in the authoritative checkout -> review from that origin # (g) pr= + STALE recorded pr_head= + newer remote pull head -> must use fetched head # (this is the class that bit reviewers holding merges over "missing" fixes) +# (h) legacy ship without mode and without task origin -> refuse stale PR evidence set -u # shellcheck source=tests/lib.sh @@ -170,6 +171,34 @@ test_pr_backed_review_refuses_without_origin() { pass "fm-review-diff refuses a PR-backed review without valid origin" } +test_legacy_pr_review_requires_task_origin() { + local case_dir out status origin_url stale_sha + case_dir=$(make_case legacy-project-origin) + stale_and_pr_commits "$case_dir" + stale_sha=$(git -C "$case_dir/wt" rev-parse fm/task-x1) + origin_url=$(git -C "$case_dir/project" remote get-url origin) + git -C "$case_dir/project" remote remove origin + git -C "$case_dir/project" config extensions.worktreeConfig true + git -C "$case_dir/project" config --worktree remote.origin.url "$origin_url" + [ "$(git -C "$case_dir/project" remote)" = origin ] \ + || fail "legacy fixture did not retain origin in the authoritative project" + [ -z "$(git -C "$case_dir/wt" remote)" ] \ + || fail "legacy fixture leaked project-only origin into the task worktree" + write_task_meta "$case_dir" \ + "kind=ship" \ + "pr=https://github.com/example/repo/pull/9" \ + "pr_head=$stale_sha" + + out=$(run_review_diff "$case_dir" task-x1 2>&1) + status=$? + [ "$status" -ne 0 ] || fail "legacy no-mistakes review accepted stale PR evidence without task origin" + assert_contains "$out" 'PR-backed task contract requires origin' \ + "legacy no-mistakes review did not require task origin" + assert_not_contains "$out" '+stale-local' \ + "legacy no-mistakes review displayed stale recorded PR evidence" + pass "fm-review-diff defaults legacy ships to the no-mistakes origin contract" +} + test_remoteless_review_ignores_stale_origin_head() { local case_dir out initial case_dir="$TMP_ROOT/remoteless-stale-default" @@ -232,5 +261,6 @@ test_pr_meta_fetches_pull_head_without_recorded_sha test_stale_recorded_pr_head_loses_to_fetched_pull_head test_no_pr_meta_uses_local_branch test_pr_backed_review_refuses_without_origin +test_legacy_pr_review_requires_task_origin test_remoteless_review_ignores_stale_origin_head test_scout_review_uses_authoritative_project_origin From e57355c81cbb107133f4e29e1d904d10ebc1c72e Mon Sep 17 00:00:00 2001 From: Sam Sherpa Date: Fri, 28 Aug 2026 12:58:05 -0700 Subject: [PATCH 07/12] no-mistakes(review): Enforce fresh-base landing and configured-remote cleanup safety --- bin/fm-merge-local.sh | 6 +++++ bin/fm-teardown.sh | 48 ++++++++++++++++++++-------------- tests/fm-task-delivery.test.sh | 46 ++++++++++++++++++++++++++++++++ tests/fm-teardown.test.sh | 27 +++++++++++++++++++ 4 files changed, 108 insertions(+), 19 deletions(-) diff --git a/bin/fm-merge-local.sh b/bin/fm-merge-local.sh index 505e4f7a355..accc4231f3d 100755 --- a/bin/fm-merge-local.sh +++ b/bin/fm-merge-local.sh @@ -45,6 +45,12 @@ if ! fm_project_base_resolve "$PROJ" "$WT" yes no; then fi DEFAULT=$FM_PROJECT_BASE_BRANCH +if ! git -C "$PROJ" merge-base --is-ancestor "$FM_PROJECT_BASE_COMMIT" "$BRANCH"; then + echo "REFUSED: $BRANCH does not contain the current resolved base $FM_PROJECT_BASE_REF." >&2 + echo "Have the crewmate rebase $BRANCH onto $FM_PROJECT_BASE_REF, then retry." >&2 + exit 1 +fi + # The project's main checkout must be on its default branch and clean, so the # fast-forward lands predictably (firstmate never writes here otherwise). cur=$(git -C "$PROJ" symbolic-ref --short HEAD 2>/dev/null || echo "") diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 2ec79148a69..0ee3490af47 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -1040,6 +1040,33 @@ patch_id_for_commit() { | awk 'NR == 1 { print $1 }' } +commits_not_on_configured_remotes() { + local format=$1 repo remotes remote refs ref has_exclusion=0 + local -a args + args=(log "$format" HEAD) + for repo in "$PROJ" "$WT"; do + remotes=$(git -C "$repo" remote 2>/dev/null) || return 1 + while IFS= read -r remote; do + [ -n "$remote" ] || continue + refs=$(git -C "$repo" for-each-ref --format='%(refname)' "refs/remotes/$remote/" 2>/dev/null) || return 1 + while IFS= read -r ref; do + [ -n "$ref" ] || continue + if [ "$has_exclusion" = 0 ]; then + args+=(--not) + has_exclusion=1 + fi + args+=("$ref") + done </dev/null +} + unpushed_patches_are_in_pr_head() { local pr_head=$1 current base pr_patch_ids commit patch_id unpushed current=$(git -C "$WT" rev-parse --verify HEAD 2>/dev/null) || return 1 @@ -1053,7 +1080,7 @@ unpushed_patches_are_in_pr_head() { | sort -u ) || return 1 [ -n "$pr_patch_ids" ] || return 1 - unpushed=$(git -C "$WT" log --format=%H HEAD --not --remotes -- 2>/dev/null) || return 1 + unpushed=$(commits_not_on_configured_remotes --format=%H) || return 1 [ -n "$unpushed" ] || return 1 while IFS= read -r commit; do [ -n "$commit" ] || continue @@ -1396,7 +1423,6 @@ teardown_treehouse_return() { validate_worktree_teardown_safety() { local dirty_raw dirty unpushed_raw unpushed DEFAULT default_commit unmerged_raw unmerged branch - local project_remotes worktree_remotes require_local_check [ -d "$WT" ] || return 0 [ "$FORCE" != "--force" ] || return 0 case "$KIND" in @@ -1413,7 +1439,7 @@ validate_worktree_teardown_safety() { fi dirty=$(printf '%s\n' "$dirty_raw" | grep -vE '^\?\? (\.claude/|\.fm-(grok|kimi)-turnend$)' | head -1 || true) - if ! unpushed_raw=$(git -C "$WT" log --oneline HEAD --not --remotes -- 2>/dev/null); then + if ! unpushed_raw=$(commits_not_on_configured_remotes --oneline); then if worktree_safety_blocked_by_lock "commits not on a remote"; then return "$TEARDOWN_WORKTREE_SAFETY_LOCK_BLOCKED" fi @@ -1424,23 +1450,7 @@ validate_worktree_teardown_safety() { unpushed=$(printf '%s\n' "$unpushed_raw" | head -5) if [ "$MODE" = local-only ]; then - require_local_check=0 if [ -n "$unpushed" ]; then - require_local_check=1 - else - project_remotes=$(git -C "$PROJ" remote 2>/dev/null) || { - echo "REFUSED: cannot inspect configured remotes for authoritative project $PROJ." >&2 - return 1 - } - worktree_remotes=$(git -C "$WT" remote 2>/dev/null) || { - echo "REFUSED: cannot inspect configured remotes for task worktree $WT." >&2 - return 1 - } - if [ -z "$project_remotes" ] && [ -z "$worktree_remotes" ]; then - require_local_check=1 - fi - fi - if [ "$require_local_check" = 1 ]; then if ! fm_project_base_resolve "$PROJ" "$WT" yes no; then echo "REFUSED: $FM_PROJECT_BASE_ERROR." >&2 return 1 diff --git a/tests/fm-task-delivery.test.sh b/tests/fm-task-delivery.test.sh index a85a83bbd02..4267cf3cfcd 100755 --- a/tests/fm-task-delivery.test.sh +++ b/tests/fm-task-delivery.test.sh @@ -494,6 +494,51 @@ test_local_landing_ignores_stale_remote_tracking_default() { pass "fm-merge-local: remote-less landing ignores stale remote-tracking defaults" } +test_local_landing_requires_current_resolved_base() { + local home project worktree origin updater meta out status initial + home="$TMP_ROOT/merge-local-current-base/home" + project="$TMP_ROOT/merge-local-current-base/project" + worktree="$TMP_ROOT/merge-local-current-base/worktree" + origin="$TMP_ROOT/merge-local-current-base/origin.git" + updater="$TMP_ROOT/merge-local-current-base/updater" + mkdir -p "$home/state" "$home/data" + printf '%s\n' '- project [local-only] - fixture (added 2026-01-01)' > "$home/data/projects.md" + 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 + initial=$(git -C "$project" rev-parse HEAD) + git -C "$project" worktree add --quiet -b fm/merge-local-e2 "$worktree" main + printf 'task change\n' > "$worktree/task.txt" + git -C "$worktree" add task.txt + git -C "$worktree" -c user.name='Firstmate Tests' -c user.email='tests@example.invalid' commit -qm task-change + git clone --quiet --bare "$project" "$origin" + git -C "$origin" symbolic-ref HEAD refs/heads/main + git -C "$project" remote add origin "file://$origin" + git clone --quiet "file://$origin" "$updater" + printf 'upstream change\n' > "$updater/upstream.txt" + git -C "$updater" add upstream.txt + git -C "$updater" -c user.name='Firstmate Tests' -c user.email='tests@example.invalid' commit -qm upstream-change + git -C "$updater" push --quiet origin main + meta="$home/state/merge-local-e2.meta" + printf 'window=fm-merge-local-e2\nkind=ship\nmode=local-only\nworktree=%s\nproject=%s\n' \ + "$worktree" "$project" > "$meta" + + set +e + out=$(FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" \ + "$MERGE_LOCAL" merge-local-e2 2>&1) + status=$? + set -e + + expect_code 1 "$status" "local landing should refuse a task behind the resolved origin base" + assert_contains "$out" "does not contain the current resolved base refs/remotes/origin/main" \ + "local landing did not explain its stale resolved base" + [ "$(git -C "$project" rev-parse main)" = "$initial" ] \ + || fail "stale-base refusal moved local main" + [ ! -e "$project/task.txt" ] || fail "stale-base refusal landed the task change" + pass "fm-merge-local: landing requires the current resolved origin base" +} + # The registry parser survives for the mechanical consumers only. It accepts the # conditional policy, maps it to its most rigorous leg for them, and exposes the # raw annotation for the one caller that must tell a policy from a flat mode. @@ -537,5 +582,6 @@ test_promote_requires_and_records_the_delivery_contract test_promotion_delivers_the_real_definition_of_done test_promote_requires_origin_for_pr_backed_contracts test_local_landing_ignores_stale_remote_tracking_default +test_local_landing_requires_current_resolved_base test_project_mode_maps_the_conditional_policy echo "# all fm-task-delivery tests passed" diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index d015cd2d8a9..fe4f2d410ae 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -733,6 +733,32 @@ test_remoteless_local_cleanup_refuses_stale_task_ref() { pass "local-only teardown distrusts stale task refs without remotes" } +test_local_cleanup_refuses_stale_ref_from_removed_remote() { + local case_dir rc wt_head + case_dir=$(make_case stale-removed-origin-ref) + write_meta "$case_dir" local-only ship + wt_commit "$case_dir" "unlanded work hidden by stale origin ref" + wt_head=$(git -C "$case_dir/wt" rev-parse HEAD) + git -C "$case_dir/project" update-ref refs/remotes/origin/fm/task-x1 "$wt_head" + git -C "$case_dir/project" remote remove origin + git init -q --bare "$case_dir/fork.git" + git -C "$case_dir/project" remote add fork "$case_dir/fork.git" + mkdir -p "$case_dir/home/data" + printf '%s\n' '- project [local-only] - fixture (added 2026-01-01)' > "$case_dir/home/data/projects.md" + + set +e + FM_HOME="$case_dir/home" run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "stale removed-origin ref should not authorize cleanup" + grep -q 'configured remotes but no origin' "$case_dir/stderr" \ + || fail "cleanup treated a stale removed-origin ref as current fork containment" + assert_present "$case_dir/wt" \ + "cleanup removed unlanded work hidden by a stale ref from a removed remote" + pass "local-only teardown trusts only currently configured remote refs" +} + test_no_mistakes_origin_remote_allows() { local case_dir rc case_dir=$(make_case nm-origin) @@ -2682,6 +2708,7 @@ test_local_only_truly_unpushed_refuses test_local_only_merged_to_local_main_allows test_remoteless_local_cleanup_ignores_stale_origin_head test_remoteless_local_cleanup_refuses_stale_task_ref +test_local_cleanup_refuses_stale_ref_from_removed_remote test_no_mistakes_origin_remote_allows test_no_mistakes_truly_unpushed_refuses test_local_only_force_overrides_unpushed From 7a99be3aad89dbd2d0380f9887d6f78004eaad6c Mon Sep 17 00:00:00 2001 From: Sam Sherpa Date: Fri, 28 Aug 2026 13:08:47 -0700 Subject: [PATCH 08/12] no-mistakes(review): Verify remote containment before teardown cleanup --- bin/fm-teardown.sh | 20 +++++++++++--------- tests/fm-teardown.test.sh | 26 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 0ee3490af47..4b579aaa921 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -21,9 +21,10 @@ # the paths that already proceed to remove the record. # REFUSES if the worktree holds work that has not LANDED, because cleanup # hard-resets/removes the worktree and kills its processes. Work has landed when it is -# reachable from any remote-tracking branch (a fork counts as a remote, so -# upstream-contribution PRs pushed to a fork satisfy this in any mode), OR - for a -# normal ship task whose commits are not so reachable - when its PR is merged and +# reachable from a branch currently advertised by any configured remote (a fork +# counts as a remote, so upstream-contribution PRs pushed to a fork satisfy this in +# any mode), OR - for a normal ship task whose commits are not so reachable - when +# its PR is merged and # GitHub reports a PR head that contains the current local work, or its content is # already present in the up-to-date default branch. This recognizes the common # squash-merge-then-delete-branch flow, where the branch's own commits live nowhere @@ -1041,23 +1042,24 @@ patch_id_for_commit() { } commits_not_on_configured_remotes() { - local format=$1 repo remotes remote refs ref has_exclusion=0 + local format=$1 repo remotes remote advertised oid ref commit has_exclusion=0 local -a args args=(log "$format" HEAD) for repo in "$PROJ" "$WT"; do remotes=$(git -C "$repo" remote 2>/dev/null) || return 1 while IFS= read -r remote; do [ -n "$remote" ] || continue - refs=$(git -C "$repo" for-each-ref --format='%(refname)' "refs/remotes/$remote/" 2>/dev/null) || return 1 - while IFS= read -r ref; do - [ -n "$ref" ] || continue + advertised=$(git -C "$repo" ls-remote --heads "$remote" 2>/dev/null) || continue + while read -r oid ref; do + [ -n "$oid" ] && [ -n "$ref" ] || continue + commit=$(git -C "$WT" rev-parse --verify --quiet "$oid^{commit}" 2>/dev/null) || continue if [ "$has_exclusion" = 0 ]; then args+=(--not) has_exclusion=1 fi - args+=("$ref") + args+=("$commit") done <