From 804bb3dc30074dae8b997e1a79f6a07694727100 Mon Sep 17 00:00:00 2001 From: Alex William Date: Tue, 8 Sep 2026 17:06:42 +0200 Subject: [PATCH 1/6] fix: sync projects on registered integration branches --- bin/fm-fleet-sync.sh | 71 ++++++++++++++------ bin/fm-project-mode.sh | 67 ++++++++++++++----- docs/architecture.md | 7 +- tests/fm-fleet-sync.test.sh | 130 ++++++++++++++++++++++++++++++++++++ 4 files changed, 233 insertions(+), 42 deletions(-) diff --git a/bin/fm-fleet-sync.sh b/bin/fm-fleet-sync.sh index dd00be86baa..a6c94f66dda 100755 --- a/bin/fm-fleet-sync.sh +++ b/bin/fm-fleet-sync.sh @@ -1,10 +1,11 @@ #!/usr/bin/env bash -# Refresh project clones: fast-forward the checked-out local default branch to -# origin/ when safe, and prune local branches whose upstream tracking +# Refresh project clones: fast-forward the checked-out registered integration +# branch, or the remote default branch for legacy registry entries, to its +# origin/ when safe, and prune local branches whose upstream tracking # branch is gone (the remote branch was deleted, i.e. its PR merged) and that no # worktree still needs. # Self-heals the one unambiguously safe drift: a clean, detached HEAD that holds -# no unique commits (it is an ancestor of origin/) and whose +# no unique commits (it is an ancestor of origin/) and whose integration # branch is free to check out is re-attached and then fast-forwarded ("recovered:"). # Every other off-default state - a non-default named branch, a detached HEAD with # unique commits, a dirty tree, or a diverged default - may hold real work, so it @@ -156,9 +157,10 @@ packed_refs_lock_path() { esac } -# Run `git -C "$PROJ" fetch origin --prune --quiet`, tolerating an orphaned -# packed-refs.lock left by a killed ref rewrite. Sets FETCH_OUTPUT to the git -# command's combined output and returns its exit status. On the packed-refs.lock +# Run the selected branch fetch, or the legacy all-branch fetch when no +# integration branch is declared, tolerating an orphaned packed-refs.lock left +# by a killed ref rewrite. Sets FETCH_OUTPUT to the git command's combined output +# and returns its exit status. On the packed-refs.lock # signature ONLY: retry up to FLEET_SYNC_PACKED_REFS_LOCK_RETRIES times (a # transient lock self-clears as the owning process exits), then - only if the lock # is provably stale per fm-lock-lib.sh (still present, mtime age past the @@ -167,9 +169,17 @@ packed_refs_lock_path() { # today's behavior. Every wait, retry, and removal prints to stderr, and a # successful recovery also prints one "$label: recovered: ..." summary to stdout so # a session-start refresh (which discards fleet-sync stderr) still surfaces it. +fetch_origin() { + if [ "${INTEGRATION_DECLARED:-no}" = yes ]; then + git -C "$PROJ" fetch origin "$DEFAULT" --quiet + else + git -C "$PROJ" fetch origin --prune --quiet + fi +} + fetch_with_packed_refs_lock_guard() { local rc attempt=0 lock lock_desc - FETCH_OUTPUT=$(git -C "$PROJ" fetch origin --prune --quiet 2>&1); rc=$? + FETCH_OUTPUT=$(fetch_origin 2>&1); rc=$? [ "$rc" -eq 0 ] && return 0 is_packed_refs_lock_error "$FETCH_OUTPUT" || return "$rc" @@ -179,7 +189,7 @@ fetch_with_packed_refs_lock_guard() { attempt=$(( attempt + 1 )) echo "$label: fetch blocked by packed-refs lock ($lock_desc); waiting ${FLEET_SYNC_PACKED_REFS_LOCK_RETRY_WAIT_SECS}s and retrying ($attempt/${FLEET_SYNC_PACKED_REFS_LOCK_RETRIES}) (owning process may be exiting)" >&2 sleep "$FLEET_SYNC_PACKED_REFS_LOCK_RETRY_WAIT_SECS" - FETCH_OUTPUT=$(git -C "$PROJ" fetch origin --prune --quiet 2>&1); rc=$? + FETCH_OUTPUT=$(fetch_origin 2>&1); rc=$? if [ "$rc" -eq 0 ]; then echo "$label: fetch succeeded on retry; packed-refs lock cleared on its own" >&2 # One stdout summary so a session-start refresh (which discards fleet-sync @@ -203,7 +213,7 @@ fetch_with_packed_refs_lock_guard() { return "$rc" fi echo "$label: removed provably-stale packed-refs lock $lock (age >= ${FLEET_SYNC_PACKED_REFS_LOCK_AGE_SECS}s, no live holder) and retrying fetch" >&2 - FETCH_OUTPUT=$(git -C "$PROJ" fetch origin --prune --quiet 2>&1); rc=$? + FETCH_OUTPUT=$(fetch_origin 2>&1); rc=$? if [ "$rc" -eq 0 ]; then echo "$label: fetch succeeded after stale packed-refs lock cleanup" >&2 echo "$label: recovered: removed a stale packed-refs lock (no live holder)" @@ -289,8 +299,8 @@ stuck_state() { } # Loud, quantified report for a clone we deliberately leave untouched. Includes -# how far behind origin/ it is, so a chronically-stuck clone is visibly -# distinct from a benign one-off skip. +# how far behind the selected origin/ it is, so a chronically-stuck clone +# is visibly distinct from a benign one-off skip. report_stuck() { local state=$1 behind behind=$(git -C "$PROJ" rev-list --count "HEAD..$BASE" 2>/dev/null) || behind="?" @@ -335,8 +345,23 @@ sync_project() { return 0 fi + # The registry's structured integration branch is authoritative when present. + # Legacy entries deliberately retain the previous remote-default resolution. + integration_branch=$("$FM_ROOT/bin/fm-project-mode.sh" --integration-branch "$label" 2>/dev/null || true) + if [ -n "$integration_branch" ]; then + DEFAULT=$integration_branch + INTEGRATION_DECLARED=yes + else + INTEGRATION_DECLARED=no + DEFAULT=$(default_branch) || { + echo "$label: skipped: cannot determine default branch" + return 0 + } + fi + BASE="origin/$DEFAULT" + if ! fetch_with_packed_refs_lock_guard; then - reason="fetch failed" + reason="fetch failed for $BASE" if [ -n "$FETCH_OUTPUT" ]; then reason="$reason: $(first_line "$FETCH_OUTPUT")" fi @@ -344,13 +369,15 @@ sync_project() { return 0 fi + if [ "${INTEGRATION_DECLARED:-no}" = yes ]; then + if ! prune_output=$(git -C "$PROJ" remote prune origin 2>&1); then + reason="remote ref pruning failed for $BASE" + [ -n "$prune_output" ] && reason="$reason: $(first_line "$prune_output")" + echo "$label: skipped: $reason" + return 0 + fi + fi prune_gone_branches || true - - DEFAULT=$(default_branch) || { - echo "$label: skipped: cannot determine default branch" - return 0 - } - BASE="origin/$DEFAULT" if ! git -C "$PROJ" rev-parse --verify --quiet "$BASE^{commit}" >/dev/null; then echo "$label: skipped: $BASE does not exist" return 0 @@ -405,9 +432,9 @@ sync_project() { } if [ "$local_rev" = "$remote_rev" ]; then if [ "$recovered" = yes ]; then - echo "$label: recovered: re-attached $DEFAULT (already current)" + echo "$label: recovered: re-attached $DEFAULT (already current at $BASE)" else - echo "$label: already current" + echo "$label: already current on $DEFAULT ($BASE)" fi return 0 fi @@ -433,9 +460,9 @@ sync_project() { return 0 } if [ "$recovered" = yes ]; then - echo "$label: recovered: re-attached $DEFAULT, synced $before..$after" + echo "$label: recovered: re-attached $DEFAULT, synced $before..$after to $BASE" else - echo "$label: synced $before..$after" + echo "$label: synced $before..$after to $BASE" fi return 0 } diff --git a/bin/fm-project-mode.sh b/bin/fm-project-mode.sh index 3046202f23f..c7e113bed56 100755 --- a/bin/fm-project-mode.sh +++ b/bin/fm-project-mode.sh @@ -7,14 +7,18 @@ # for this project", never "how does this task ship". A task's delivery mode and # 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-spawn.sh's advisory registry-deviation notice. +# The consumers are bin/fm-fleet-sync.sh (skip local-only clones and resolve their +# registered integration branch), bin/fm-home-seed.sh (refuse local-only seeding, +# run no-mistakes init), and bin/fm-spawn.sh's advisory registry-deviation notice. # # Registry line format (data/projects.md): -# - - (added ) -> no-mistakes off (legacy default) -# - [] - (added ) -> off -# - [ +yolo] - (added ) -> on +# - - (added ) -> no-mistakes off (legacy default) +# - [] - (added ) -> off +# - [ +yolo integration-branch=] - -> on +# +# `integration-branch=` is a structured annotation. It is intentionally +# not read from the free-form description. Omitted integration branches are +# reported as empty by --integration-branch so callers retain their old fallback. # # Registered modes: # no-mistakes full pipeline -> PR -> configured merge authority (default) @@ -34,7 +38,10 @@ # # An unknown/missing project or unknown mode falls back to "no-mistakes off" and warns # to stderr, so a typo never silently drops the gate. -# Usage: fm-project-mode.sh [--raw] +# --raw prints the registered mode annotation unmapped. +# --integration-branch prints the structured integration branch, or nothing when +# the registry uses the legacy format without one. +# Usage: fm-project-mode.sh [--raw|--integration-branch] set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -43,42 +50,66 @@ FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" REG="$DATA/projects.md" RAW=0 -if [ "${1:-}" = "--raw" ]; then - RAW=1 - shift -fi -NAME=${1:?usage: fm-project-mode.sh [--raw] } +QUERY=mode +case "${1:-}" in + --raw) RAW=1; shift ;; + --integration-branch) QUERY=integration-branch; shift ;; + '') ;; + -*) echo "usage: fm-project-mode.sh [--raw|--integration-branch] " >&2; exit 1 ;; + *) ;; +esac +NAME=${1:?usage: fm-project-mode.sh [--raw|--integration-branch] } if [ ! -f "$REG" ]; then + if [ "$QUERY" = integration-branch ]; then + exit 0 + fi echo "warn: no registry at $REG; defaulting $NAME to no-mistakes off" >&2 echo "no-mistakes off" exit 0 fi -# awk emits " " (one line) or nothing if the project is absent. +# awk emits " " (one line) or nothing if +# the project is absent. The third field is only consumed by the branch query. parsed=$(awk -v n="$NAME" ' $1=="-" && $2==n { - mode="no-mistakes"; yolo="off"; + mode="no-mistakes"; yolo="off"; integration=""; if ($3 ~ /^\[/) { s=""; for (i=3; i<=NF; i++) { s = s (s==""?"":" ") $i; if ($i ~ /\]$/) break } gsub(/^\[|\]$/, "", s); # strip the surrounding brackets k = split(s, a, " "); - if (a[1] != "" && a[1] != "+yolo") mode = a[1]; - for (j=1; j<=k; j++) if (a[j]=="+yolo") yolo="on"; + if (a[1] != "" && a[1] != "+yolo" && a[1] !~ /^integration-branch=/) mode = a[1]; + for (j=1; j<=k; j++) { + if (a[j]=="+yolo") yolo="on"; + if (a[j] ~ /^integration-branch=/) integration=substr(a[j], 20); + } } - print mode, yolo; exit + print mode, yolo, integration; exit } ' "$REG") if [ -z "$parsed" ]; then + if [ "$QUERY" = integration-branch ]; then + exit 0 + fi echo "warn: project \"$NAME\" not in registry; defaulting to no-mistakes off" >&2 echo "no-mistakes off" exit 0 fi mode=${parsed%% *} -yolo=${parsed##* } +yolo=${parsed#* } +yolo=${yolo%% *} +integration_branch=${parsed##* } +if [ "$QUERY" = integration-branch ]; then + if [ -n "$integration_branch" ] && ! git check-ref-format --branch "$integration_branch" >/dev/null 2>&1; then + echo "warn: invalid integration branch \"$integration_branch\" for $NAME; using the remote default branch" >&2 + exit 0 + fi + [ "$integration_branch" = "-" ] || printf '%s\n' "$integration_branch" + exit 0 +fi case "$mode" in no-mistakes|direct-PR|local-only|no-mistakes-prod-only) ;; *) echo "warn: unknown mode \"$mode\" for $NAME; defaulting to no-mistakes off" >&2; mode=no-mistakes; yolo=off ;; diff --git a/docs/architecture.md b/docs/architecture.md index 58b900786d4..8217c88f5cb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -389,8 +389,11 @@ Invoked in a primary home, `/stow` then cascades the same sweep to every registe The locked session-start deferred network stage, PR-based teardown, and merged-PR wake handling refresh remote-backed project clones when the clone is safe to move. Wake-time refreshes can target a single clone by project name, so the primary home also catches up when a secondmate reports a merge from its own home. -Clean default-branch clones fast-forward to `origin/`, and a clean detached HEAD that holds no unique commits is re-attached to the default branch before the same fast-forward path runs. -Dirty clones, non-default branches, detached HEADs with unique commits, diverged defaults, and default branches checked out in another worktree are reported as `STUCK:` with their behind count and left untouched. +A registry entry with `integration-branch=` makes that declared branch the sync target even when `origin/HEAD` names another default branch. +For that declaration, fleet sync fetches and compares only `origin/`, then fast-forwards the checked-out local `` when safe. +A legacy entry without the declaration retains remote-default resolution and its existing sync behavior. +Clean target-branch clones fast-forward to `origin/`, and a clean detached HEAD that holds no unique commits is re-attached to the target branch before the same fast-forward path runs. +Dirty clones, non-target branches, detached HEADs with unique commits, diverged targets, and target branches checked out in another worktree are reported as `STUCK:` with their behind count and left untouched. Fetches blocked by an orphaned `.git/packed-refs.lock` use bounded retries and remove the lock only when the shared staleness proof can prove it abandoned; [configuration.md](configuration.md#toolchain) owns the recovery details and tuning knobs. Local-only projects, clones without an origin remote, and fetch failures remain benign skips. The refresh also prunes local branches whose remote is gone and that no worktree still needs. diff --git a/tests/fm-fleet-sync.test.sh b/tests/fm-fleet-sync.test.sh index c2ea85ae361..0131a89f384 100755 --- a/tests/fm-fleet-sync.test.sh +++ b/tests/fm-fleet-sync.test.sh @@ -12,6 +12,11 @@ # The pre-existing fast-forward / already-current / local-only / no-origin paths # must be unchanged, and bootstrap must relay the new outcomes as FLEET_SYNC lines. # +# It also pins registered integration branches: a project may integrate on +# develop even when origin/HEAD is main, while legacy entries retain the remote +# default branch. Dirty, divergent, and fast-forward integration clones remain +# untouched or advance only on the declared branch. +# # It also pins the clone-root guard: a plain directory under projects/ resolves, # through git's upward repository discovery, to the ENCLOSING repository - in a # firstmate home, the firstmate checkout itself - so it must be skipped by name @@ -87,6 +92,45 @@ advance_origin() { git -C "$work" push -q origin main } +# build_integration_pair: origin/HEAD remains main, but the project declares and +# checks out develop as its integration branch. +build_integration_pair() { + local home=$1 name=$2 work remote clone remote_abs + work="$home/work-$name" + remote="$home/remotes/$name.git" + clone="$home/projects/$name" + mkdir -p "$home/remotes" + + git init -q "$work" + git -C "$work" symbolic-ref HEAD refs/heads/main + commit_file "$work" file.txt v0 C0 + git -C "$work" branch develop + git clone --quiet --bare "$work" "$remote" + remote_abs=$(cd "$remote" && pwd) + git -C "$work" remote add origin "file://$remote_abs" + git -C "$work" push -q -u origin main develop + + git clone --quiet "file://$remote_abs" "$clone" + git -C "$clone" branch --track develop origin/develop >/dev/null + git -C "$clone" checkout --quiet develop + printf '%s\n' "$clone" +} + +advance_integration_origin() { + local home=$1 name=$2 msg=$3 work + work="$home/work-$name" + git -C "$work" checkout --quiet develop + commit_file "$work" file.txt "$msg" "$msg" + git -C "$work" push -q origin develop +} + +declare_integration_branch() { + local home=$1 name=$2 + mkdir -p "$home/data" + printf -- '- %s [no-mistakes integration-branch=develop] - integration fixture (added 2026-09-01)\n' \ + "$name" > "$home/data/projects.md" +} + head_sha() { git -C "$1" rev-parse HEAD; } # run_sync [args...]: run fleet-sync against an isolated home, stdout only. @@ -236,6 +280,88 @@ run_sync_guarded() { # --- tests ------------------------------------------------------------------ +test_declared_integration_branch_overrides_remote_default() { + local home clone out main_remote_before + home=$(new_home) + clone=$(build_integration_pair "$home" integration) + declare_integration_branch "$home" integration + main_remote_before=$(git -C "$clone" rev-parse origin/main) + # Advance both refs so a sync that incorrectly follows origin/HEAD=main is + # observably wrong even if it happens to fetch the remote. + git -C "$home/work-integration" checkout --quiet main + commit_file "$home/work-integration" main.txt main1 "main1" + git -C "$home/work-integration" push -q origin main + advance_integration_origin "$home" integration develop1 + + out=$(run_sync "$home" integration) + + assert_contains "$out" "integration: synced" "declared integration branch did not sync" + assert_contains "$out" "origin/develop" "declared branch is absent from the outcome" + assert_not_contains "$out" "origin/main" "declared branch was evaluated against origin/main" + [ "$(git -C "$clone" rev-parse HEAD)" = "$(git -C "$clone" rev-parse origin/develop)" ] \ + || fail "declared integration branch did not advance to origin/develop" + [ "$(git -C "$clone" rev-parse HEAD)" != "$(git -C "$clone" rev-parse origin/main)" ] \ + || fail "fixture did not keep integration and remote-default refs distinct" + [ "$(git -C "$clone" rev-parse origin/main)" = "$main_remote_before" ] \ + || fail "declared integration fetch unexpectedly updated origin/main" + [ "$(git -C "$clone" symbolic-ref --short refs/remotes/origin/HEAD)" = "origin/main" ] \ + || fail "fixture origin/HEAD no longer points to main" + pass "declared integration branch governs fetch, comparison, and fast-forward despite origin/HEAD=main" +} + +test_legacy_registry_entry_uses_remote_default() { + local home clone out + home=$(new_home) + clone=$(build_pair "$home" legacy) + advance_origin "$home" legacy C1 + + out=$(run_sync "$home" legacy) + + assert_contains "$out" "legacy: synced" "legacy project did not sync" + assert_contains "$out" "origin/main" "legacy project did not name the remote default branch" + [ "$(git -C "$clone" rev-parse HEAD)" = "$(git -C "$clone" rev-parse origin/main)" ] \ + || fail "legacy project did not retain its remote-default behavior" + pass "project without an integration declaration safely retains remote-default sync" +} + +test_declared_integration_branch_dirty_is_stuck_untouched() { + local home clone out before + home=$(new_home) + clone=$(build_integration_pair "$home" dirty-integration) + declare_integration_branch "$home" dirty-integration + advance_integration_origin "$home" dirty-integration develop1 + before=$(head_sha "$clone") + printf 'uncommitted edit\n' >> "$clone/file.txt" + + out=$(run_sync "$home" dirty-integration) + + assert_contains "$out" "dirty-integration: STUCK:" "dirty declared branch did not report STUCK" + assert_contains "$out" "1 commits behind origin/develop" "dirty result did not name origin/develop" + assert_not_contains "$out" "origin/main" "dirty declared branch was evaluated against origin/main" + [ "$(head_sha "$clone")" = "$before" ] || fail "dirty declared branch HEAD was moved" + grep -q 'uncommitted edit' "$clone/file.txt" || fail "dirty declared branch edit was discarded" + pass "dirty declared integration clone is quantified and left untouched" +} + +test_declared_integration_branch_divergence_is_stuck_untouched() { + local home clone out before + home=$(new_home) + clone=$(build_integration_pair "$home" diverged-integration) + declare_integration_branch "$home" diverged-integration + commit_file "$clone" local.txt local "local divergent develop commit" + before=$(head_sha "$clone") + advance_integration_origin "$home" diverged-integration develop1 + + out=$(run_sync "$home" diverged-integration) + + assert_contains "$out" "diverged-integration: STUCK:" "diverged declared branch did not report STUCK" + assert_contains "$out" "diverged develop" "divergence did not name the declared branch" + assert_contains "$out" "origin/develop" "divergence did not name the evaluated remote branch" + assert_not_contains "$out" "origin/main" "divergence was evaluated against origin/main" + [ "$(head_sha "$clone")" = "$before" ] || fail "diverged declared branch was moved" + pass "diverged declared integration clone is reported and left untouched" +} + test_detached_clean_ancestor_recovers() { local home clone out before after home=$(new_home) @@ -694,6 +820,10 @@ test_non_signature_fetch_failure_is_not_retried() { pass "a non-packed-refs.lock fetch failure keeps today's behavior (no retry)" } +test_declared_integration_branch_overrides_remote_default +test_legacy_registry_entry_uses_remote_default +test_declared_integration_branch_dirty_is_stuck_untouched +test_declared_integration_branch_divergence_is_stuck_untouched test_detached_clean_ancestor_recovers test_detached_unique_commit_is_stuck_untouched test_detached_clean_ancestor_with_diverged_local_default_is_stuck_untouched From 12bd055c47832b3ea63c03906b3256dfc0659f69 Mon Sep 17 00:00:00 2001 From: Alex William Date: Tue, 8 Sep 2026 17:45:16 +0200 Subject: [PATCH 2/6] no-mistakes(review): use one integration-branch resolver for sync, seed, and spawn --- bin/fm-ff-lib.sh | 19 ++----- bin/fm-fleet-sync.sh | 67 ++++++------------------ bin/fm-home-seed.sh | 27 +++++++++- bin/fm-integration-branch-lib.sh | 57 ++++++++++++++++++++ bin/fm-project-mode.sh | 8 +-- bin/fm-remote-home-provision.sh | 18 ++++++- bin/fm-spawn.sh | 13 +++-- bin/fm-test-run.sh | 1 + docs/architecture.md | 6 +-- tests/fm-fleet-sync.test.sh | 5 +- tests/fm-remote-transport-lanes.test.sh | 1 + tests/fm-secondmate-safety.test.sh | 60 +++++++++++++++++++++ tests/fm-spawn-pool-base-freshen.test.sh | 56 ++++++++++++++++++++ tests/fm-task-delivery.test.sh | 52 ++++++++++++++++++ 14 files changed, 305 insertions(+), 85 deletions(-) create mode 100644 bin/fm-integration-branch-lib.sh diff --git a/bin/fm-ff-lib.sh b/bin/fm-ff-lib.sh index b099fa9a00c..a11af30f453 100644 --- a/bin/fm-ff-lib.sh +++ b/bin/fm-ff-lib.sh @@ -35,6 +35,9 @@ 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" +# default_branch and the integration-branch resolver every base-picking path shares. +# shellcheck source=bin/fm-integration-branch-lib.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/fm-integration-branch-lib.sh" # --- helpers --------------------------------------------------------------- @@ -42,22 +45,6 @@ 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 - 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 -} - # 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-fleet-sync.sh b/bin/fm-fleet-sync.sh index a6c94f66dda..e3ea36717bd 100755 --- a/bin/fm-fleet-sync.sh +++ b/bin/fm-fleet-sync.sh @@ -41,6 +41,9 @@ FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}" # shellcheck source=bin/fm-lock-lib.sh . "$SCRIPT_DIR/fm-lock-lib.sh" +# default_branch and the integration-branch resolver every base-picking path shares. +# shellcheck source=bin/fm-integration-branch-lib.sh +. "$SCRIPT_DIR/fm-integration-branch-lib.sh" # Inert unless FM_TIMING_LOG names a file; only the deferred network stage sets it. # shellcheck source=bin/fm-timing-lib.sh . "$SCRIPT_DIR/fm-timing-lib.sh" @@ -116,22 +119,6 @@ resolve_project_arg() { printf '%s\n' "$arg" } -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 -} - first_line() { printf '%s\n' "$1" | sed -n '1s/[[:space:]]\{1,\}/ /g;1p' } @@ -157,10 +144,9 @@ packed_refs_lock_path() { esac } -# Run the selected branch fetch, or the legacy all-branch fetch when no -# integration branch is declared, tolerating an orphaned packed-refs.lock left -# by a killed ref rewrite. Sets FETCH_OUTPUT to the git command's combined output -# and returns its exit status. On the packed-refs.lock +# Run `git -C "$PROJ" fetch origin --prune --quiet`, tolerating an orphaned +# packed-refs.lock left by a killed ref rewrite. Sets FETCH_OUTPUT to the git +# command's combined output and returns its exit status. On the packed-refs.lock # signature ONLY: retry up to FLEET_SYNC_PACKED_REFS_LOCK_RETRIES times (a # transient lock self-clears as the owning process exits), then - only if the lock # is provably stale per fm-lock-lib.sh (still present, mtime age past the @@ -169,17 +155,9 @@ packed_refs_lock_path() { # today's behavior. Every wait, retry, and removal prints to stderr, and a # successful recovery also prints one "$label: recovered: ..." summary to stdout so # a session-start refresh (which discards fleet-sync stderr) still surfaces it. -fetch_origin() { - if [ "${INTEGRATION_DECLARED:-no}" = yes ]; then - git -C "$PROJ" fetch origin "$DEFAULT" --quiet - else - git -C "$PROJ" fetch origin --prune --quiet - fi -} - fetch_with_packed_refs_lock_guard() { local rc attempt=0 lock lock_desc - FETCH_OUTPUT=$(fetch_origin 2>&1); rc=$? + FETCH_OUTPUT=$(git -C "$PROJ" fetch origin --prune --quiet 2>&1); rc=$? [ "$rc" -eq 0 ] && return 0 is_packed_refs_lock_error "$FETCH_OUTPUT" || return "$rc" @@ -189,7 +167,7 @@ fetch_with_packed_refs_lock_guard() { attempt=$(( attempt + 1 )) echo "$label: fetch blocked by packed-refs lock ($lock_desc); waiting ${FLEET_SYNC_PACKED_REFS_LOCK_RETRY_WAIT_SECS}s and retrying ($attempt/${FLEET_SYNC_PACKED_REFS_LOCK_RETRIES}) (owning process may be exiting)" >&2 sleep "$FLEET_SYNC_PACKED_REFS_LOCK_RETRY_WAIT_SECS" - FETCH_OUTPUT=$(fetch_origin 2>&1); rc=$? + FETCH_OUTPUT=$(git -C "$PROJ" fetch origin --prune --quiet 2>&1); rc=$? if [ "$rc" -eq 0 ]; then echo "$label: fetch succeeded on retry; packed-refs lock cleared on its own" >&2 # One stdout summary so a session-start refresh (which discards fleet-sync @@ -213,7 +191,7 @@ fetch_with_packed_refs_lock_guard() { return "$rc" fi echo "$label: removed provably-stale packed-refs lock $lock (age >= ${FLEET_SYNC_PACKED_REFS_LOCK_AGE_SECS}s, no live holder) and retrying fetch" >&2 - FETCH_OUTPUT=$(fetch_origin 2>&1); rc=$? + FETCH_OUTPUT=$(git -C "$PROJ" fetch origin --prune --quiet 2>&1); rc=$? if [ "$rc" -eq 0 ]; then echo "$label: fetch succeeded after stale packed-refs lock cleanup" >&2 echo "$label: recovered: removed a stale packed-refs lock (no live holder)" @@ -345,19 +323,12 @@ sync_project() { return 0 fi - # The registry's structured integration branch is authoritative when present. - # Legacy entries deliberately retain the previous remote-default resolution. - integration_branch=$("$FM_ROOT/bin/fm-project-mode.sh" --integration-branch "$label" 2>/dev/null || true) - if [ -n "$integration_branch" ]; then - DEFAULT=$integration_branch - INTEGRATION_DECLARED=yes - else - INTEGRATION_DECLARED=no - DEFAULT=$(default_branch) || { - echo "$label: skipped: cannot determine default branch" - return 0 - } - fi + # The registry's structured integration branch is authoritative when present; + # a legacy entry falls back to the remote default branch (fm-integration-branch-lib.sh). + DEFAULT=$(integration_branch "$PROJ" "$label") || { + echo "$label: skipped: cannot determine default branch" + return 0 + } BASE="origin/$DEFAULT" if ! fetch_with_packed_refs_lock_guard; then @@ -369,14 +340,6 @@ sync_project() { return 0 fi - if [ "${INTEGRATION_DECLARED:-no}" = yes ]; then - if ! prune_output=$(git -C "$PROJ" remote prune origin 2>&1); then - reason="remote ref pruning failed for $BASE" - [ -n "$prune_output" ] && reason="$reason: $(first_line "$prune_output")" - echo "$label: skipped: $reason" - return 0 - fi - fi prune_gone_branches || true if ! git -C "$PROJ" rev-parse --verify --quiet "$BASE^{commit}" >/dev/null; then echo "$label: skipped: $BASE does not exist" diff --git a/bin/fm-home-seed.sh b/bin/fm-home-seed.sh index 6693ab1df74..3b706bc2fe7 100755 --- a/bin/fm-home-seed.sh +++ b/bin/fm-home-seed.sh @@ -49,6 +49,9 @@ SUB_HOME_PARENT_MARKER=".fm-secondmate-parent" . "$SCRIPT_DIR/fm-secondmate-charter-lib.sh" # shellcheck source=bin/fm-wake-lib.sh . "$SCRIPT_DIR/fm-wake-lib.sh" +# The integration-branch resolver every base-picking path shares. +# shellcheck source=bin/fm-integration-branch-lib.sh +. "$SCRIPT_DIR/fm-integration-branch-lib.sh" usage() { echo "usage: fm-home-seed.sh {...|--no-projects}" >&2 @@ -456,6 +459,27 @@ EOF return 1 } +# A fresh clone checks out origin/HEAD. When the registry declares an integration +# branch, put the new clone on it here: that declaration is what fleet sync +# refreshes and what a spawn bases task copies on, so a clone left on the remote +# default would be reported STUCK on every later sync and never refreshed. Only +# newly created clones are moved; an already-seeded clone may hold work, so it is +# never switched. A declared branch the origin does not publish is refused loudly +# rather than silently seeded onto the wrong base. +checkout_declared_integration_branch() { # + local project=$1 dst=$2 branch + branch=$(FM_HOME="$FM_HOME" FM_DATA_OVERRIDE="$DATA" declared_integration_branch "$project") + [ -n "$branch" ] || return 0 + if ! git -C "$dst" rev-parse --verify --quiet "refs/remotes/origin/$branch^{commit}" >/dev/null; then + echo "error: project $project declares integration branch $branch but its origin publishes no such branch" >&2 + return 1 + fi + git -C "$dst" checkout --quiet -B "$branch" --track "origin/$branch" >/dev/null 2>&1 || { + echo "error: could not check out declared integration branch $branch for project $project" >&2 + return 1 + } +} + clone_project() { local project=$1 home=$2 src dst url dst_url mode src="$PROJECTS/$project" @@ -481,7 +505,8 @@ EOF return 0 fi url=$(source_origin_url "$project" "$mode" "$src") || return 1 - git clone --quiet "$url" "$dst" + git clone --quiet "$url" "$dst" || return 1 + checkout_declared_integration_branch "$project" "$dst" } validate_seed_project() { diff --git a/bin/fm-integration-branch-lib.sh b/bin/fm-integration-branch-lib.sh new file mode 100644 index 00000000000..e84682ee40e --- /dev/null +++ b/bin/fm-integration-branch-lib.sh @@ -0,0 +1,57 @@ +# shellcheck shell=bash +# The one resolver for "which branch does this project integrate on". +# Usage: . bin/fm-integration-branch-lib.sh +# +# A registry entry may carry the structured `integration-branch=` +# annotation; bin/fm-project-mode.sh owns that format and its validation, and is +# the only reader of the registry here. The declaration is authoritative for +# every path that has to pick a base branch - bin/fm-fleet-sync.sh's refresh +# target, the branch bin/fm-home-seed.sh and bin/fm-remote-home-provision.sh +# check out in a new clone, and the base bin/fm-spawn.sh resets a pooled +# worktree to - so a project cannot be synced on one branch while its tasks are +# cut from another. +# +# A project that declares nothing keeps the legacy resolution, origin's default +# branch, so unannotated homes behave exactly as before. +# Callers pass the registry home through the same FM_HOME/FM_DATA_OVERRIDE +# variables bin/fm-project-mode.sh already reads. + +FM_INTEGRATION_BRANCH_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Origin's default branch for : the tracked origin/HEAD, else a local main +# or master. Returns 1 when neither resolves, so a caller can refuse rather than +# guess a base. +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 + 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 +} + +# The branch declares in the registry, or nothing when the entry +# uses the legacy format without a declaration. +declared_integration_branch() { # + "$FM_INTEGRATION_BRANCH_LIB_DIR/fm-project-mode.sh" --integration-branch "$1" 2>/dev/null || true +} + +# The branch new work and refreshes must follow in : the declaration when +# the project makes one, otherwise origin's default branch. Returns 1 only when +# neither resolves. +integration_branch() { # + local declared + declared=$(declared_integration_branch "$2") + if [ -n "$declared" ]; then + printf '%s\n' "$declared" + return 0 + fi + default_branch "$1" +} diff --git a/bin/fm-project-mode.sh b/bin/fm-project-mode.sh index c7e113bed56..86d479fdc20 100755 --- a/bin/fm-project-mode.sh +++ b/bin/fm-project-mode.sh @@ -7,9 +7,11 @@ # for this project", never "how does this task ship". A task's delivery mode and # 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 and resolve their -# registered integration branch), bin/fm-home-seed.sh (refuse local-only seeding, -# run no-mistakes init), and bin/fm-spawn.sh's advisory registry-deviation notice. +# 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. --integration-branch is +# read only through bin/fm-integration-branch-lib.sh, the one resolver fleet +# sync, seeding, remote provisioning, and spawn all pick their base branch with. # # Registry line format (data/projects.md): # - - (added ) -> no-mistakes off (legacy default) diff --git a/bin/fm-remote-home-provision.sh b/bin/fm-remote-home-provision.sh index 8f733d6d3c4..6617e3a3a15 100755 --- a/bin/fm-remote-home-provision.sh +++ b/bin/fm-remote-home-provision.sh @@ -26,6 +26,9 @@ MAX_MANIFEST_BYTES=1048576 # shellcheck source=bin/fm-project-origin-lib.sh . "$SCRIPT_DIR/fm-project-origin-lib.sh" +# The integration-branch resolver every base-picking path shares. +# shellcheck source=bin/fm-integration-branch-lib.sh +. "$SCRIPT_DIR/fm-integration-branch-lib.sh" die() { printf 'error: %s\n' "$1" >&2; exit 1; } @@ -221,6 +224,9 @@ EOF fm_project_origin_safe "$ORIGIN" || die "project $NAME origin is not an accepted clone URL: $ORIGIN" case "$MODE" in no-mistakes|direct-PR) ;; *) die "project $NAME has unsupported remote mode: $MODE" ;; esac case "$REGISTRY_LINE" in "- $NAME "*) ;; *) die "project $NAME registry line is malformed" ;; esac + # Published before the clone so the declared integration branch resolves out of + # the registry this provisioning run is assembling. + printf '%s\n' "$REGISTRY_LINE" >> "$PROJECT_REG" DEST="$FM_HOME/projects/$NAME" if [ -e "$DEST" ] || [ -L "$DEST" ]; then [ -d "$DEST" ] && [ ! -L "$DEST" ] && [ -d "$DEST/.git" ] \ @@ -230,13 +236,23 @@ EOF else printf '%s\n' "$NAME" >> "$CREATED_PROJECTS" git clone --quiet -- "$ORIGIN" "$DEST" || die "could not clone project $NAME on the remote host" + # A new clone is born on the branch the registry declares, for the same reason + # bin/fm-home-seed.sh does it: fleet sync refreshes and spawn bases work on + # that branch, so a clone left on the remote default is permanently STUCK. + # Already-provisioned clones may hold work and are never switched. + INTEGRATION_BRANCH=$(FM_DATA_OVERRIDE="$TMP" declared_integration_branch "$NAME") + if [ -n "$INTEGRATION_BRANCH" ]; then + git -C "$DEST" rev-parse --verify --quiet "refs/remotes/origin/$INTEGRATION_BRANCH^{commit}" >/dev/null \ + || die "project $NAME declares integration branch $INTEGRATION_BRANCH but its origin publishes no such branch" + git -C "$DEST" checkout --quiet -B "$INTEGRATION_BRANCH" --track "origin/$INTEGRATION_BRANCH" >/dev/null 2>&1 \ + || die "could not check out declared integration branch $INTEGRATION_BRANCH for project $NAME" + fi if [ "$MODE" = no-mistakes ]; then command -v no-mistakes >/dev/null 2>&1 || die "no-mistakes is unavailable for project $NAME" (cd "$DEST" && no-mistakes init >/dev/null && no-mistakes doctor >/dev/null) \ || die "no-mistakes initialization failed for project $NAME" fi fi - printf '%s\n' "$REGISTRY_LINE" >> "$PROJECT_REG" done < <(grep '^project=' "$TMP/manifest") cp "$TMP/charter" "$FM_HOME/data/charter.md.tmp.$$" diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 1c27df82883..1171f3ac080 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -2396,8 +2396,8 @@ spawn_worktree_has_origin_config() { # return 1 } -freshen_spawn_worktree_base() { # - local worktree=$1 default target expected actual status +freshen_spawn_worktree_base() { # + local worktree=$1 project=$2 default target expected actual status 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 @@ -2421,8 +2421,11 @@ freshen_spawn_worktree_base() { # 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 + # A registered integration branch is the base new task copies must be cut from, + # so a project that integrates on develop never starts work from origin/main. + # An unannotated project keeps origin's default branch (fm-integration-branch-lib.sh). + default=$(integration_branch "$worktree" "$project") || { + echo "error: could not determine the integration branch for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 return 1 } target="origin/$default" @@ -3072,7 +3075,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" "$(basename "$PROJ_ABS")" || exit 1 fi # Pre-register Claude's workspace trust for the worktree, at the first point the diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 762272b948f..99d6ff5ed15 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -1400,6 +1400,7 @@ families_for_changed_path() { bin/fm-tmux-lib.sh|bin/fm-marker-lib.sh|bin/fm-operational-input.sh|bin/fm-tasks-axi-lib.sh|\ bin/fm-vendor-auth-probe.sh|\ bin/fm-primary-scope-lib.sh|bin/fm-project-mode.sh|bin/fm-promote.sh|\ + bin/fm-integration-branch-lib.sh|\ bin/fm-ff-lib.sh|bin/fm-gotmp*|bin/*pretool*) printf '%s\n' pure-contract-unit ;; diff --git a/docs/architecture.md b/docs/architecture.md index 8217c88f5cb..cf6f9920914 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -389,9 +389,9 @@ Invoked in a primary home, `/stow` then cascades the same sweep to every registe The locked session-start deferred network stage, PR-based teardown, and merged-PR wake handling refresh remote-backed project clones when the clone is safe to move. Wake-time refreshes can target a single clone by project name, so the primary home also catches up when a secondmate reports a merge from its own home. -A registry entry with `integration-branch=` makes that declared branch the sync target even when `origin/HEAD` names another default branch. -For that declaration, fleet sync fetches and compares only `origin/`, then fast-forwards the checked-out local `` when safe. -A legacy entry without the declaration retains remote-default resolution and its existing sync behavior. +A registry entry with `integration-branch=` makes that declared branch the base every path picks: fleet sync compares and fast-forwards it, a newly seeded local or remote clone is checked out on it, and a spawn resets its pooled worktree to `origin/` so task copies are cut from it. +`bin/fm-integration-branch-lib.sh` is the one resolver; a declared branch the origin does not publish refuses the seed or the spawn rather than silently falling back. +A legacy entry without the declaration retains remote-default resolution and its existing behavior, and an already-seeded clone is never switched. Clean target-branch clones fast-forward to `origin/`, and a clean detached HEAD that holds no unique commits is re-attached to the target branch before the same fast-forward path runs. Dirty clones, non-target branches, detached HEADs with unique commits, diverged targets, and target branches checked out in another worktree are reported as `STUCK:` with their behind count and left untouched. Fetches blocked by an orphaned `.git/packed-refs.lock` use bounded retries and remove the lock only when the shared staleness proof can prove it abandoned; [configuration.md](configuration.md#toolchain) owns the recovery details and tuning knobs. diff --git a/tests/fm-fleet-sync.test.sh b/tests/fm-fleet-sync.test.sh index 0131a89f384..21776daf4a1 100755 --- a/tests/fm-fleet-sync.test.sh +++ b/tests/fm-fleet-sync.test.sh @@ -281,11 +281,10 @@ run_sync_guarded() { # --- tests ------------------------------------------------------------------ test_declared_integration_branch_overrides_remote_default() { - local home clone out main_remote_before + local home clone out home=$(new_home) clone=$(build_integration_pair "$home" integration) declare_integration_branch "$home" integration - main_remote_before=$(git -C "$clone" rev-parse origin/main) # Advance both refs so a sync that incorrectly follows origin/HEAD=main is # observably wrong even if it happens to fetch the remote. git -C "$home/work-integration" checkout --quiet main @@ -302,8 +301,6 @@ test_declared_integration_branch_overrides_remote_default() { || fail "declared integration branch did not advance to origin/develop" [ "$(git -C "$clone" rev-parse HEAD)" != "$(git -C "$clone" rev-parse origin/main)" ] \ || fail "fixture did not keep integration and remote-default refs distinct" - [ "$(git -C "$clone" rev-parse origin/main)" = "$main_remote_before" ] \ - || fail "declared integration fetch unexpectedly updated origin/main" [ "$(git -C "$clone" symbolic-ref --short refs/remotes/origin/HEAD)" = "origin/main" ] \ || fail "fixture origin/HEAD no longer points to main" pass "declared integration branch governs fetch, comparison, and fast-forward despite origin/HEAD=main" diff --git a/tests/fm-remote-transport-lanes.test.sh b/tests/fm-remote-transport-lanes.test.sh index cbdf1356092..802de49a11b 100755 --- a/tests/fm-remote-transport-lanes.test.sh +++ b/tests/fm-remote-transport-lanes.test.sh @@ -56,6 +56,7 @@ cp "$ROOT/bin/fm-remote-job-lib.sh" "$ROOT/bin/fm-remote-job-worker.sh" \ "$ROOT/bin/fm-composer-lib.sh" "$ROOT/bin/fm-cursor-lib.sh" \ "$ROOT/bin/fm-classify-lib.sh" "$ROOT/bin/fm-timeout-lib.sh" \ "$ROOT/bin/fm-ff-lib.sh" "$ROOT/bin/fm-secondmate-registry-lib.sh" \ + "$ROOT/bin/fm-integration-branch-lib.sh" "$ROOT/bin/fm-project-mode.sh" \ "$REMOTE_ROOT/bin/" mkdir -p "$REMOTE_ROOT/bin/backends" cp "$ROOT/bin/backends/herdr.sh" "$REMOTE_ROOT/bin/backends/herdr.sh" diff --git a/tests/fm-secondmate-safety.test.sh b/tests/fm-secondmate-safety.test.sh index 7a69e15fe86..0de515488ef 100755 --- a/tests/fm-secondmate-safety.test.sh +++ b/tests/fm-secondmate-safety.test.sh @@ -135,6 +135,64 @@ EOF pass "seed allows overlapping project clone lists and drops the owns/owner routing" } +# A seeded clone starts on origin/HEAD, so a project that integrates elsewhere +# would be reported STUCK by every later fleet sync and have its task copies cut +# from the wrong base. The declared branch is checked out at seed time instead. +test_seed_checks_out_the_declared_integration_branch() { + local home sub clone beta_default + home="$TMP_ROOT/integration-main" + sub="$TMP_ROOT/integration-sub" + mkdir -p "$home/projects" "$home/data" "$home/state" + fm_git_init_commit "$home/projects/alpha" + git -C "$home/projects/alpha" branch develop + fm_git_add_origin "$home/projects/alpha" "$TMP_ROOT/remotes/seed-integration-alpha.git" + fm_git_init_commit "$home/projects/beta" + fm_git_add_origin "$home/projects/beta" "$TMP_ROOT/remotes/seed-integration-beta.git" + beta_default=$(git -C "$home/projects/beta" symbolic-ref --short HEAD) + cat > "$home/data/projects.md" </dev/null \ + || fail "seed failed for a project declaring an integration branch" + clone="$sub/projects/alpha" + [ "$(git -C "$clone" symbolic-ref --short HEAD)" = develop ] \ + || fail "seeded clone was not born on its declared integration branch" + [ "$(git -C "$clone" rev-parse HEAD)" = "$(git -C "$clone" rev-parse origin/develop)" ] \ + || fail "seeded clone's declared branch does not sit at origin/develop" + [ "$(git -C "$sub/projects/beta" symbolic-ref --short HEAD)" = "$beta_default" ] \ + || fail "an undeclared project's clone left its remote default branch" + pass "a seeded clone is born on its declared integration branch" +} + +# The declaration is a promise about the base. A branch the origin does not +# publish must refuse the seed loudly rather than leave a clone on the wrong base. +test_seed_refuses_an_unpublished_declared_integration_branch() { + local home sub err + home="$TMP_ROOT/integration-missing-main" + sub="$TMP_ROOT/integration-missing-sub" + err="$TMP_ROOT/integration-missing.err" + mkdir -p "$home/projects" "$home/data" "$home/state" + fm_git_init_commit "$home/projects/alpha" + fm_git_add_origin "$home/projects/alpha" "$TMP_ROOT/remotes/seed-integration-missing.git" + printf -- '- alpha [direct-PR integration-branch=develop] - alpha project (added 2026-09-01)\n' \ + > "$home/data/projects.md" + + if FM_HOME="$home" FM_SECONDMATE_CHARTER='missing branch fixture' \ + FM_SECONDMATE_SCOPE='missing branch fixture' \ + "$ROOT/bin/fm-home-seed.sh" mate "$sub" alpha >/dev/null 2>"$err"; then + fail "seed accepted a declared integration branch the origin does not publish" + fi + assert_grep 'declares integration branch develop' "$err" \ + "seed did not name the unresolved declared branch in its refusal" + [ ! -d "$sub/projects/alpha" ] || fail "refused seed left the clone behind" + [ ! -e "$home/data/secondmates.md" ] || fail "refused seed registered the secondmate" + pass "a declared integration branch the origin lacks refuses the seed" +} + test_home_seed_validate_rejects_unparseable_registry_entry() { local home err home="$TMP_ROOT/unparseable-registry-home" @@ -2957,6 +3015,8 @@ EOF test_fm_home_parameterization test_lock_status_is_per_home test_seed_allows_overlapping_clones_and_drops_owner +test_seed_checks_out_the_declared_integration_branch +test_seed_refuses_an_unpublished_declared_integration_branch test_home_seed_validate_rejects_unparseable_registry_entry test_home_seed_refuses_broken_registry_symlink test_home_seed_refuses_unreadable_registry diff --git a/tests/fm-spawn-pool-base-freshen.test.sh b/tests/fm-spawn-pool-base-freshen.test.sh index aeb5a193149..dcd5c36ff0d 100755 --- a/tests/fm-spawn-pool-base-freshen.test.sh +++ b/tests/fm-spawn-pool-base-freshen.test.sh @@ -409,6 +409,60 @@ test_direct_pr_and_scout_refresh_before_launch() { pass "direct-PR ships and scouts both refresh stale pooled worktrees before launch" } +# A project that integrates on a branch other than origin/HEAD must have its task +# copies cut from that declared branch, or fleet sync reports the clone current on +# develop while live work is based on main. +test_declared_integration_branch_bases_the_task_copy() { + local rec id out status publisher declared_tip + id='pool-integration-branch-r1' + rec=$(make_case integration-branch "$id") + read_case_record "$rec" + printf -- '- project [no-mistakes integration-branch=develop] - fixture (added 2026-09-01)\n' \ + > "$HOME_DIR/data/projects.md" + publisher="$CASE_DIR/publisher" + git -C "$publisher" checkout --quiet -b develop + printf 'only on the declared integration branch\n' > "$publisher/develop-only.txt" + git -C "$publisher" add develop-only.txt + git -C "$publisher" -c user.name='Firstmate Tests' -c user.email='tests@example.invalid' commit -qm advance-develop + git -C "$publisher" push --quiet origin develop + declared_tip=$(git -C "$publisher" rev-parse HEAD) + + out=$(run_spawn "$id" --mode no-mistakes --yolo off) + status=$? + expect_code 0 "$status" "spawn should base a declared-integration project on its declared branch"$'\n'"$out" + [ "$(git -C "$POOL_DIR" rev-parse HEAD)" = "$declared_tip" ] \ + || fail "spawn did not base the task copy on origin/develop" + [ "$(git -C "$POOL_DIR" rev-parse HEAD)" != "$(git -C "$POOL_DIR" rev-parse origin/main)" ] \ + || fail "fixture did not keep origin/develop distinct from origin/main" + assert_grep 'only on the declared integration branch' "$POOL_DIR/develop-only.txt" \ + "the task copy omitted content that exists only on the declared branch" + [ "$(git --git-dir="$CASE_DIR/origin.git" symbolic-ref --short HEAD)" = main ] \ + || fail "fixture origin default branch is no longer main" + pass "a declared integration branch, not origin/HEAD, is the base of a new task copy" +} + +# The declaration is a promise about the base; a branch the origin does not +# publish must stop the launch rather than silently fall back to origin/HEAD. +test_unpublished_declared_integration_branch_refuses_pool() { + local rec id out status before + id='pool-missing-integration-branch-r1' + rec=$(make_case missing-integration-branch "$id") + read_case_record "$rec" + printf -- '- project [no-mistakes integration-branch=develop] - fixture (added 2026-09-01)\n' \ + > "$HOME_DIR/data/projects.md" + before=$(git -C "$POOL_DIR" rev-parse HEAD) + + out=$(run_spawn "$id" --mode no-mistakes --yolo off) + status=$? + [ "$status" -ne 0 ] || fail "spawn accepted a declared integration branch the origin does not publish" + assert_contains "$out" "could not fetch 'origin/develop'" \ + "spawn did not name the unresolved declared branch in its refusal" + [ "$(git -C "$POOL_DIR" rev-parse HEAD)" = "$before" ] \ + || fail "spawn moved HEAD after failing to resolve the declared integration branch" + [ ! -e "$HOME_DIR/state/$id.meta" ] || fail "refused spawn published task metadata" + pass "an unpublished declared integration branch refuses the pooled worktree" +} + test_dirty_pool_refuses_without_discarding_work() { local rec id out status before id='pool-dirty-refusal-r4' @@ -680,6 +734,8 @@ test_remote_seeded_home_spawns_from_treehouse_pool test_linked_spawning_home_rejects_primary_before_refresh test_stale_pool_base_refreshes_before_branching test_non_main_default_branch_refreshes_before_branching +test_declared_integration_branch_bases_the_task_copy +test_unpublished_declared_integration_branch_refuses_pool test_direct_pr_and_scout_refresh_before_launch test_dirty_pool_refuses_without_discarding_work test_unresolved_remote_default_refuses_pool diff --git a/tests/fm-task-delivery.test.sh b/tests/fm-task-delivery.test.sh index bdace4b501e..e5acfbbca39 100755 --- a/tests/fm-task-delivery.test.sh +++ b/tests/fm-task-delivery.test.sh @@ -431,6 +431,57 @@ EOF pass "fm-project-mode: the conditional policy is accepted, mapped for mechanical callers, and readable raw" } +# --integration-branch reads only the structured bracket annotation: the same +# free-form text in the description is registry prose, never a declaration, and a +# branch name git itself would reject falls back to the caller's remote default +# with a warning rather than being handed on as a ref. +test_project_mode_reads_only_the_structured_integration_branch() { + local home out err project + home="$TMP_ROOT/integration-branch/home" + mkdir -p "$home/data" + cat > "$home/data/projects.md" <<'EOF' +- declared [no-mistakes integration-branch=develop] - fixture (added 2026-01-01) +- withyolo [direct-PR +yolo integration-branch=release/2.x] - fixture (added 2026-01-01) +- yolofirst [no-mistakes integration-branch=develop +yolo] - fixture (added 2026-01-01) +- proseonly [no-mistakes] - integration-branch=develop is only prose here (added 2026-01-01) +- legacy [no-mistakes +yolo] - fixture (added 2026-01-01) +- bareentry - fixture (added 2026-01-01) +- badbranch [no-mistakes integration-branch=..bad] - fixture (added 2026-01-01) +EOF + out=$(FM_HOME="$home" "$PROJECT_MODE" --integration-branch declared 2>/dev/null) + [ "$out" = develop ] || fail "a declared integration branch was not reported (got '$out')" + + out=$(FM_HOME="$home" "$PROJECT_MODE" --integration-branch withyolo 2>/dev/null) + [ "$out" = release/2.x ] || fail "an integration branch beside +yolo was not reported (got '$out')" + + out=$(FM_HOME="$home" "$PROJECT_MODE" --integration-branch yolofirst 2>/dev/null) + [ "$out" = develop ] || fail "the annotation token order changed the declared branch (got '$out')" + + # The declaration must not change the mode or yolo posture it sits beside. + out=$(FM_HOME="$home" "$PROJECT_MODE" declared 2>/dev/null) + [ "$out" = "no-mistakes off" ] || fail "a declared integration branch disturbed the mode (got '$out')" + out=$(FM_HOME="$home" "$PROJECT_MODE" withyolo 2>/dev/null) + [ "$out" = "direct-PR on" ] || fail "a declared integration branch disturbed mode/yolo (got '$out')" + out=$(FM_HOME="$home" "$PROJECT_MODE" yolofirst 2>/dev/null) + [ "$out" = "no-mistakes on" ] || fail "a trailing +yolo was lost beside a declaration (got '$out')" + + for project in proseonly legacy bareentry unregistered; do + out=$(FM_HOME="$home" "$PROJECT_MODE" --integration-branch "$project" 2>/dev/null) + [ -z "$out" ] || fail "$project reported an integration branch it never declares (got '$out')" + done + out=$(FM_HOME="$home" "$PROJECT_MODE" legacy 2>/dev/null) + [ "$out" = "no-mistakes on" ] || fail "an undeclared entry lost its posture (got '$out')" + + out=$(FM_HOME="$home" "$PROJECT_MODE" --integration-branch badbranch 2>/dev/null) + [ -z "$out" ] || fail "an invalid branch name was handed on as a ref (got '$out')" + err=$(FM_HOME="$home" "$PROJECT_MODE" --integration-branch badbranch 2>&1 >/dev/null) + assert_contains "$err" "invalid integration branch" "an invalid declared branch fell back silently" + + out=$(FM_HOME="$TMP_ROOT/integration-branch/absent" "$PROJECT_MODE" --integration-branch declared 2>&1) + [ -z "$out" ] || fail "a home with no registry reported an integration branch (got '$out')" + pass "fm-project-mode: --integration-branch reads only the structured annotation" +} + # Spawn and promotion refuse leftover Task-subsection placeholders through the # public brief/spawn/promote path. Filling both subsections lets the spawn # delivery checks proceed (the fake tmux still fails later). @@ -800,5 +851,6 @@ test_promote_requires_and_records_the_delivery_contract test_promote_refuses_a_symlinked_task_record test_promotion_delivers_the_real_definition_of_done test_project_mode_maps_the_conditional_policy +test_project_mode_reads_only_the_structured_integration_branch test_spawn_and_promote_require_filled_task_subsections echo "# all fm-task-delivery tests passed" From a549dde9f6006abd65f8d30b7cfa7f82d262d381 Mon Sep 17 00:00:00 2001 From: Alex William Date: Tue, 8 Sep 2026 18:15:06 +0200 Subject: [PATCH 3/6] no-mistakes(review): resolve review, landing and cleanup bases through the declaration --- bin/fm-fleet-sync.sh | 22 ++++++-- bin/fm-home-seed.sh | 5 +- bin/fm-integration-branch-lib.sh | 26 ++++++---- bin/fm-merge-local.sh | 30 ++++------- bin/fm-project-mode.sh | 11 ++-- bin/fm-remote-home-provision.sh | 3 +- bin/fm-review-diff.sh | 32 +++++------- bin/fm-teardown.sh | 38 ++++++-------- docs/architecture.md | 4 +- tests/fm-fleet-sync.test.sh | 45 ++++++++++++++++ tests/fm-review-diff.test.sh | 89 +++++++++++++++++++++++++++++++- tests/fm-task-delivery.test.sh | 27 +++++++--- tests/fm-teardown.test.sh | 59 +++++++++++++++++++++ 13 files changed, 299 insertions(+), 92 deletions(-) diff --git a/bin/fm-fleet-sync.sh b/bin/fm-fleet-sync.sh index e3ea36717bd..d78cf4ef810 100755 --- a/bin/fm-fleet-sync.sh +++ b/bin/fm-fleet-sync.sh @@ -325,10 +325,17 @@ sync_project() { # The registry's structured integration branch is authoritative when present; # a legacy entry falls back to the remote default branch (fm-integration-branch-lib.sh). - DEFAULT=$(integration_branch "$PROJ" "$label") || { - echo "$label: skipped: cannot determine default branch" + # A declaration the resolver rejects is the registry promising a base that + # cannot exist, so it is reported loudly on stdout (session-start discards this + # script's stderr) instead of degrading to the legacy no-default-branch skip. + if ! DEFAULT=$(integration_branch "$PROJ" "$label"); then + if declared_integration_branch "$label" >/dev/null 2>&1; then + echo "$label: skipped: cannot determine default branch" + else + echo "$label: STUCK: the registry declares an invalid integration branch - needs attention" + fi return 0 - } + fi BASE="origin/$DEFAULT" if ! fetch_with_packed_refs_lock_guard; then @@ -342,7 +349,14 @@ sync_project() { prune_gone_branches || true if ! git -C "$PROJ" rev-parse --verify --quiet "$BASE^{commit}" >/dev/null; then - echo "$label: skipped: $BASE does not exist" + # A declared branch origin does not publish is a broken registry promise, not + # a benign absence: the clone would otherwise never be refreshed again and + # never be reported as needing attention. + if [ -n "$(declared_integration_branch "$label" 2>/dev/null)" ]; then + echo "$label: STUCK: declared integration branch $DEFAULT is not published by origin - needs attention" + else + echo "$label: skipped: $BASE does not exist" + fi return 0 fi diff --git a/bin/fm-home-seed.sh b/bin/fm-home-seed.sh index 3b706bc2fe7..bef4ce9ab07 100755 --- a/bin/fm-home-seed.sh +++ b/bin/fm-home-seed.sh @@ -468,7 +468,10 @@ EOF # rather than silently seeded onto the wrong base. checkout_declared_integration_branch() { # local project=$1 dst=$2 branch - branch=$(FM_HOME="$FM_HOME" FM_DATA_OVERRIDE="$DATA" declared_integration_branch "$project") + branch=$(FM_HOME="$FM_HOME" FM_DATA_OVERRIDE="$DATA" declared_integration_branch "$project") || { + echo "error: project $project declares an integration branch the registry format rejects" >&2 + return 1 + } [ -n "$branch" ] || return 0 if ! git -C "$dst" rev-parse --verify --quiet "refs/remotes/origin/$branch^{commit}" >/dev/null; then echo "error: project $project declares integration branch $branch but its origin publishes no such branch" >&2 diff --git a/bin/fm-integration-branch-lib.sh b/bin/fm-integration-branch-lib.sh index e84682ee40e..6fee5a2dd22 100644 --- a/bin/fm-integration-branch-lib.sh +++ b/bin/fm-integration-branch-lib.sh @@ -7,12 +7,17 @@ # the only reader of the registry here. The declaration is authoritative for # every path that has to pick a base branch - bin/fm-fleet-sync.sh's refresh # target, the branch bin/fm-home-seed.sh and bin/fm-remote-home-provision.sh -# check out in a new clone, and the base bin/fm-spawn.sh resets a pooled -# worktree to - so a project cannot be synced on one branch while its tasks are -# cut from another. +# check out in a new clone, the base bin/fm-spawn.sh resets a pooled worktree to, +# the base bin/fm-review-diff.sh diffs a task against, and the branch +# bin/fm-teardown.sh tests landed content and unmerged local-only work against +# and bin/fm-merge-local.sh fast-forwards - so a project cannot be synced, +# worked, reviewed and landed against four different branches. # # A project that declares nothing keeps the legacy resolution, origin's default -# branch, so unannotated homes behave exactly as before. +# branch, so unannotated homes behave exactly as before. A declaration that is +# not a valid branch name is never silently downgraded to that fallback: the +# query fails, its diagnostic reaches the caller's stderr, and every consumer +# refuses rather than working from a base the registry did not ask for. # Callers pass the registry home through the same FM_HOME/FM_DATA_OVERRIDE # variables bin/fm-project-mode.sh already reads. @@ -38,17 +43,18 @@ default_branch() { # } # The branch declares in the registry, or nothing when the entry -# uses the legacy format without a declaration. +# uses the legacy format without a declaration. Returns non-zero, and lets the +# diagnostic through to stderr, when the entry declares an invalid branch. declared_integration_branch() { # - "$FM_INTEGRATION_BRANCH_LIB_DIR/fm-project-mode.sh" --integration-branch "$1" 2>/dev/null || true + "$FM_INTEGRATION_BRANCH_LIB_DIR/fm-project-mode.sh" --integration-branch "$1" } -# The branch new work and refreshes must follow in : the declaration when -# the project makes one, otherwise origin's default branch. Returns 1 only when -# neither resolves. +# The branch new work, refreshes, reviews and landings must follow in : the +# declaration when the project makes one, otherwise origin's default branch. +# Returns 1 when the declaration is invalid, or when neither resolves. integration_branch() { # local declared - declared=$(declared_integration_branch "$2") + declared=$(declared_integration_branch "$2") || return 1 if [ -n "$declared" ]; then printf '%s\n' "$declared" return 0 diff --git a/bin/fm-merge-local.sh b/bin/fm-merge-local.sh index 70ac9b7be2c..3bd50f1bc65 100755 --- a/bin/fm-merge-local.sh +++ b/bin/fm-merge-local.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash # Perform the approved local merge for a local-only ship task: fast-forward the -# project's default branch to the crewmate's fm/ branch. +# project's integration branch - its registered integration-branch declaration +# when it makes one, otherwise its default branch - to the crewmate's fm/ +# branch, so work lands on the branch bin/fm-spawn.sh cut it from. # # This is firstmate's merge gate-action (the captain's merge authority applied # locally instead of via a GitHub PR). It is the one sanctioned exception to hard @@ -16,6 +18,10 @@ 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}" +# The one resolver for the branch a project integrates on, so the landing target +# is the branch the task was cut from. +# shellcheck source=bin/fm-integration-branch-lib.sh +. "$SCRIPT_DIR/fm-integration-branch-lib.sh" "$FM_ROOT/bin/fm-guard.sh" || true # Role partition: landing local-only work is MAIN-owned; the Pi supervision # branch reports readiness and never lands (contract: bin/fm-lease-lib.sh; @@ -31,31 +37,15 @@ PROJ=$(grep '^project=' "$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 -} - 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; } +DEFAULT=$(integration_branch "$PROJ" "$(basename "$PROJ")") || { echo "error: cannot determine the integration branch for $PROJ; expected a valid registry declaration, origin/HEAD, main, or master" >&2; exit 1; } -# The project's main checkout must be on its default branch and clean, so the +# The project's main checkout must be on its integration 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 "") -[ "$cur" = "$DEFAULT" ] || { echo "error: $PROJ is on '$cur', expected default branch '$DEFAULT'; cannot merge safely" >&2; exit 1; } +[ "$cur" = "$DEFAULT" ] || { echo "error: $PROJ is on '$cur', expected integration branch '$DEFAULT'; cannot merge safely" >&2; exit 1; } if [ -n "$(git -C "$PROJ" status --porcelain 2>/dev/null | head -1)" ]; then echo "error: $PROJ has a dirty working tree; refusing to merge into it" >&2 exit 1 diff --git a/bin/fm-project-mode.sh b/bin/fm-project-mode.sh index 86d479fdc20..9f8c44355a8 100755 --- a/bin/fm-project-mode.sh +++ b/bin/fm-project-mode.sh @@ -20,7 +20,9 @@ # # `integration-branch=` is a structured annotation. It is intentionally # not read from the free-form description. Omitted integration branches are -# reported as empty by --integration-branch so callers retain their old fallback. +# reported as empty by --integration-branch so callers retain their old fallback; +# a declared branch git itself would reject exits non-zero with a diagnostic, so +# no caller can mistake a broken declaration for an absent one. # # Registered modes: # no-mistakes full pipeline -> PR -> configured merge authority (default) @@ -42,7 +44,8 @@ # to stderr, so a typo never silently drops the gate. # --raw prints the registered mode annotation unmapped. # --integration-branch prints the structured integration branch, or nothing when -# the registry uses the legacy format without one. +# the registry uses the legacy format without one, and exits non-zero when the +# declared branch is not a valid branch name. # Usage: fm-project-mode.sh [--raw|--integration-branch] set -eu @@ -106,8 +109,8 @@ yolo=${yolo%% *} integration_branch=${parsed##* } if [ "$QUERY" = integration-branch ]; then if [ -n "$integration_branch" ] && ! git check-ref-format --branch "$integration_branch" >/dev/null 2>&1; then - echo "warn: invalid integration branch \"$integration_branch\" for $NAME; using the remote default branch" >&2 - exit 0 + echo "error: invalid integration branch \"$integration_branch\" for $NAME; fix the registry entry" >&2 + exit 1 fi [ "$integration_branch" = "-" ] || printf '%s\n' "$integration_branch" exit 0 diff --git a/bin/fm-remote-home-provision.sh b/bin/fm-remote-home-provision.sh index 6617e3a3a15..3fe41c0bde0 100755 --- a/bin/fm-remote-home-provision.sh +++ b/bin/fm-remote-home-provision.sh @@ -240,7 +240,8 @@ EOF # bin/fm-home-seed.sh does it: fleet sync refreshes and spawn bases work on # that branch, so a clone left on the remote default is permanently STUCK. # Already-provisioned clones may hold work and are never switched. - INTEGRATION_BRANCH=$(FM_DATA_OVERRIDE="$TMP" declared_integration_branch "$NAME") + INTEGRATION_BRANCH=$(FM_DATA_OVERRIDE="$TMP" declared_integration_branch "$NAME") \ + || die "project $NAME declares an integration branch the registry format rejects" if [ -n "$INTEGRATION_BRANCH" ]; then git -C "$DEST" rev-parse --verify --quiet "refs/remotes/origin/$INTEGRATION_BRANCH^{commit}" >/dev/null \ || die "project $NAME declares integration branch $INTEGRATION_BRANCH but its origin publishes no such branch" diff --git a/bin/fm-review-diff.sh b/bin/fm-review-diff.sh index 06e0efb5bd7..ed5961862d5 100755 --- a/bin/fm-review-diff.sh +++ b/bin/fm-review-diff.sh @@ -1,9 +1,13 @@ #!/usr/bin/env bash # Review a crewmate branch against the authoritative base. # -# Pooled project clones do not keep their local default branch current, so this -# helper compares remote-backed projects against origin/ after fetching -# the default branch, and local-only projects against the local default branch. +# Pooled project clones do not keep their local integration branch current, so +# this helper compares remote-backed projects against origin/ after +# fetching it, and local-only projects against the local branch. That branch is +# the project's registered integration branch when it declares one, and origin's +# default branch otherwise (bin/fm-integration-branch-lib.sh) - the same +# resolution bin/fm-spawn.sh cut the task copy from, so the three-dot diff shows +# the task's work and nothing the integration branch already carried. # When state/.meta records pr= (URL or number) for an open PR, the compare # side is ALWAYS a freshly fetched refs/pull//head by default so review stays # current after no-mistakes fix rounds push to the PR. A recorded pr_head= is @@ -18,6 +22,10 @@ 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}" +# The one resolver for the branch a project integrates on, so the review base is +# the branch the task was actually cut from. +# shellcheck source=bin/fm-integration-branch-lib.sh +. "$SCRIPT_DIR/fm-integration-branch-lib.sh" "$FM_ROOT/bin/fm-guard.sh" || true usage() { @@ -49,23 +57,7 @@ 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; } +DEFAULT=$(integration_branch "$PROJ" "$(basename "$PROJ")") || { echo "error: cannot determine the integration branch for $PROJ; expected a valid registry declaration, origin/HEAD, main, or master" >&2; exit 1; } BRANCH="fm/$ID" if ! git -C "$WT" rev-parse --verify --quiet "refs/heads/$BRANCH" >/dev/null; then diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index fd2db419806..56502b44cf3 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -255,6 +255,10 @@ 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" +# The one resolver for the branch a project integrates on, so the landed-content +# and unmerged-work checks test against the branch the task was cut from. +# shellcheck source=bin/fm-integration-branch-lib.sh +. "$SCRIPT_DIR/fm-integration-branch-lib.sh" if [ "$#" -lt 1 ] || ! fm_task_id_path_safe "$1"; then echo "error: invalid teardown request" >&2 exit 2 @@ -1107,20 +1111,8 @@ 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 +project_integration_branch() { + integration_branch "$PROJ" "$(basename "$PROJ")" } meta_value() { @@ -1340,16 +1332,16 @@ pr_is_merged() { return 0 } -# Is the branch's content already present in the up-to-date default branch? Fetches -# first, then 3-way merges the default branch with HEAD: when HEAD introduces nothing -# the default branch does not already contain (e.g. its change landed via squash) the -# merged tree equals the default branch's tree. This isolates branch-only changes, so -# unrelated commits the default branch gained past the merge-base do not count as -# "added". Returns non-zero when inconclusive (no default ref, or a merge conflict), -# so the caller refuses rather than guesses. +# Is the branch's content already present in the up-to-date integration branch? +# Fetches first, then 3-way merges that branch with HEAD: when HEAD introduces +# nothing it does not already contain (e.g. its change landed via squash) the +# merged tree equals its tree. This isolates branch-only changes, so unrelated +# commits the integration branch gained past the merge-base do not count as +# "added". Returns non-zero when inconclusive (no branch ref, an invalid registry +# declaration, 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 + name=$(project_integration_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" @@ -1665,7 +1657,7 @@ 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; } + DEFAULT=$(project_integration_branch) || { echo "REFUSED: cannot determine the integration branch for $PROJ; expected a valid registry declaration, origin/HEAD, main, or master." >&2; return 1; } if ! unmerged_raw=$(git -C "$WT" log --oneline HEAD --not "$DEFAULT" -- 2>/dev/null); then if worktree_safety_blocked_by_lock "commits not on $DEFAULT"; then return "$TEARDOWN_WORKTREE_SAFETY_LOCK_BLOCKED" diff --git a/docs/architecture.md b/docs/architecture.md index cf6f9920914..cbe7cf633be 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -389,8 +389,8 @@ Invoked in a primary home, `/stow` then cascades the same sweep to every registe The locked session-start deferred network stage, PR-based teardown, and merged-PR wake handling refresh remote-backed project clones when the clone is safe to move. Wake-time refreshes can target a single clone by project name, so the primary home also catches up when a secondmate reports a merge from its own home. -A registry entry with `integration-branch=` makes that declared branch the base every path picks: fleet sync compares and fast-forwards it, a newly seeded local or remote clone is checked out on it, and a spawn resets its pooled worktree to `origin/` so task copies are cut from it. -`bin/fm-integration-branch-lib.sh` is the one resolver; a declared branch the origin does not publish refuses the seed or the spawn rather than silently falling back. +A registry entry with `integration-branch=` makes that declared branch the base every path picks: fleet sync compares and fast-forwards it, a newly seeded local or remote clone is checked out on it, a spawn resets its pooled worktree to `origin/` so task copies are cut from it, the review diff is taken against it, and the landed-content, unmerged-work and local-only landing checks all test against it. +`bin/fm-integration-branch-lib.sh` is the one resolver: a declaration that is not a valid branch name fails the query with a diagnostic instead of falling back, a declared branch the origin does not publish refuses the seed or the spawn, and fleet sync reports both as `STUCK:` rather than a benign skip. A legacy entry without the declaration retains remote-default resolution and its existing behavior, and an already-seeded clone is never switched. Clean target-branch clones fast-forward to `origin/`, and a clean detached HEAD that holds no unique commits is re-attached to the target branch before the same fast-forward path runs. Dirty clones, non-target branches, detached HEADs with unique commits, diverged targets, and target branches checked out in another worktree are reported as `STUCK:` with their behind count and left untouched. diff --git a/tests/fm-fleet-sync.test.sh b/tests/fm-fleet-sync.test.sh index 21776daf4a1..46caf178d24 100755 --- a/tests/fm-fleet-sync.test.sh +++ b/tests/fm-fleet-sync.test.sh @@ -321,6 +321,49 @@ test_legacy_registry_entry_uses_remote_default() { pass "project without an integration declaration safely retains remote-default sync" } +# A declaration the registry format rejects promises a base that cannot exist. +# Degrading it to "no declaration" would silently resync the clone against +# origin/main, so it is reported loudly on stdout - session-start relays this +# script's stdout and discards its stderr. +test_invalid_declared_integration_branch_is_stuck() { + local home clone out before + home=$(new_home) + clone=$(build_integration_pair "$home" bad-integration) + mkdir -p "$home/data" + printf -- '- bad-integration [no-mistakes integration-branch=..bad] - fixture (added 2026-09-01)\n' \ + > "$home/data/projects.md" + advance_integration_origin "$home" bad-integration develop1 + before=$(head_sha "$clone") + + out=$(run_sync "$home" bad-integration) + + assert_contains "$out" "bad-integration: STUCK:" "an invalid declaration was not reported as needing attention" + assert_contains "$out" "invalid integration branch" "the STUCK line did not name the invalid declaration" + assert_not_contains "$out" "origin/main" "an invalid declaration fell back to the remote default" + [ "$(head_sha "$clone")" = "$before" ] || fail "an invalid declaration still moved the clone" + pass "an invalid integration-branch declaration is reported STUCK, never resolved to the remote default" +} + +# A declared branch origin stopped publishing (renamed or deleted on the forge) +# would otherwise be a benign one-line skip on every sync forever, leaving the +# clone silently un-refreshed. +test_unpublished_declared_integration_branch_is_stuck() { + local home clone out before + home=$(new_home) + clone=$(build_pair "$home" gone-integration) + declare_integration_branch "$home" gone-integration + advance_origin "$home" gone-integration C1 + before=$(head_sha "$clone") + + out=$(run_sync "$home" gone-integration) + + assert_contains "$out" "gone-integration: STUCK:" "an unpublished declared branch was not reported as needing attention" + assert_contains "$out" "develop" "the STUCK line did not name the declared branch" + assert_not_contains "$out" "skipped:" "an unpublished declared branch degraded to a benign skip" + [ "$(head_sha "$clone")" = "$before" ] || fail "an unpublished declared branch still moved the clone" + pass "a declared integration branch origin does not publish is reported STUCK, not skipped" +} + test_declared_integration_branch_dirty_is_stuck_untouched() { local home clone out before home=$(new_home) @@ -818,6 +861,8 @@ test_non_signature_fetch_failure_is_not_retried() { } test_declared_integration_branch_overrides_remote_default +test_invalid_declared_integration_branch_is_stuck +test_unpublished_declared_integration_branch_is_stuck test_legacy_registry_entry_uses_remote_default test_declared_integration_branch_dirty_is_stuck_untouched test_declared_integration_branch_divergence_is_stuck_untouched diff --git a/tests/fm-review-diff.test.sh b/tests/fm-review-diff.test.sh index 2193772b9d9..991351d9ca7 100755 --- a/tests/fm-review-diff.test.sh +++ b/tests/fm-review-diff.test.sh @@ -23,7 +23,7 @@ TMP_ROOT=$(fm_test_tmproot fm-review-diff-tests) make_case() { local name=$1 case_dir case_dir="$TMP_ROOT/$name" - mkdir -p "$case_dir/state" + mkdir -p "$case_dir/state" "$case_dir/data" git init -q --bare "$case_dir/origin.git" git -C "$case_dir/origin.git" symbolic-ref HEAD refs/heads/main @@ -70,11 +70,95 @@ stale_and_pr_commits() { run_review_diff() { local case_dir=$1 shift + # The project registry decides the diff base, so pin it to the case dir: an + # ambient FM_HOME would otherwise let the operator's live registry pick it. FM_ROOT_OVERRIDE="$ROOT" \ FM_STATE_OVERRIDE="$case_dir/state" \ + FM_DATA_OVERRIDE="$case_dir/data" \ "$REVIEW_DIFF" "$@" } +declare_integration_branch() { + local case_dir=$1 branch=$2 + printf -- '- project [no-mistakes integration-branch=%s] - fixture (added 2026-09-01)\n' \ + "$branch" > "$case_dir/data/projects.md" +} + +# The task copy is cut from the declared integration branch, so the review base +# must be that branch too. Against origin/main the three-dot diff would take the +# merge base where develop forked and hand the reviewer every develop-only change +# on top of the task's own - a wrong set, reported with no error. +test_declared_integration_branch_is_the_diff_base() { + local case_dir out + case_dir=$(make_case declared-base) + declare_integration_branch "$case_dir" develop + + # develop carries a change main never had; the task branch is cut from develop. + git -C "$case_dir/project" branch -q develop main + git -C "$case_dir/project" push -q origin develop + git -C "$case_dir/wt" checkout -q develop + printf 'develop-only\n' > "$case_dir/wt/integration.txt" + git -C "$case_dir/wt" add integration.txt + git -C "$case_dir/wt" commit -qm "integration branch work" + git -C "$case_dir/wt" push -q origin develop + git -C "$case_dir/wt" branch -qf fm/task-x1 develop + git -C "$case_dir/wt" checkout -q fm/task-x1 + printf 'task-work\n' > "$case_dir/wt/task.txt" + git -C "$case_dir/wt" add task.txt + git -C "$case_dir/wt" commit -qm "task work" + write_task_meta "$case_dir" + + out=$(run_review_diff "$case_dir" task-x1 2> "$case_dir/stderr") + + assert_contains "$out" 'diff base: origin/develop' "declared-base: the review base is not the declared branch" + assert_contains "$out" '+task-work' "declared-base: the task's own change is missing from the diff" + assert_not_contains "$out" 'develop-only' \ + "declared-base: the diff carried integration-branch commits the task did not write" + pass "fm-review-diff diffs a declared-integration project against origin/, not origin/HEAD" +} + +# An entry with no declaration keeps origin/HEAD, so unannotated projects review +# exactly as they did before the declaration existed. +test_legacy_entry_keeps_the_remote_default_diff_base() { + local case_dir out + case_dir=$(make_case legacy-base) + printf -- '- project [no-mistakes] - fixture (added 2026-09-01)\n' \ + > "$case_dir/data/projects.md" + printf 'task-work\n' > "$case_dir/wt/task.txt" + git -C "$case_dir/wt" add task.txt + git -C "$case_dir/wt" commit -qm "task work" + write_task_meta "$case_dir" + + out=$(run_review_diff "$case_dir" task-x1 2> "$case_dir/stderr") + + assert_contains "$out" 'diff base: origin/main' "legacy-base: an undeclared project left origin/HEAD" + assert_contains "$out" '+task-work' "legacy-base: the task's change is missing from the diff" + pass "fm-review-diff keeps origin/ for a registry entry with no declaration" +} + +# A declaration git itself rejects must refuse, not quietly review against main. +test_invalid_declared_integration_branch_refuses_the_diff() { + local case_dir out status err + case_dir=$(make_case invalid-base) + declare_integration_branch "$case_dir" '..bad' + printf 'task-work\n' > "$case_dir/wt/task.txt" + git -C "$case_dir/wt" add task.txt + git -C "$case_dir/wt" commit -qm "task work" + write_task_meta "$case_dir" + + set +e + out=$(run_review_diff "$case_dir" task-x1 2> "$case_dir/stderr") + status=$? + set -e + err=$(cat "$case_dir/stderr") + + [ "$status" -ne 0 ] || fail "invalid-base: review diff succeeded on an invalid declaration" + assert_not_contains "$out" 'diff base:' "invalid-base: an invalid declaration still produced a diff" + assert_contains "$err" 'invalid integration branch' \ + "invalid-base: the refusal did not surface the registry diagnostic" + pass "fm-review-diff refuses an invalid integration-branch declaration instead of reviewing against origin/HEAD" +} + test_pr_meta_uses_pr_head_not_stale_local() { local case_dir out case_dir=$(make_case pr-head-sha) @@ -169,6 +253,9 @@ test_unreachable_pr_head_falls_back_with_warning() { pass "fm-review-diff falls back to local branch with a warning when PR head is unreachable" } +test_declared_integration_branch_is_the_diff_base +test_legacy_entry_keeps_the_remote_default_diff_base +test_invalid_declared_integration_branch_refuses_the_diff 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 diff --git a/tests/fm-task-delivery.test.sh b/tests/fm-task-delivery.test.sh index e5acfbbca39..9db9e2d4cd6 100755 --- a/tests/fm-task-delivery.test.sh +++ b/tests/fm-task-delivery.test.sh @@ -433,10 +433,10 @@ EOF # --integration-branch reads only the structured bracket annotation: the same # free-form text in the description is registry prose, never a declaration, and a -# branch name git itself would reject falls back to the caller's remote default -# with a warning rather than being handed on as a ref. +# branch name git itself would reject fails the query with a diagnostic rather +# than being handed on as a ref or downgraded to a silent remote-default fallback. test_project_mode_reads_only_the_structured_integration_branch() { - local home out err project + local home out err project status home="$TMP_ROOT/integration-branch/home" mkdir -p "$home/data" cat > "$home/data/projects.md" <<'EOF' @@ -472,10 +472,25 @@ EOF out=$(FM_HOME="$home" "$PROJECT_MODE" legacy 2>/dev/null) [ "$out" = "no-mistakes on" ] || fail "an undeclared entry lost its posture (got '$out')" - out=$(FM_HOME="$home" "$PROJECT_MODE" --integration-branch badbranch 2>/dev/null) + out=$(FM_HOME="$home" "$PROJECT_MODE" --integration-branch badbranch 2>/dev/null) && status=0 || status=$? + [ "$status" -ne 0 ] || fail "an invalid branch name resolved successfully (got '$out')" [ -z "$out" ] || fail "an invalid branch name was handed on as a ref (got '$out')" - err=$(FM_HOME="$home" "$PROJECT_MODE" --integration-branch badbranch 2>&1 >/dev/null) - assert_contains "$err" "invalid integration branch" "an invalid declared branch fell back silently" + err=$(FM_HOME="$home" "$PROJECT_MODE" --integration-branch badbranch 2>&1 >/dev/null || true) + assert_contains "$err" "invalid integration branch" "an invalid declared branch failed without saying why" + + # The shared resolver must propagate that failure instead of reporting "no + # declaration", which would silently base the clone and every task copy on the + # remote default - the exact drift the declaration exists to stop. + out=$(FM_HOME="$home" bash -c '. "$1"; declared_integration_branch badbranch' _ \ + "$ROOT/bin/fm-integration-branch-lib.sh" 2>/dev/null) && status=0 || status=$? + [ "$status" -ne 0 ] || fail "the shared resolver swallowed an invalid declaration (got '$out')" + out=$(FM_HOME="$home" bash -c '. "$1"; integration_branch "$2" badbranch' _ \ + "$ROOT/bin/fm-integration-branch-lib.sh" "$ROOT" 2>/dev/null) && status=0 || status=$? + [ "$status" -ne 0 ] || fail "the shared resolver fell back to a default branch (got '$out')" + out=$(FM_HOME="$home" bash -c '. "$1"; integration_branch "$2" declared' _ \ + "$ROOT/bin/fm-integration-branch-lib.sh" "$ROOT") \ + || fail "the shared resolver failed on a valid declaration" + [ "$out" = develop ] || fail "the shared resolver did not return the declaration (got '$out')" out=$(FM_HOME="$TMP_ROOT/integration-branch/absent" "$PROJECT_MODE" --integration-branch declared 2>&1) [ -z "$out" ] || fail "a home with no registry reported an integration branch (got '$out')" diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index c5c274b7d8e..b832aa09ce0 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -783,6 +783,63 @@ test_local_only_merged_to_local_main_allows() { pass "local-only worktree with work merged into local main is torn down (no regression)" } +# A local-only task is cut from the project's declared integration branch, so the +# unmerged-work refusal must test against that branch. Testing against main would +# refuse work that has already landed on develop, and bin/fm-merge-local.sh - the +# landing the refusal points at - must fast-forward the same branch. +test_local_only_merged_to_declared_integration_branch_allows() { + local case_dir rc wt_head out + case_dir=$(make_case merged-declared) + printf -- '- project [local-only integration-branch=develop] - fixture (added 2026-09-01)\n' \ + > "$case_dir/data/projects.md" + git -C "$case_dir/project" branch -q develop main + git -C "$case_dir/project" checkout -q develop + write_meta "$case_dir" local-only ship + wt_commit "$case_dir" "merged work" + wt_head=$(git -C "$case_dir/wt" rev-parse HEAD) + + # Landing through the real gate action proves both consumers agree on develop. + out=$(FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$case_dir/state" \ + FM_DATA_OVERRIDE="$case_dir/data" "$ROOT/bin/fm-merge-local.sh" task-x1 2>&1) \ + || fail "merged-declared: local landing refused the declared branch: $out" + [ "$(git -C "$case_dir/project" rev-parse develop)" = "$wt_head" ] \ + || fail "merged-declared: local landing did not fast-forward develop" + [ "$(git -C "$case_dir/project" rev-parse main)" != "$wt_head" ] \ + || fail "merged-declared: local landing moved main instead of the declared branch" + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 0 "$rc" "merged-declared: teardown should accept work merged into the declared branch" + ! grep -q REFUSED "$case_dir/stderr" || fail "merged-declared: teardown printed a REFUSED line" + pass "local-only work landed on the declared integration branch lands there and clears teardown" +} + +# The same fixture without a landing: the refusal must name the declared branch, +# so an operator is not told to merge into a branch nothing is based on. +test_local_only_unmerged_refusal_names_the_declared_branch() { + local case_dir rc err + case_dir=$(make_case unmerged-declared) + printf -- '- project [local-only integration-branch=develop] - fixture (added 2026-09-01)\n' \ + > "$case_dir/data/projects.md" + git -C "$case_dir/project" branch -q develop main + write_meta "$case_dir" local-only ship + wt_commit "$case_dir" "unlanded work" + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + err=$(cat "$case_dir/stderr") + + [ "$rc" -ne 0 ] || fail "unmerged-declared: teardown discarded unlanded local-only work" + assert_contains "$err" "not yet merged into develop" \ + "unmerged-declared: the refusal did not name the declared integration branch" + pass "an unlanded local-only worktree is refused against its declared integration branch" +} + test_no_mistakes_origin_remote_allows() { local case_dir rc case_dir=$(make_case nm-origin) @@ -3653,6 +3710,8 @@ 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_local_only_merged_to_declared_integration_branch_allows +test_local_only_unmerged_refusal_names_the_declared_branch test_no_mistakes_origin_remote_allows test_no_mistakes_truly_unpushed_refuses test_local_only_force_overrides_unpushed From b5a882b9fc8279e43a72208aed380ee42795d376 Mon Sep 17 00:00:00 2001 From: Alex William Date: Tue, 8 Sep 2026 18:44:08 +0200 Subject: [PATCH 4/6] no-mistakes(review): Enforce declared integration branches across spawn base selection --- bin/fm-project-mode.sh | 13 +++-- bin/fm-spawn.sh | 44 +++++++-------- tests/fm-spawn-pool-base-freshen.test.sh | 69 ++++++++++++++++++++++-- 3 files changed, 95 insertions(+), 31 deletions(-) diff --git a/bin/fm-project-mode.sh b/bin/fm-project-mode.sh index 9f8c44355a8..622f946766c 100755 --- a/bin/fm-project-mode.sh +++ b/bin/fm-project-mode.sh @@ -87,7 +87,7 @@ parsed=$(awk -v n="$NAME" ' if (a[1] != "" && a[1] != "+yolo" && a[1] !~ /^integration-branch=/) mode = a[1]; for (j=1; j<=k; j++) { if (a[j]=="+yolo") yolo="on"; - if (a[j] ~ /^integration-branch=/) integration=substr(a[j], 20); + if (a[j] ~ /^integration-branch=/) integration=a[j]; } } print mode, yolo, integration; exit @@ -108,11 +108,14 @@ yolo=${parsed#* } yolo=${yolo%% *} integration_branch=${parsed##* } if [ "$QUERY" = integration-branch ]; then - if [ -n "$integration_branch" ] && ! git check-ref-format --branch "$integration_branch" >/dev/null 2>&1; then - echo "error: invalid integration branch \"$integration_branch\" for $NAME; fix the registry entry" >&2 - exit 1 + if [ -n "$integration_branch" ]; then + integration_branch=${integration_branch#integration-branch=} + if ! git check-ref-format --branch "$integration_branch" >/dev/null 2>&1; then + echo "error: invalid integration branch \"$integration_branch\" for $NAME; fix the registry entry" >&2 + exit 1 + fi fi - [ "$integration_branch" = "-" ] || printf '%s\n' "$integration_branch" + printf '%s\n' "$integration_branch" exit 0 fi case "$mode" in diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 1171f3ac080..873236515a4 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -2410,28 +2410,30 @@ freshen_spawn_worktree_base() { # fi return 1 fi + default=$(declared_integration_branch "$project") || return 1 if ! spawn_worktree_has_origin_config "$worktree"; then - return 0 - fi - 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 - # A registered integration branch is the base new task copies must be cut from, - # so a project that integrates on develop never starts work from origin/main. - # An unannotated project keeps origin's default branch (fm-integration-branch-lib.sh). - default=$(integration_branch "$worktree" "$project") || { - echo "error: could not determine the integration branch 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 + [ -n "$default" ] || return 0 + target="refs/heads/$default" + else + 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 [ -z "$default" ]; then + 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=$(integration_branch "$worktree" "$project") || { + echo "error: could not determine the integration branch for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 + return 1 + } + fi + 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 + fi 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 diff --git a/tests/fm-spawn-pool-base-freshen.test.sh b/tests/fm-spawn-pool-base-freshen.test.sh index dcd5c36ff0d..ea06ca8d308 100755 --- a/tests/fm-spawn-pool-base-freshen.test.sh +++ b/tests/fm-spawn-pool-base-freshen.test.sh @@ -413,9 +413,9 @@ test_direct_pr_and_scout_refresh_before_launch() { # copies cut from that declared branch, or fleet sync reports the clone current on # develop while live work is based on main. test_declared_integration_branch_bases_the_task_copy() { - local rec id out status publisher declared_tip - id='pool-integration-branch-r1' - rec=$(make_case integration-branch "$id") + local rec id out status publisher declared_tip remote_head=${1:-main} + id="pool-integration-branch-$remote_head-r1" + rec=$(make_case "integration-branch-$remote_head" "$id") read_case_record "$rec" printf -- '- project [no-mistakes integration-branch=develop] - fixture (added 2026-09-01)\n' \ > "$HOME_DIR/data/projects.md" @@ -426,6 +426,7 @@ test_declared_integration_branch_bases_the_task_copy() { git -C "$publisher" -c user.name='Firstmate Tests' -c user.email='tests@example.invalid' commit -qm advance-develop git -C "$publisher" push --quiet origin develop declared_tip=$(git -C "$publisher" rev-parse HEAD) + git --git-dir="$CASE_DIR/origin.git" symbolic-ref HEAD "refs/heads/$remote_head" out=$(run_spawn "$id" --mode no-mistakes --yolo off) status=$? @@ -436,8 +437,8 @@ test_declared_integration_branch_bases_the_task_copy() { || fail "fixture did not keep origin/develop distinct from origin/main" assert_grep 'only on the declared integration branch' "$POOL_DIR/develop-only.txt" \ "the task copy omitted content that exists only on the declared branch" - [ "$(git --git-dir="$CASE_DIR/origin.git" symbolic-ref --short HEAD)" = main ] \ - || fail "fixture origin default branch is no longer main" + [ "$(git --git-dir="$CASE_DIR/origin.git" symbolic-ref --short HEAD)" = "$remote_head" ] \ + || fail "fixture origin default branch changed" pass "a declared integration branch, not origin/HEAD, is the base of a new task copy" } @@ -730,6 +731,64 @@ test_stale_pin_beside_other_dirt_reports_one_verdict() { pass "a stale pin beside other dirt yields the conservative refusal alone, with no stale-pin line" } +test_originless_declared_base() { + local variant rec id out status tip declaration + for variant in develop missing malformed empty dirty; do + id="pool-local-declared-$variant" + rec=$(make_originless_case "local-declared-$variant" "$id") + read_case_record "$rec" + git -C "$PROJECT_DIR" checkout --quiet -b develop + printf 'local integration content\n' > "$PROJECT_DIR/local.txt" + git -C "$PROJECT_DIR" add local.txt + git -C "$PROJECT_DIR" -c user.name='Firstmate Tests' -c user.email='tests@example.invalid' commit -qm develop + tip=$(git -C "$PROJECT_DIR" rev-parse HEAD) + declaration=develop + case "$variant" in + missing) declaration=missing ;; + malformed) declaration=..bad ;; + empty) declaration='' ;; + dirty) printf 'preserve me\n' > "$POOL_DIR/uncommitted.txt" ;; + esac + printf -- '- project [local-only integration-branch=%s] - fixture\n' "$declaration" > "$HOME_DIR/data/projects.md" + out=$(run_spawn "$id" --mode local-only --yolo off) + status=$? + if [ "$variant" = develop ]; then + expect_code 0 "$status" "originless declared spawn should launch"$'\n'"$out" + [ "$(git -C "$POOL_DIR" rev-parse HEAD)" = "$tip" ] || fail "local task did not start on develop" + [ "$tip" != "$INITIAL_SHA" ] || fail "local develop did not advance" + else + [ "$status" -ne 0 ] || fail "originless $variant declaration launched" + case "$variant" in + missing) assert_contains "$out" "'refs/heads/missing' is not a commit" "missing local branch not diagnosed" ;; + malformed|empty) assert_contains "$out" 'invalid integration branch' "invalid declaration not diagnosed" ;; + dirty) assert_contains "$out" 'is not clean' "dirty pool not diagnosed" + assert_grep 'preserve me' "$POOL_DIR/uncommitted.txt" "dirty work discarded" ;; + esac + [ "$(git -C "$POOL_DIR" rev-parse HEAD)" = "$INITIAL_SHA" ] || fail "refusal moved the local pool" + [ ! -e "$HOME_DIR/state/$id.meta" ] || fail "refusal published task metadata" + fi + pass "originless declared base: $variant" + done +} + +test_empty_remote_declaration_refuses() { + local rec id out status + id=pool-empty-remote-declaration + rec=$(make_case empty-remote-declaration "$id") + read_case_record "$rec" + printf -- '- project [no-mistakes integration-branch=] - fixture\n' > "$HOME_DIR/data/projects.md" + out=$(run_spawn "$id" --mode no-mistakes --yolo off) + status=$? + [ "$status" -ne 0 ] || fail "empty remote declaration launched" + assert_contains "$out" 'invalid integration branch' "empty remote declaration not diagnosed" + [ "$(git -C "$POOL_DIR" rev-parse HEAD)" = "$INITIAL_SHA" ] || fail "empty declaration moved pool" + [ ! -e "$HOME_DIR/state/$id.meta" ] || fail "empty declaration published task metadata" + pass "empty remote declaration refuses without fallback" +} + +test_originless_declared_base +test_empty_remote_declaration_refuses +test_declared_integration_branch_bases_the_task_copy missing-default test_remote_seeded_home_spawns_from_treehouse_pool test_linked_spawning_home_rejects_primary_before_refresh test_stale_pool_base_refreshes_before_branching From e2204e97613464ceefeff1a3bd08ca4f80c650c4 Mon Sep 17 00:00:00 2001 From: Alex William Date: Tue, 8 Sep 2026 19:10:55 +0200 Subject: [PATCH 5/6] no-mistakes(document): Align integration-branch documentation and remove stale default assumptions --- .agents/skills/bootstrap-diagnostics/SKILL.md | 4 ++-- .agents/skills/secondmate-provisioning/SKILL.md | 2 +- AGENTS.md | 2 +- bin/fm-integration-branch-lib.sh | 3 ++- docs/architecture.md | 6 +++--- docs/scripts.md | 2 +- 6 files changed, 10 insertions(+), 9 deletions(-) diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index fd6926b2183..c10aec20e1d 100644 --- a/.agents/skills/bootstrap-diagnostics/SKILL.md +++ b/.agents/skills/bootstrap-diagnostics/SKILL.md @@ -37,8 +37,8 @@ When any diagnostic needs captain attention, report the plain consequence and re - `CREW_DISPATCH: invalid config/crew-dispatch.json - ` - the optional dispatch profile file exists but failed low-cost bootstrap validation; stop profile-based dispatch, report the actionable error, and require correction of the malformed schema, unverified harness name, or invalid harness/effort pair rather than falling back around it or selecting a bad profile. - `FLEET_SYNC: : skipped: ` - a benign one-off skip (offline, no origin, local-only); bootstrap continued, investigate only if it blocks work. A skip can also report the bounded fleet-refresh timeout (`FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT`, or a fleet-size-aware default with a 20 second floor); a timeout never blocks startup. -- `FLEET_SYNC: : recovered: ` - the clone had drifted onto a clean detached HEAD holding no unique commits and the sync self-healed it (re-attached the default branch and fast-forwarded); no action needed, it is reported only so the self-heal is visible. -- `FLEET_SYNC: : STUCK: on , N commits behind - needs attention` - the clone is dirty, on a non-default branch, detached with unique commits, or diverged, so the sync left it untouched (never forcing or discarding); it will keep falling behind until you look. +- `FLEET_SYNC: : recovered: ` - no action needed; the guarded recovery described in `docs/architecture.md` under project-clone refresh succeeded. +- `FLEET_SYNC: : STUCK: ` - inspect the named blocker under the project-clone refresh contract in `docs/architecture.md`; preserve work, and correct invalid or unpublished registry declarations rather than substituting another base. A loud STUCK, especially a growing N across bootstraps, means that clone needs hands-on attention; dispatch a crewmate or resolve it before it strands work. - `HOME_SUMMARY: this home has never published state/home-summary.json` or `... has not been republished since ` - this home's structured summary publication has failed repeatedly, and the line carries the failure count and the newest recorded reason from `state/.home-summary-refresh.log`. Publication is deliberately best-effort, so it cannot change another session-start, spawn, teardown, or watcher-poll result, and the watcher runs it detached so a slow attempt cannot delay the liveness beacon. diff --git a/.agents/skills/secondmate-provisioning/SKILL.md b/.agents/skills/secondmate-provisioning/SKILL.md index 46c856eb204..0a244aaef88 100644 --- a/.agents/skills/secondmate-provisioning/SKILL.md +++ b/.agents/skills/secondmate-provisioning/SKILL.md @@ -169,7 +169,7 @@ Both of those cases require record intake before the new mate acts on any inheri For an existing or inherited domain, the creating agent must: -1. Reconcile every inherited plan against the domain's authoritative shipped state, which is `origin/main` for each relevant project plus the live deployment. +1. Reconcile every inherited plan against the domain's authoritative shipped state, using each relevant project's integration base from `bin/fm-integration-branch-lib.sh` plus the live deployment. A fetched clone of each relevant project is a precondition of that reconciliation, so wire the home to its projects before reconciling rather than on first task. The imported backlog, the predecessor's own notes, instruction-surface prose, and an absent or unfetched local view are all inadmissible as shipped-state evidence. 2. Seed the new home with only genuinely open work plus the domain's durable knowledge, meaning the learnings, decisions, and delivery posture that are still live. diff --git a/AGENTS.md b/AGENTS.md index ca09ab7a4cf..5160d3e9481 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -398,7 +398,7 @@ A report may recommend implementation but does not authorize it. Before treating the investigation or any visual review as complete, load `captain-hold-lifecycle`; teardown enforces that shared completion gate. When a scout's deliverable is a visual artifact the captain will iterate on, prefer keeping that scout alive to host its own Lavish loop rather than tearing it down and mediating from firstmate, so the scout keeps its investigation context and the captain iterates in one continuous session. When implementation is separately authorized, promote the existing scout through `bin/fm-promote.sh` rather than creating a duplicate task. -The promoted worker must inventory scratch state, return to a clean default-branch base, carry over only intended fix changes, create the ship branch, and follow the project's selected delivery path while leaving scratch commits and debug edits behind and turning a reproduced bug into the regression test. +The promoted worker must inventory scratch state, return to a clean project integration base (resolved by `bin/fm-integration-branch-lib.sh`), carry over only intended fix changes, create the ship branch, and follow the project's selected delivery path while leaving scratch commits and debug edits behind and turning a reproduced bug into the regression test. ## 8. Supervision protocol diff --git a/bin/fm-integration-branch-lib.sh b/bin/fm-integration-branch-lib.sh index 6fee5a2dd22..af14a6d1593 100644 --- a/bin/fm-integration-branch-lib.sh +++ b/bin/fm-integration-branch-lib.sh @@ -8,6 +8,7 @@ # every path that has to pick a base branch - bin/fm-fleet-sync.sh's refresh # target, the branch bin/fm-home-seed.sh and bin/fm-remote-home-provision.sh # check out in a new clone, the base bin/fm-spawn.sh resets a pooled worktree to, +# using origin/ with a remote or the verified local branch without one, # the base bin/fm-review-diff.sh diffs a task against, and the branch # bin/fm-teardown.sh tests landed content and unmerged local-only work against # and bin/fm-merge-local.sh fast-forwards - so a project cannot be synced, @@ -15,7 +16,7 @@ # # A project that declares nothing keeps the legacy resolution, origin's default # branch, so unannotated homes behave exactly as before. A declaration that is -# not a valid branch name is never silently downgraded to that fallback: the +# empty or not a valid branch name is never downgraded to that fallback: the # query fails, its diagnostic reaches the caller's stderr, and every consumer # refuses rather than working from a base the registry did not ask for. # Callers pass the registry home through the same FM_HOME/FM_DATA_OVERRIDE diff --git a/docs/architecture.md b/docs/architecture.md index cbe7cf633be..5799be6ea90 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -389,9 +389,9 @@ Invoked in a primary home, `/stow` then cascades the same sweep to every registe The locked session-start deferred network stage, PR-based teardown, and merged-PR wake handling refresh remote-backed project clones when the clone is safe to move. Wake-time refreshes can target a single clone by project name, so the primary home also catches up when a secondmate reports a merge from its own home. -A registry entry with `integration-branch=` makes that declared branch the base every path picks: fleet sync compares and fast-forwards it, a newly seeded local or remote clone is checked out on it, a spawn resets its pooled worktree to `origin/` so task copies are cut from it, the review diff is taken against it, and the landed-content, unmerged-work and local-only landing checks all test against it. -`bin/fm-integration-branch-lib.sh` is the one resolver: a declaration that is not a valid branch name fails the query with a diagnostic instead of falling back, a declared branch the origin does not publish refuses the seed or the spawn, and fleet sync reports both as `STUCK:` rather than a benign skip. -A legacy entry without the declaration retains remote-default resolution and its existing behavior, and an already-seeded clone is never switched. +[`fm-integration-branch-lib.sh`](../bin/fm-integration-branch-lib.sh) owns project base resolution across refresh, seeding, spawn, review, cleanup, and local landing; [`fm-project-mode.sh`](../bin/fm-project-mode.sh) owns the structured registry declaration format. +Fleet sync reports invalid declarations and declared branches missing from origin as `STUCK:` rather than silently selecting another base. +Seeding checks out the selected branch only in newly created clones; it never switches an already-seeded clone. Clean target-branch clones fast-forward to `origin/`, and a clean detached HEAD that holds no unique commits is re-attached to the target branch before the same fast-forward path runs. Dirty clones, non-target branches, detached HEADs with unique commits, diverged targets, and target branches checked out in another worktree are reported as `STUCK:` with their behind count and left untouched. Fetches blocked by an orphaned `.git/packed-refs.lock` use bounded retries and remove the lock only when the shared staleness proof can prove it abandoned; [configuration.md](configuration.md#toolchain) owns the recovery details and tuning knobs. diff --git a/docs/scripts.md b/docs/scripts.md index 43d692db5a8..ae1d86f3556 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -67,7 +67,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `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-merge-local.sh` | Fast-forward a `local-only` project's local default branch after approval | +| `fm-merge-local.sh` | Approved `local-only` landing ([branch contract](../bin/fm-merge-local.sh)) | | `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` | | `fm-task-inbox-lib.sh` | Single owner of durable steering-inbox records, acknowledgement, doorbells, and the delivery-attempt ladder | From 8c7a9e4d9f13d867d50dcd8bd8b7814e1f3f9972 Mon Sep 17 00:00:00 2001 From: Alex William Date: Tue, 8 Sep 2026 19:13:06 +0200 Subject: [PATCH 6/6] no-mistakes(lint): Quote hyphenated test identifier to satisfy ShellCheck --- tests/fm-spawn-pool-base-freshen.test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fm-spawn-pool-base-freshen.test.sh b/tests/fm-spawn-pool-base-freshen.test.sh index ea06ca8d308..dfc11c6bfd0 100755 --- a/tests/fm-spawn-pool-base-freshen.test.sh +++ b/tests/fm-spawn-pool-base-freshen.test.sh @@ -773,7 +773,7 @@ test_originless_declared_base() { test_empty_remote_declaration_refuses() { local rec id out status - id=pool-empty-remote-declaration + id='pool-empty-remote-declaration' rec=$(make_case empty-remote-declaration "$id") read_case_record "$rec" printf -- '- project [no-mistakes integration-branch=] - fixture\n' > "$HOME_DIR/data/projects.md"