fix(bin): launch remote-less projects from their local default branch - #1
Merged
Merged
Conversation
A local-only project is allowed to have no remote, but the pooled-worktree base refresh unconditionally fetched origin, so every spawn into a freshly `git init`-ed project refused with "could not fetch origin ... refusing to launch from a potentially stale base". When the slot's repository has no origin remote, resolve the base from the shared checkout instead: the common git dir's HEAD names the branch that checkout is on, falling back to the local main or master heads rather than assuming a name, and the reset targets refs/heads/<default>. Every refusal still stands - dirty worktree, stale submodule pin, unresolvable default branch, and mismatch after reset - now naming the local branch. Behavior with an origin remote, including all of its messages, is unchanged.
… never checkout HEAD
local_default_branch() reads init.defaultBranch, which falls through to the host's global and system git config. test_unresolved_local_default_refuses_pool uses a 'trunk' fixture and expects the base to be unresolvable, so it only refused because no host config happened to name 'trunk'; on a machine whose global init.defaultBranch is 'trunk' the spawn resolved a base and the case failed for an environmental reason. Run every spawn in this file with GIT_CONFIG_GLOBAL and GIT_CONFIG_SYSTEM pointed at /dev/null. Each case already states the configuration it needs in the project repository itself, so nothing depends on the host any more. Verified both directions with a global init.defaultBranch of 'trunk': the case fails before this change and the whole file passes after it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
Fix: bin/fm-spawn.sh refuses to launch a crewmate or scout for a local-only project whose git repository has no remote.
Observed on a freshly
git init-ed project registered [local-only] in data/projects.md (firstmate's project-management skill explicitly allows 'a local-only project may have no remote'):fatal: 'origin' does not appear to be a git repository
error: could not fetch origin for pooled worktree ''; refusing to launch from a potentially stale base
Cause: freshen_spawn_worktree_base() unconditionally ran
git fetch origin,git remote set-head origin --auto, resolved origin/, and reset --hard'd the pooled worktree to it. The treehouse pool worktree is a linked worktree of the project checkout under projects/ (shared common git dir), so for a remote-less repo the correct base is the checkout's own default branch, not a remote-tracking ref.Required behavior:
git remoteprints nothing) freshens against the local default branch: resolve it, require the worktree to be clean exactly as today, reset --hard to refs/heads/, and verify HEAD matches that commit. Keep every existing refusal (dirty worktree, stale submodule pin, unresolvable default branch, mismatch after reset) with wording that names the local branch rather than origin.Explicitly excluded: do NOT add a self-pointing 'origin' remote as the fix; that is the temporary operator workaround this task replaces.
Two review decisions were escalated to the repository owner during an earlier validation run and accepted; both are already implemented and must not be reverted:
The final commit on this branch fixes a test-hermeticity finding raised by the previous run's review: local_default_branch() reads init.defaultBranch, which falls through to the host's global and system git config, and tests/lib.sh does not isolate it. test_unresolved_local_default_refuses_pool used a 'trunk' fixture and expected the base to be unresolvable, so it only refused because no host config named 'trunk'. Every spawn in that test file now runs with GIT_CONFIG_GLOBAL and GIT_CONFIG_SYSTEM pointed at /dev/null; each case already states the configuration it needs in the project repository itself. Verified both directions under a global init.defaultBranch of 'trunk': the case fails before the change and the whole file passes after it.
This repository is firstmate itself, so its own coding guidelines apply: one sentence per line in tracked Markdown, plain dashes never em dashes, bin/*.sh must pass bin/fm-lint.sh (pinned shellcheck 0.11.0 + actionlint 1.7.12), tests colocated in tests/ named .test.sh following tests/lib.sh conventions, extend the existing test file rather than adding a new one, tests must exercise behavior through the executable and never assert on implementation source bytes, each script header is the single owner of that script's contract, keep the change minimal and focused on this defect with no refactoring of surrounding code, and never add an agent name as a commit co-author.
Context for the lint step: the previous run's lint gate failed only because shellcheck and actionlint were absent from the pipeline daemon's PATH, not because of any code issue. Both pinned binaries (shellcheck 0.11.0, actionlint 1.7.12) are now installed on that PATH, and bin/fm-lint.sh plus bin/fm-doc-audience-check.sh and the full tests/fm-spawn-pool-base-freshen.test.sh suite were run green on this exact head before starting this run.
What Changed
bin/fm-spawn.shnow checks whether the pooled worktree's repository has any remotes before freshening its base: with no remotes at all it skips the origin fetch and resets torefs/heads/<default>, resolved by a newlocal_default_branch()helper that uses the shareddefault_branch()lookup and then falls back to the configuredinit.defaultBranchwhen that local branch exists; the currently checked-out branch is never taken on its own, an unresolvable local default refuses with a message naming the local branch, and any repository with remotes keeps the existing origin-based path and error messages unchanged.fm-spawn.shheader block (which also feeds--help) anddocs/architecture.mddescribe the remote-less base rule, including the refusal when no default branch resolves.tests/fm-spawn-pool-base-freshen.test.shadds a local-only fixture and seven cases covering the remote-less refresh, dirty-worktree refusal, unresolvable default, default-over-feature-branch selection, configured-default fallback, unrecognised checkout branch refusal, and non-origin remotes still refusing; every spawn in the file now runs withGIT_CONFIG_GLOBALandGIT_CONFIG_SYSTEMpointed at/dev/nullso host git config cannot influence results.Risk Assessment
✅ Low: The change is a narrow, well-gated branch in one function that reuses the shared default-branch resolver, leaves the remote-backed path and all its messages untouched, honours every recorded owner decision, and is covered by executable regression tests that reproduce the reported failure and the stranded-checkout refusals.
Testing
Ran the targeted tests/fm-spawn-pool-base-freshen.test.sh suite on the target head (all 18 cases pass), reproduced the original origin-fetch refusal by running the same new tests and a manual fm-spawn.sh transcript against the base commit, confirmed the target spawns a remote-less local-only project from the advanced local default tip, and proved the final hermeticity commit by showing the previous head fails and the target passes under a host global init.defaultBranch of trunk; --help reflects the new header contract and the remote-backed path is unchanged.
Evidence: Manual before/after CLI transcript: remote-less local-only spawn on base vs target
Source: Manual before/after CLI transcript: remote-less local-only spawn on base vs target
=== BEFORE (base c03bfbe) $ git -C project remote # prints nothing: no remote at all $ fm-spawn.sh local-only-before project --mode local-only --yolo off fatal: 'origin' does not appear to be a git repository error: could not fetch origin for pooled worktree '.../BEFORE/pool'; refusing to launch from a potentially stale base exit=1 $ git -C pool rev-parse HEAD -> b3d3284 (still the stale initial commit) === AFTER (target ff8d919) $ fm-spawn.sh local-only-after project --mode local-only --yolo off spawned local-only-after harness=codex kind=ship mode=local-only yolo=off window=firstmate:fm-local-only-after worktree=.../AFTER/pool exit=0 $ git -C pool rev-parse HEAD -> b4ea0b3 (== refs/heads/main tip, advance-main) $ ls pool -> README.md newer.txtEvidence: Script that produces the manual transcript
Source: Script that produces the manual transcript
Evidence: Targeted suite on target head with observed spawn/refusal lines
Source: Targeted suite on target head with observed spawn/refusal lines
Evidence: Regression reproduction: new tests against base commit fail at the remote-less case
Source: Regression reproduction: new tests against base commit fail at the remote-less case
Evidence: Hermeticity: previous head fails under global init.defaultBranch=trunk
Source: Hermeticity: previous head fails under global init.defaultBranch=trunk
Evidence: Hermeticity: target head passes under global init.defaultBranch=trunk
Source: Hermeticity: target head passes under global init.defaultBranch=trunk
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
✅ **Review** - passed
✅ No issues found.
✅ **Test** - passed
✅ No issues found.
bash tests/fm-spawn-pool-base-freshen.test.shwithFM_TEST_EVIDENCE=1on target head ff8d919: all 18 cases pass, including the 7 new remote-less, non-origin-remote, feature-checkout, and configured-default casesRegression reproduction: same new test file run against agit archiveof base commit c03bfbe fails attest_remote_less_pool_refreshes_to_local_default_tip(exit 1) while all pre-existing origin cases still pass thereManual end-user transcript (manual-remote-less-spawn.sh): freshgit initproject with no remote, pool slot allocated then local main advanced,fm-spawn.sh <id> <project> --mode local-only --yolo offdriven with fake tmux/treehouse; base tree prints the reportedfatal: 'origin' does not appear to be a git repository/could not fetch origin ... refusing to launch from a potentially stale baseand leaves the pool stale, target tree printsspawned ... mode=local-onlyand the pool HEAD equals the advanced local main tip with the new file presentHermeticity check: previous head 4744cd3 test file run withGIT_CONFIG_GLOBALnaminginit.defaultBranch = trunkfails attest_unresolved_local_default_refuses_pool; target head test file under the same global config passes all 18 casesbin/fm-spawn.sh --helpoutput contains the updated pooled-base freshen paragraph describing the no-remote rule, the init.defaultBranch fallback, the never-take-checkout-HEAD rule, and the non-origin-remote refusalReviewedfreshen_spawn_worktree_base()remote branch against base: the remote-backed code path and all its error strings are textually identical to before, and the local path's refusals namerefs/heads/<default>or 'the local default branch' rather than origin✅ **Document** - passed
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.