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
22 changes: 21 additions & 1 deletion bin/fm-fleet-sync.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
94 changes: 94 additions & 0 deletions tests/fm-fleet-sync.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <name>: 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/<dir> 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 <home> <name>: like build_pair, but the clone has PACKED
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Loading