Skip to content
Closed
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
21 changes: 7 additions & 14 deletions bin/fm-ff-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,26 +26,19 @@

SUB_HOME_MARKER="${SUB_HOME_MARKER:-.fm-secondmate-home}"

# bin/fm-tangle-lib.sh owns fm_default_branch, including its refusal to trust a
# dangling origin/HEAD.
# shellcheck source=bin/fm-tangle-lib.sh disable=SC1091
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/fm-tangle-lib.sh"

# --- helpers ---------------------------------------------------------------

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
default_branch() { # <dir>
fm_default_branch "$1"
}

# Resolve the PRIMARY checkout's current default-branch commit - the local-HEAD
Expand Down
18 changes: 5 additions & 13 deletions bin/fm-fleet-sync.sh
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ 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"
# bin/fm-tangle-lib.sh owns fm_default_branch, including its refusal to trust a
# dangling origin/HEAD.
# shellcheck source=bin/fm-tangle-lib.sh disable=SC1091
. "$SCRIPT_DIR/fm-tangle-lib.sh"
FM_LOCK_LOG_PREFIX=fleet-sync
"$FM_ROOT/bin/fm-guard.sh" || true

Expand Down Expand Up @@ -108,19 +112,7 @@ resolve_project_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
fm_default_branch "$PROJ"
}

first_line() {
Expand Down
18 changes: 5 additions & 13 deletions bin/fm-merge-local.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ 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}"
"$FM_ROOT/bin/fm-guard.sh" || true
# bin/fm-tangle-lib.sh owns fm_default_branch, including its refusal to trust a
# dangling origin/HEAD.
# shellcheck source=bin/fm-tangle-lib.sh disable=SC1091
. "$SCRIPT_DIR/fm-tangle-lib.sh"
ID=${1:?usage: fm-merge-local.sh <task-id>}
META="$STATE/$ID.meta"
[ -f "$META" ] || { echo "error: no meta for task $ID at $META" >&2; exit 1; }
Expand All @@ -26,19 +30,7 @@ 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 <id> <PR url> 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
fm_default_branch "$PROJ"
}

BRANCH="fm/$ID"
Expand Down
18 changes: 5 additions & 13 deletions bin/fm-review-diff.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ 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}"
"$FM_ROOT/bin/fm-guard.sh" || true
# bin/fm-tangle-lib.sh owns fm_default_branch, including its refusal to trust a
# dangling origin/HEAD.
# shellcheck source=bin/fm-tangle-lib.sh disable=SC1091
. "$SCRIPT_DIR/fm-tangle-lib.sh"

usage() {
echo "usage: fm-review-diff.sh <task-id> [--stat]" >&2
Expand Down Expand Up @@ -50,19 +54,7 @@ PROJ=$(grep '^project=' "$META" | cut -d= -f2-)
[ -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
fm_default_branch "$PROJ"
}

DEFAULT=$(default_branch) || { echo "error: cannot determine default branch for $PROJ; expected origin/HEAD, main, or master" >&2; exit 1; }
Expand Down
60 changes: 60 additions & 0 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -837,6 +837,66 @@ real_path_or_raw() { # <path>
fi
}

# --- treehouse base-ref preflight -------------------------------------------
#
# `treehouse get` takes no base-ref argument: treehouse picks the base commit
# for every pool worktree itself, from the project clone's origin/HEAD, then the
# clone's own checked-out branch, then init.defaultBranch. It does not verify
# that the branch it picked still exists, so a clone whose origin/HEAD points at
# a default branch that was since renamed or deleted dies inside the pane with
# `git worktree add --detach <path> refs/remotes/origin/<gone>: fatal: invalid
# reference` - for both a fresh worktree and the reuse-and-reset path, so a
# non-empty pool does not rescue it. Verified live against treehouse v2.1.0 on a
# clone that publishes only build/production while origin/HEAD still names main.
#
# All firstmate ever saw was the generic 60s "did not enter a worktree" timeout
# below, which names neither the missing ref nor the branches that do exist.
# Resolve the same base up front and refuse with the real cause instead. This is
# read-only: firstmate never repairs a project's refs, so the remedy belongs to
# whoever owns the clone.
treehouse_base_branch() { # echoes the branch name treehouse will resolve, or nothing
local ref
if git -C "$PROJ_ABS" remote 2>/dev/null | grep -qx origin; then
ref=$(git -C "$PROJ_ABS" symbolic-ref --quiet refs/remotes/origin/HEAD 2>/dev/null || true)
case "$ref" in
refs/remotes/origin/?*) printf '%s\n' "${ref#refs/remotes/origin/}"; return 0 ;;
esac
fi
ref=$(git -C "$PROJ_ABS" symbolic-ref --quiet HEAD 2>/dev/null || true)
case "$ref" in
refs/heads/?*) printf '%s\n' "${ref#refs/heads/}"; return 0 ;;
esac
git -C "$PROJ_ABS" config init.defaultBranch 2>/dev/null || true
}

