From 628278ebe4004729b76796ac1e5aa3d5d2c778dd Mon Sep 17 00:00:00 2001 From: garrytan Date: Fri, 30 Oct 2026 02:07:25 +0000 Subject: [PATCH] fix(ship-preflight): read the default branch from the remote's HEAD, not a local origin/HEAD A Capy checkout points refs/remotes/origin/HEAD at the PR branch, so the preflight refused every lane as 'on the base branch'. ls-remote --symref is authoritative; the local ref stays the offline fallback. Failing-first test in test/ship-preflight.test.ts. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- lib/ship-preflight.ts | 3 ++- test/ship-preflight.test.ts | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/ship-preflight.ts b/lib/ship-preflight.ts index 42bfd847a..69ceff3e3 100644 --- a/lib/ship-preflight.ts +++ b/lib/ship-preflight.ts @@ -22,7 +22,8 @@ const DIST = /(^|\/)(cmd\/[^/]+\/main\.go|bin\/[^/]+|Cargo\.toml|setup\.py|packa function integrate(cwd: string, base: string, r: Report): boolean { const branch = git(cwd, ['branch', '--show-current']).stdout.trim(); - const head = git(cwd, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']).stdout.trim().replace(/^origin\//, ''); + const remote = /^ref: refs\/heads\/(\S+)\s+HEAD$/m.exec(git(cwd, ['ls-remote', '--symref', 'origin', 'HEAD'], 60_000).stdout)?.[1]; + const head = remote ?? git(cwd, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']).stdout.trim().replace(/^origin\//, ''); r.add(`BRANCH: ${branch || '(detached)'} base=${base}`); if (!branch || branch === base || branch === head) { r.stop(`on the base branch (${branch || 'detached HEAD'}): ship from a feature branch`); return false; } const status = git(cwd, ['status', '--porcelain']).stdout.split('\n').filter(Boolean); diff --git a/test/ship-preflight.test.ts b/test/ship-preflight.test.ts index 0a71c5969..3d761700f 100644 --- a/test/ship-preflight.test.ts +++ b/test/ship-preflight.test.ts @@ -45,6 +45,17 @@ describe('gstack-ship-preflight', () => { expect(fetch.stdout).not.toContain('MERGE:'); }); + // Value: protects=the default-branch abort reads the remote's HEAD, not a local origin/HEAD a clone pointed at the feature branch; fails_when=a checkout whose local origin/HEAD names the feature branch is refused as "on the base branch"; + // why_new=GSTA-167 dogfood on #3633: a Capy checkout's origin/HEAD is the PR branch; seam=git ls-remote --symref; no_claim=offline fallback + test('a local origin/HEAD naming the feature branch is not the default branch', () => { + const f = fx(); + f.git('update-ref', 'refs/remotes/origin/feat', 'HEAD'); + f.git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/feat'); + const r = f.run(PREFLIGHT, ['--base', 'main']); + expect(r.stdout).not.toContain('STOP: on the base branch'); + expect(r.status).toBe(0); + }); + // Value: protects=Step 3 merges the base and a conflict stops with the files named, leaving the merge for the agent to resolve; fails_when=the bin resolves a conflict itself, aborts the merge, or continues to tests; // why_new=moved from the Step 3 prose; seam=git merge; no_claim=which side is right test('a merged base is reported; a conflict stops with the conflicted files and the merge left in place', () => {