diff --git a/bin/fm-ff-lib.sh b/bin/fm-ff-lib.sh index e6d6801a79..07d61190cf 100644 --- a/bin/fm-ff-lib.sh +++ b/bin/fm-ff-lib.sh @@ -17,14 +17,19 @@ # A tracked-files fast-forward never touches the gitignored operational dirs # (data/, state/, config/, projects/, .no-mistakes/), so it cannot disturb a # secondmate's backlog, projects, or in-flight work. -# The seeded .fm-secondmate-home identity marker is gitignored too; the local -# sync tolerates only that marker during the one-time upgrade of pre-ignore -# linked-worktree homes. +# The shared dirt gate preserves an exact untracked root treehouse.toml operator +# machine configuration while every other porcelain record blocks fast-forward. +# The seeded .fm-secondmate-home identity marker is gitignored too; local sync +# additionally tolerates that exact marker only during the one-time upgrade of +# pre-ignore linked-worktree homes. # Homes are leased at a detached HEAD on the # default branch, so the fast-forward advances HEAD only and never moves the # shared default branch or any other worktree's checkout. SUB_HOME_MARKER="${SUB_HOME_MARKER:-.fm-secondmate-home}" +# Exact untracked root paths known to be operator-owned machine configuration. +# Keep this allowlist explicit: every other status entry must block fast-forward. +TOLERATED_UNTRACKED_LOCAL_CONFIG="treehouse.toml" # --- helpers --------------------------------------------------------------- @@ -224,11 +229,17 @@ changed_instr() { dirty_status() { local dir=$1 ignore_seed_marker=${2:-no} - if [ "$ignore_seed_marker" = yes ]; then - git -C "$dir" status --porcelain 2>/dev/null | awk -v marker="?? $SUB_HOME_MARKER" '$0 != marker { print; exit }' - else - git -C "$dir" status --porcelain 2>/dev/null | head -1 - fi + # Match complete porcelain records so similarly named paths remain dirty. + git -C "$dir" status --porcelain 2>/dev/null \ + | awk \ + -v ignore_seed_marker="$ignore_seed_marker" \ + -v local_config="?? $TOLERATED_UNTRACKED_LOCAL_CONFIG" \ + -v seed_marker="?? $SUB_HOME_MARKER" \ + ' + $0 == local_config { next } + ignore_seed_marker == "yes" && $0 == seed_marker { next } + { print; exit } + ' } secondmate_registry_field() { diff --git a/bin/fm-fleet-sync.sh b/bin/fm-fleet-sync.sh index 5c338edf68..985c2f7df2 100755 --- a/bin/fm-fleet-sync.sh +++ b/bin/fm-fleet-sync.sh @@ -6,11 +6,11 @@ # Self-heals the one unambiguously safe drift: a clean, detached HEAD that holds # no unique commits (it is an ancestor of origin/) and whose # 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 -# is left untouched and reported as a quantified, loud "STUCK: ... N commits behind -# ... - needs attention" warning rather than a quiet drift. Nothing is ever forced, -# stashed, or discarded. +# Every other unsafe state - a non-default named branch, a detached HEAD with +# unique commits, a tree rejected by fm-ff-lib.sh's shared dirty-status gate, or +# a diverged default - may hold real work, so it is left untouched and reported +# as a quantified, loud "STUCK: ... N commits behind ... - needs attention" +# warning rather than a quiet drift. Nothing is ever forced, stashed, or discarded. # Still skips (benignly) local-only/no-origin projects, missing remotes/branches, # and fetch failures. # Pruning never deletes the checked-out branch or a branch that still has a @@ -33,6 +33,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}" +# shellcheck source=bin/fm-ff-lib.sh +. "$SCRIPT_DIR/fm-ff-lib.sh" # shellcheck source=bin/fm-lock-lib.sh . "$SCRIPT_DIR/fm-lock-lib.sh" FM_LOCK_LOG_PREFIX=fleet-sync @@ -107,26 +109,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' -} - # True when git stderr shows the packed-refs.lock "File exists" race. The lock # path can appear anywhere in the message (git prefixes it with the failed ref op, # e.g. "could not delete reference ...:"). Other "File exists" errors must not match. @@ -323,7 +305,7 @@ sync_project() { prune_gone_branches || true - DEFAULT=$(default_branch) || { + DEFAULT=$(default_branch "$PROJ") || { echo "$label: skipped: cannot determine default branch" return 0 } @@ -335,7 +317,7 @@ sync_project() { cur=$(git -C "$PROJ" symbolic-ref --short HEAD 2>/dev/null || echo "") dirty=no - [ -z "$(git -C "$PROJ" status --porcelain 2>/dev/null | head -1)" ] || dirty=yes + [ -z "$(dirty_status "$PROJ")" ] || dirty=yes recovered=no if [ "$cur" != "$DEFAULT" ]; then diff --git a/docs/architecture.md b/docs/architecture.md index d1bcb83c56..c88ecfe2ef 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -246,7 +246,8 @@ Generalizable firstmate knowledge goes to shared tracked docs through the normal The locked session-start bootstrap step, 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. +An exact untracked root-level `treehouse.toml` operator machine configuration is preserved and does not make the clone unsafe to move. +Every other dirty status, non-default branch, detached HEAD with unique commits, diverged default, or default branch checked out in another worktree is reported as `STUCK:` with its 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. @@ -254,7 +255,7 @@ The refresh also prunes local branches whose remote is gone and that no worktree ## Self-updates stay safe `/updatefirstmate` fast-forwards the running firstmate repo and registered secondmate homes from `origin`, then re-reads updated instructions and nudges updated secondmates without touching project clones. -The update is fast-forward only: dirty, diverged, offline, and off-default targets are reported and left untouched. +The update is fast-forward only: targets rejected by the shared dirty-status gate, diverged targets, offline targets, and off-default targets are reported and left untouched. The origin-based updater and the local secondmate sync share the same guarded fast-forward helper; only the origin mode fetches. The mechanics are owned by the `/updatefirstmate` skill and firstmate's operating manual in [`AGENTS.md`](../AGENTS.md) (self-update). diff --git a/docs/configuration.md b/docs/configuration.md index adf215b408..435f600c23 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -157,7 +157,7 @@ Set `FM_SECONDMATE_CHARTER` to seed from inline charter text when no filled char The seeded home's `data/charter.md` owns the standard secondmate lifecycle and escalation contract; the route file points to it through the existing `home:` field instead of adding another pointer. Each seed writes an `.fm-secondmate-home` identity marker at the home root. The tracked root `.gitignore` ignores that marker, so validation can read it without making a freshly seeded home appear dirty to porcelain-based safety checks. -This does not relax protection for any other untracked file. +This conditional marker tolerance does not relax protection for any other untracked file; the shared helper's separate operator-machine-config rule is documented under [Local clones stay fresh](architecture.md#local-clones-stay-fresh). An existing linked-worktree home that predates this rule advances through its marker-only state during its next bootstrap or spawn local sync, after which Git ignores the marker normally. A standalone-clone home cannot receive a primary-local commit through that no-fetch sync, so it receives the rule through `/updatefirstmate`'s origin refresh instead. diff --git a/tests/fm-fleet-sync.test.sh b/tests/fm-fleet-sync.test.sh index b1fcd0a38e..489f65375b 100755 --- a/tests/fm-fleet-sync.test.sh +++ b/tests/fm-fleet-sync.test.sh @@ -276,6 +276,90 @@ test_dirty_is_stuck_untouched() { pass "dirty working tree is reported STUCK and left untouched" } +test_treehouse_local_config_does_not_block_sync() { + local home clone out before + home=$(new_home) + clone=$(build_pair "$home" local-config) + advance_origin "$home" local-config C1 + before=$(head_sha "$clone") + printf 'root = "/tmp/treehouse-pool"\n' > "$clone/treehouse.toml" + + out=$(run_sync "$home" "$clone") + + assert_contains "$out" "local-config: synced" "treehouse local config permits sync" + assert_not_contains "$out" "STUCK" "treehouse local config is not reported as dirt" + [ "$(head_sha "$clone")" != "$before" ] || fail "treehouse local config blocked the fast-forward" + grep -qxF 'root = "/tmp/treehouse-pool"' "$clone/treehouse.toml" \ + || fail "treehouse local config was changed or removed" + pass "an exact untracked treehouse.toml is tolerated and preserved" +} + +test_modified_tracked_file_still_blocks_sync() { + local home clone out before + home=$(new_home) + clone=$(build_pair "$home" modified-tracked) + advance_origin "$home" modified-tracked C1 + before=$(head_sha "$clone") + printf 'local edit\n' >> "$clone/file.txt" + + out=$(run_sync "$home" "$clone") + + assert_contains "$out" "modified-tracked: STUCK:" "modified tracked file reports STUCK" + [ "$(head_sha "$clone")" = "$before" ] || fail "modified tracked clone HEAD moved" + grep -q 'local edit' "$clone/file.txt" || fail "modified tracked content was discarded" + pass "a modified tracked file still blocks fleet sync" +} + +test_staged_change_still_blocks_sync() { + local home clone out before + home=$(new_home) + clone=$(build_pair "$home" staged-change) + advance_origin "$home" staged-change C1 + before=$(head_sha "$clone") + printf 'staged work\n' > "$clone/staged.txt" + git -C "$clone" add staged.txt + + out=$(run_sync "$home" "$clone") + + assert_contains "$out" "staged-change: STUCK:" "staged change reports STUCK" + [ "$(head_sha "$clone")" = "$before" ] || fail "staged-change clone HEAD moved" + [ "$(git -C "$clone" status --porcelain -- staged.txt)" = "A staged.txt" ] \ + || fail "staged change was altered" + pass "a staged change still blocks fleet sync" +} + +test_other_untracked_file_still_blocks_sync() { + local home clone out before + home=$(new_home) + clone=$(build_pair "$home" other-untracked) + advance_origin "$home" other-untracked C1 + before=$(head_sha "$clone") + printf 'local notes\n' > "$clone/notes.txt" + + out=$(run_sync "$home" "$clone") + + assert_contains "$out" "other-untracked: STUCK:" "other untracked file reports STUCK" + [ "$(head_sha "$clone")" = "$before" ] || fail "other-untracked clone HEAD moved" + [ -f "$clone/notes.txt" ] || fail "other untracked file was removed" + pass "an unrelated untracked file still blocks fleet sync" +} + +test_treehouse_name_lookalike_still_blocks_sync() { + local home clone out before + home=$(new_home) + clone=$(build_pair "$home" local-config-lookalike) + advance_origin "$home" local-config-lookalike C1 + before=$(head_sha "$clone") + printf 'local notes\n' > "$clone/treehouse.toml.backup" + + out=$(run_sync "$home" "$clone") + + assert_contains "$out" "local-config-lookalike: STUCK:" "treehouse name lookalike reports STUCK" + [ "$(head_sha "$clone")" = "$before" ] || fail "treehouse-name-lookalike clone HEAD moved" + [ -f "$clone/treehouse.toml.backup" ] || fail "treehouse name lookalike was removed" + pass "an untracked name containing treehouse.toml still blocks fleet sync" +} + test_non_default_branch_is_stuck_untouched() { local home clone out home=$(new_home) @@ -607,6 +691,11 @@ test_detached_clean_ancestor_recovers test_detached_unique_commit_is_stuck_untouched test_detached_clean_ancestor_with_diverged_local_default_is_stuck_untouched test_dirty_is_stuck_untouched +test_treehouse_local_config_does_not_block_sync +test_modified_tracked_file_still_blocks_sync +test_staged_change_still_blocks_sync +test_other_untracked_file_still_blocks_sync +test_treehouse_name_lookalike_still_blocks_sync test_non_default_branch_is_stuck_untouched test_diverged_is_stuck_untouched test_on_default_clean_behind_fast_forwards diff --git a/tests/fm-secondmate-sync.test.sh b/tests/fm-secondmate-sync.test.sh index 79781e28ad..2a3b4aba2c 100755 --- a/tests/fm-secondmate-sync.test.sh +++ b/tests/fm-secondmate-sync.test.sh @@ -837,13 +837,50 @@ test_seed_marker_does_not_mask_real_dirt() { pass "T14 marker tolerance does not mask a genuinely dirty home" } -# --- T15: the shipped firstmate repo gitignores the seed marker ----------------- +# --- T15: a local Treehouse config does not block shared fast-forward logic ----- +test_treehouse_local_config_does_not_block_ff() { + local w c1 base + w=$(new_world treehouse-local-config) + c1=$(head_of "$w/main") + git -C "$w/main" worktree add -q --detach "$w/sm" "$c1" + printf 'root = "/tmp/treehouse-pool"\n' > "$w/sm/treehouse.toml" + bump_primary "$w" readme + base=$(primary_head_commit "$w/main") + + run_ff "$w/sm" "$base" + + [ "$FF_STATUS" = updated ] \ + || fail "treehouse-config-only home did not fast-forward, got '$FF_STATUS': $FF_OUT" + [ "$(head_of "$w/sm")" = "$base" ] || fail "treehouse-config-only home did not reach the base" + grep -qxF 'root = "/tmp/treehouse-pool"' "$w/sm/treehouse.toml" \ + || fail "treehouse local config was changed or removed" + pass "T15 an exact untracked treehouse.toml does not block shared fast-forward logic" +} + +# --- T16: the existing seed-marker exception remains unchanged ----------------- +test_seed_marker_tolerance_remains_intact() { + local w c0 base + w=$(new_world marker-tolerance-intact) + c0=$(head_of "$w/main") + seed_marked_home "$w" sm "$c0" + bump_primary "$w" readme + base=$(primary_head_commit "$w/main") + + run_ff "$w/sm" "$base" + + [ "$FF_STATUS" = updated ] \ + || fail "seed-marker-only home did not fast-forward, got '$FF_STATUS': $FF_OUT" + [ "$(head_of "$w/sm")" = "$base" ] || fail "seed-marker-only home did not reach the base" + pass "T16 existing secondmate-marker tolerance remains intact" +} + +# --- T17: the shipped firstmate repo gitignores the seed marker ----------------- # Pins the actual fix so it cannot silently regress: without this .gitignore entry # every seeded home would read dirty again the moment it lands on this repo's HEAD. test_repo_gitignores_seed_marker() { grep -qxF '.fm-secondmate-home' "$ROOT/.gitignore" \ || fail "the firstmate repo .gitignore must ignore the seed marker (.fm-secondmate-home)" - pass "T15 the firstmate repo gitignores the secondmate seed marker" + pass "T17 the firstmate repo gitignores the secondmate seed marker" } test_ff_updated @@ -866,6 +903,8 @@ test_spawn_warns_when_sync_skipped_before_launch test_seed_marker_clean_when_gitignored test_seed_marker_converges_existing_home test_seed_marker_does_not_mask_real_dirt +test_treehouse_local_config_does_not_block_ff +test_seed_marker_tolerance_remains_intact test_repo_gitignores_seed_marker echo "# all fm-secondmate-sync tests passed"