assert_treehouse_base_ref() {
local branch remote_branches
git -C "$PROJ_ABS" rev-parse --git-dir >/dev/null 2>&1 || return 0
branch=$(treehouse_base_branch)
remote_branches=$(git -C "$PROJ_ABS" for-each-ref --format='%(refname:lstrip=3)' \
refs/remotes/origin 2>/dev/null | grep -vx HEAD | paste -sd, - || true)
if [ -z "$branch" ]; then
echo "error: cannot determine the base branch treehouse would build the isolated worktree for $ID from in $PROJ_ABS (no usable origin/HEAD, no checked-out branch, no init.defaultBranch); refusing to launch. Remote branches: ${remote_branches:-none}" >&2
exit 1
fi
if git -C "$PROJ_ABS" rev-parse --verify --quiet "refs/heads/$branch" >/dev/null 2>&1 \
|| git -C "$PROJ_ABS" rev-parse --verify --quiet "refs/remotes/origin/$branch" >/dev/null 2>&1; then
return 0
fi
# No remote-tracking branches at all means nothing has been fetched yet, and
# treehouse fetches before it allocates - that is not a broken base ref, so
# leave it to the existing paths rather than refusing a legitimate spawn.
[ -n "$remote_branches" ] || return 0
echo "error: treehouse would build the isolated worktree for $ID from '$branch', but neither refs/heads/$branch nor refs/remotes/origin/$branch exists in $PROJ_ABS; refusing to launch rather than allocating from a base ref that is not there. Remote branches present: $remote_branches. Repointing that clone's origin/HEAD at a branch that exists is a project-side repair firstmate does not make." >&2
exit 1
}

# Refuse before any window, worktree, or metadata exists: the same condition
# that sends `treehouse get` below.
if [ "$KIND" != secondmate ] && [ "$BACKEND" != orca ]; then
assert_treehouse_base_ref
fi

# Session-provider container-ensure + task creation. tmux stays exactly as P1
# left it (same session-name / new-window sequence, see bin/backends/tmux.sh);
# a herdr spawn goes through the version-gated, workspace-per-HOME,
Expand Down
13 changes: 12 additions & 1 deletion bin/fm-tangle-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,21 @@

# Resolve the default branch name of the git repo at <dir>: prefer origin/HEAD,
# then fall back to a local main/master. Echoes the name, or returns 1.
#
# origin/HEAD is only trusted when the branch it names actually exists. A clone
# whose remote advertised a default branch that was later renamed or deleted
# keeps a DANGLING symbolic ref (seen live: refs/remotes/origin/HEAD ->
# refs/remotes/origin/main in a repo that only publishes build/production).
# `symbolic-ref` resolves that ref happily, so an unvalidated read invents a
# default branch name no caller can ever check out, diff, or merge into.
# Verifying the target keeps resolution honest: an unusable origin/HEAD falls
# through to the local candidates and, failing those, returns 1 so callers
# refuse with their own "cannot determine default branch" error instead of
# operating against a branch that does not exist.
fm_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
if [ -n "$ref" ] && git -C "$dir" rev-parse --verify --quiet "refs/remotes/$ref" >/dev/null 2>&1; then
printf '%s\n' "${ref#origin/}"
return 0
fi
Expand Down
18 changes: 5 additions & 13 deletions bin/fm-teardown.sh
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ SUB_HOME_MARKER=".fm-secondmate-home"
. "$SCRIPT_DIR/fm-gate-refuse-lib.sh"
# shellcheck source=bin/fm-pr-lib.sh
. "$SCRIPT_DIR/fm-pr-lib.sh"
# bin/fm-tangle-lib.sh owns fm_default_branch, including its refusal to trust a
# dangling origin/HEAD.
# shellcheck source=bin/fm-tangle-lib.sh disable=SC1091
. "$SCRIPT_DIR/fm-tangle-lib.sh"
if [ "$#" -lt 1 ] || ! fm_task_id_path_safe "$1"; then
echo "error: invalid teardown request" >&2
exit 2
Expand Down Expand Up @@ -144,19 +148,7 @@ MODE=$(grep '^mode=' "$META" | cut -d= -f2- || true)
[ -n "$MODE" ] || MODE=no-mistakes

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
fm_default_branch "$PROJ"
}

