diff --git a/bin/fm-fleet-sync.sh b/bin/fm-fleet-sync.sh index d5c951e1a7..dd00be86ba 100755 --- a/bin/fm-fleet-sync.sh +++ b/bin/fm-fleet-sync.sh @@ -13,6 +13,11 @@ # stashed, or discarded. # Still skips (benignly) local-only/no-origin projects, missing remotes/branches, # and fetch failures. +# A candidate under projects/ must be the root of its own work tree: git discovery +# walks up, so a plain nested directory would otherwise resolve to the enclosing +# repository (the firstmate checkout) and be synced under that directory's label. +# Anything else is reported as "skipped: not a clone root" naming the repository +# that would have been touched. # Pruning never deletes the checked-out branch or a branch that still has a # worktree, so it cannot discard unlanded work; set FM_FLEET_PRUNE=0 to disable it. # When the fetch fails on an orphaned .git/packed-refs.lock (left by a ref rewrite @@ -300,10 +305,25 @@ sync_project() { echo "$label: skipped: not a directory" return 0 fi - if ! git -C "$PROJ" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + # Git repository discovery walks UP from $PROJ, so a plain directory merely + # nested inside a repository - a worktree container left under projects/, say - + # resolves to the ENCLOSING repository, which in a firstmate home is the + # firstmate checkout itself. Every later `git -C "$PROJ"` would then read, prune + # and fast-forward that repository under this project's label, turning a routine + # refresh into an unrequested self-update reported as a project sync. Require + # $PROJ to be the root of its own work tree before any other git command runs. + proj_top=$(git -C "$PROJ" rev-parse --show-toplevel 2>/dev/null) || proj_top="" + if [ -z "$proj_top" ]; then echo "$label: skipped: not a git repo" return 0 fi + # Both sides are physical paths (git resolves --show-toplevel through symlinks), + # so a symlinked clone dir still compares equal to its own root. + proj_abs=$(cd "$PROJ" && pwd -P) || proj_abs="" + if [ "$proj_top" != "$proj_abs" ]; then + echo "$label: skipped: not a clone root (git would act on $proj_top)" + return 0 + fi mode_line=$("$FM_ROOT/bin/fm-project-mode.sh" "$label" 2>/dev/null || echo "no-mistakes off") mode=${mode_line%% *} if [ "$mode" = "local-only" ]; then diff --git a/tests/fm-fleet-sync.test.sh b/tests/fm-fleet-sync.test.sh index b1fcd0a38e..c2ea85ae36 100755 --- a/tests/fm-fleet-sync.test.sh +++ b/tests/fm-fleet-sync.test.sh @@ -12,6 +12,12 @@ # 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 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 +# with the enclosing repo left untouched, in both the whole-fleet and +# single-project forms, while a symlinked clone dir still syncs. +# # It also pins the orphaned .git/packed-refs.lock recovery in the fetch step # (fetch_with_packed_refs_lock_guard, backed by bin/fm-lock-lib.sh's shared # staleness proof): a provably-stale lock is retried then removed and the clone @@ -90,6 +96,40 @@ run_sync() { FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" "$ROOT/bin/fm-fleet-sync.sh" "$@" 2>/dev/null } +# build_enclosing_home : an FM_HOME that is itself nested inside another git +# repository - firstmate's own layout, where projects/ sits inside the firstmate +# checkout. The enclosing repo is a clean clone of a bare origin that is one commit +# ahead, so a sync that walked git discovery UP out of projects/ would find a +# fast-forward available and visibly take it. Echoes the enclosing repo, which is +# also the home. Its work tree is left pristine so the only thing under projects/ +# is what the test puts there. +build_enclosing_home() { + local name=$1 root work remote enclosing remote_abs + root="$TMP_ROOT/enclosing-$name" + work="$root/work" + remote="$root/remote.git" + enclosing="$root/enclosing" + mkdir -p "$root" + + git init -q "$work" + git -C "$work" symbolic-ref HEAD refs/heads/main + printf '/projects/\n' > "$work/.gitignore" + git -C "$work" add .gitignore + commit_file "$work" AGENTS.md v0 C0 + + 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 + + git clone --quiet "file://$remote_abs" "$enclosing" + commit_file "$work" AGENTS.md v1 C1 + git -C "$work" push -q origin main + + mkdir -p "$enclosing/projects" + printf '%s\n' "$enclosing" +} + # --- packed-refs.lock fixtures ---------------------------------------------- # build_packed_prunable : like build_pair, but the clone has PACKED @@ -582,6 +622,57 @@ test_transient_packed_refs_lock_self_clears() { pass "a transient packed-refs.lock that self-clears is retried without a force-remove" } +test_non_clone_dir_never_syncs_the_enclosing_repo() { + local home before out after + home=$(build_enclosing_home nonclone) + # A worktree container, not a clone: the repo is one level BELOW it. + mkdir -p "$home/projects/not-a-clone/wt" + before=$(head_sha "$home") + + out=$(run_sync "$home") + after=$(head_sha "$home") + + assert_contains "$out" "not-a-clone: skipped: not a clone root" \ + "a non-repo directory under projects/ must be skipped by name" + assert_not_contains "$out" "not-a-clone: synced" \ + "a non-repo directory must never be reported as a synced project" + [ "$before" = "$after" ] || \ + fail "fleet-sync fast-forwarded the enclosing repo ($before -> $after) under a project's label" + pass "a non-repo directory under projects/ never fast-forwards the enclosing repo" +} + +test_non_clone_dir_named_directly_never_syncs_the_enclosing_repo() { + local home before out after + home=$(build_enclosing_home nonclonedirect) + mkdir -p "$home/projects/not-a-clone" + before=$(head_sha "$home") + + out=$(run_sync "$home" not-a-clone) + after=$(head_sha "$home") + + assert_contains "$out" "not-a-clone: skipped: not a clone root" \ + "the single-project form must apply the same clone-root guard" + [ "$before" = "$after" ] || \ + fail "the single-project form fast-forwarded the enclosing repo ($before -> $after)" + pass "the single-project form also refuses a directory that is not its own clone root" +} + +test_symlinked_clone_still_syncs() { + local home clone out + home=$(new_home) + clone=$(build_pair "$home" sigma) + advance_origin "$home" sigma C1 + # A symlinked clone dir is a real clone root; the guard compares resolved paths, + # so it must not be mistaken for a directory nested in someone else's repo. + mv "$clone" "$home/real-sigma" + ln -s "$home/real-sigma" "$clone" + + out=$(run_sync "$home") + + assert_contains "$out" "sigma: synced" "a symlinked clone must still fast-forward" + pass "the clone-root guard accepts a symlinked clone directory" +} + test_non_signature_fetch_failure_is_not_retried() { local home fakebin clone out err home=$(new_home) @@ -625,3 +716,6 @@ test_live_packed_refs_lock_is_never_removed test_live_git_cwd_in_clone_dir_blocks_removal test_transient_packed_refs_lock_self_clears test_non_signature_fetch_failure_is_not_retried +test_non_clone_dir_never_syncs_the_enclosing_repo +test_non_clone_dir_named_directly_never_syncs_the_enclosing_repo +test_symlinked_clone_still_syncs