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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 19 additions & 8 deletions bin/fm-ff-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---------------------------------------------------------------

Expand Down Expand Up @@ -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() {
Expand Down
36 changes: 9 additions & 27 deletions bin/fm-fleet-sync.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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/<default>) and whose <default>
# 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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand Down
5 changes: 3 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,15 +246,16 @@ 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/<default>`, 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.

## 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).

Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
89 changes: 89 additions & 0 deletions tests/fm-fleet-sync.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
43 changes: 41 additions & 2 deletions tests/fm-secondmate-sync.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Loading