meta_value() {
Expand Down
5 changes: 4 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,10 @@ Its operating checkout (`FM_ROOT`) and the disposable crewmate worktrees are all
The primary checkout is healthy on its default branch, and linked worktrees or secondmate homes are healthy at detached HEAD.
Only a named non-default branch checked out in `FM_ROOT` is a worktree tangle.

`fm-tangle-lib.sh` resolves the default branch from `origin/HEAD`, then local `main` or `master`, and classifies that named non-default primary branch as the tangle.
`fm-tangle-lib.sh` owns `fm_default_branch` for firstmate's tangle and default-branch consumers: it resolves the default branch from a valid `origin/HEAD`, then local `main` or `master`, and classifies that named non-default primary branch as the tangle.
It trusts `origin/HEAD` only when the branch that ref names still exists, so a clone left pointing at a renamed or deleted default resolves nothing rather than an unusable branch name.
Treehouse-backed spawns have a separate base-ref preflight because treehouse chooses its allocation base itself; before opening a backend endpoint, `fm-spawn.sh` resolves the base treehouse would allocate from and refuses with the missing ref and present remote branches when that base does not exist.
An unfetched clone with no remote-tracking branches is left to treehouse's fetch path rather than refused.
`fm-guard.sh` prints the repair command on the next mutable fleet action, while `bin/fm-session-start.sh` reports the same condition through bootstrap as a `TANGLE:` line at session start.
If another live session holds the fleet lock, both surfaces keep the alarm but switch to read-only wording with no repair command.
Ship briefs also tell the crewmate to verify `pwd -P` and `git rev-parse --show-toplevel` before creating `fm/<id>`, then stop with a blocked status if it landed in the primary checkout.
Expand Down
111 changes: 111 additions & 0 deletions tests/fm-tangle-guard.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -301,9 +301,120 @@ test_spawn_tmux_window_construction() {
pass "fm-spawn: appends windows by session-colon, pins the name, and targets the window id"
}

# --- base-ref resolution: build-only projects and dangling origin/HEAD -------
#
# Not every project publishes main. A repo whose branches are build/production
# still carries refs/remotes/origin/HEAD from whatever its remote advertised at
# clone time, and that symbolic ref keeps resolving after the branch it names is
# renamed or deleted. `git symbolic-ref` reports the dangling target happily, so
# an unvalidated read hands every caller a branch name that cannot be checked
# out, diffed, or merged into - and hands treehouse a base ref that makes
# `git worktree add` fail, which is exactly how an isolated worktree stopped
# being allocatable for a build-only project.

# A repo with an `origin` remote and remote-tracking branches for each of
# <branches>, checked out on the first one. Echoes its path.
make_remote_repo() {
local dir=$1; shift
local upstream="$dir.upstream.git" b first=$1
git init -q --bare -b "$first" "$upstream"
git init -q -b "$first" "$dir"
git -C "$dir" commit -q --allow-empty -m init
git -C "$dir" remote add origin "$upstream"
for b in "$@"; do
[ "$b" = "$first" ] || git -C "$dir" branch -q -f "$b" HEAD
git -C "$dir" push -q origin "$b"
done
git -C "$dir" fetch -q origin
# Keep only the checked-out branch locally, so resolution has to go through
# the remote-tracking refs the way a firstmate project clone does.
for b in "$@"; do
[ "$b" = "$first" ] || git -C "$dir" branch -q -D "$b"
done
printf '%s\n' "$dir"
}

test_default_branch_ref_validation() {
local repo out
repo=$(make_remote_repo "$TMP_ROOT/db-repo" build production)

# A dangling origin/HEAD must not become the answer.
git -C "$repo" symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main
out=$(fm_default_branch "$repo" || true)
assert_not_contains "$out" "main" "a dangling origin/HEAD must not resolve to its missing target"
[ -z "$out" ] || fail "build-only repo with no local main/master should not resolve a default branch, got '$out'"

# A valid origin/HEAD is still authoritative, including a non-main default.
git -C "$repo" symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/build
out=$(fm_default_branch "$repo" || true)
[ "$out" = build ] || fail "valid origin/HEAD should resolve to build, got '$out'"

# No origin/HEAD at all still falls back to a local main/master.
git -C "$repo" symbolic-ref -d refs/remotes/origin/HEAD
git -C "$repo" branch -q -f main HEAD
out=$(fm_default_branch "$repo" || true)
[ "$out" = main ] || fail "absent origin/HEAD should fall back to local main, got '$out'"

# A dangling origin/HEAD falls THROUGH to the local candidates rather than
# winning: the fallback is what keeps the invented name out of the answer.
git -C "$repo" symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/gone
out=$(fm_default_branch "$repo" || true)
[ "$out" = main ] || fail "dangling origin/HEAD should fall through to local main, got '$out'"

pass "fm_default_branch: trusts origin/HEAD only when its target exists"
}

# fm-spawn cannot pass a base ref to treehouse - `treehouse get` takes none, and
# treehouse resolves the base itself without checking that it exists. So the
# guard is a refusal: fail with the real cause before a window, worktree, or
# metadata exists, rather than allocating from a ref that is not there and
# surfacing only the generic 60s "did not enter a worktree" timeout.
test_spawn_refuses_missing_base_ref() {
local home proj fakebin out status
home="$TMP_ROOT/base-ref-home"
mkdir -p "$home/data"
proj=$(make_remote_repo "$TMP_ROOT/base-ref-proj" build production)
fakebin=$(make_spawn_fakebin "$TMP_ROOT/base-ref-fake")
git -C "$proj" worktree add -q --detach "$TMP_ROOT/base-ref-wt" >/dev/null 2>&1

# Build-only project whose origin/HEAD still names the deleted default.
git -C "$proj" symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main
out=$(run_spawn "$home" base-gone-hh8 "$proj" "$TMP_ROOT/base-ref-wt" "$fakebin"); status=$?
expect_code 1 "$status" "spawn should refuse when the resolved base ref does not exist"
assert_contains "$out" "refusing to launch" "missing base ref lacked the refusal"
assert_contains "$out" "build" "the refusal must name the branches that do exist"
assert_absent "$home/state/base-gone-hh8.meta" "refused spawn must not record meta"

# Same project, origin/HEAD repointed at a branch that exists: spawn proceeds.
git -C "$proj" symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/build
out=$(run_spawn "$home" base-ok-ii9 "$proj" "$TMP_ROOT/base-ref-wt" "$fakebin"); status=$?
expect_code 0 "$status" "a build-only project with a valid origin/HEAD should spawn"
assert_contains "$out" "spawned base-ok-ii9" "valid build base did not report success"
assert_not_contains "$out" "refusing to launch" "valid build base wrongly tripped the base-ref guard"

# A clone with nothing fetched yet must NOT be refused: treehouse fetches
# before it allocates, so an empty remote-tracking namespace is not proof of a
# broken base ref.
local bare unfetched
bare="$TMP_ROOT/base-unfetched.git"
git init -q --bare -b main "$bare"
unfetched="$TMP_ROOT/base-unfetched"
git init -q -b main "$unfetched"
git -C "$unfetched" commit -q --allow-empty -m init
git -C "$unfetched" remote add origin "$bare"
git -C "$unfetched" worktree add -q --detach "$TMP_ROOT/base-unfetched-wt" >/dev/null 2>&1
out=$(run_spawn "$home" base-unfetched-jj0 "$unfetched" "$TMP_ROOT/base-unfetched-wt" "$fakebin"); status=$?
expect_code 0 "$status" "an unfetched clone must not be refused by the base-ref guard"
assert_not_contains "$out" "refusing to launch" "unfetched clone wrongly tripped the base-ref guard"

pass "fm-spawn: refuses a missing base ref, allows a build-only default and an unfetched clone"
}

test_lib_classification
test_guard_banner
test_bootstrap_line
test_brief_assertion_precedes_branch
test_spawn_isolation_abort
test_spawn_tmux_window_construction
test_default_branch_ref_validation
test_spawn_refuses_missing_base_ref
Loading