From c3b3c37d033930fd8422a1aab6fe4976dc9fde17 Mon Sep 17 00:00:00 2001 From: garrytan Date: Tue, 3 Nov 2026 01:54:43 +0000 Subject: [PATCH 1/3] fix(ship-preflight): stop when ls-remote origin HEAD fails instead of guessing the default branch Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .../capy-ship-preflight-lsremote-stop.md | 7 ++++++ docs/eval-calls.md | 3 ++- docs/eval-models.md | 3 ++- lib/ship-preflight.ts | 8 ++++--- test/ship-preflight.test.ts | 22 +++++++++++++++++++ 5 files changed, 38 insertions(+), 5 deletions(-) create mode 100644 changelog.d/capy-ship-preflight-lsremote-stop.md diff --git a/changelog.d/capy-ship-preflight-lsremote-stop.md b/changelog.d/capy-ship-preflight-lsremote-stop.md new file mode 100644 index 000000000..4f50c4748 --- /dev/null +++ b/changelog.d/capy-ship-preflight-lsremote-stop.md @@ -0,0 +1,7 @@ +**`gstack-ship-preflight` stops when it can't read origin's default branch, instead of guessing it from a local ref.** When `git ls-remote --symref origin HEAD` failed and the fetch worked, preflight fell back to the local `origin/HEAD`. With no local `origin/HEAD` it went on from the default branch itself and merged the given base into it. When a PR-branch clone's `origin/HEAD` named the feature branch, it refused that branch as "on the base branch". Both now stop with `git ls-remote origin HEAD failed: …; restore access first` before anything merges. The default-branch check now runs after the fetch, so a failed fetch still reports the fetch. + +### Fixed +- **`docs/eval-calls.md`, `docs/eval-models.md`**: the default `--base` is described as base resolution now works: origin/HEAD's branch unless that is the checked-out branch, then the PR's base, `main` or `master`. + +### Tests +- `test/ship-preflight.test.ts`: with `ls-remote` failing and the fetch working, preflight stops before merging both on the default branch with no local `origin/HEAD` (on main it exited 0 and merged `release` into `main`) and on a feature branch that the local `origin/HEAD` names (on main it stopped as "on the base branch"). diff --git a/docs/eval-calls.md b/docs/eval-calls.md index fb7f9101e..176a589f5 100644 --- a/docs/eval-calls.md +++ b/docs/eval-calls.md @@ -22,7 +22,8 @@ dropped), so a command with quotes or `$` never passes through a shell string; 1. `--plan-file `: an operator-given file, read from disk. 2. Otherwise `price_file` in `.gstack/pregate.json` as committed on - `origin/` (`--base`, default origin/HEAD's branch): a repo-relative + `origin/` (`--base`, default origin/HEAD's branch unless that is the + checked-out branch, then the PR's base, `main` or `master`): a repo-relative path whose bytes are read from the **HEAD commit** (`git show HEAD:`), never from the working tree. diff --git a/docs/eval-models.md b/docs/eval-models.md index 406149101..eabefb8e2 100644 --- a/docs/eval-models.md +++ b/docs/eval-models.md @@ -66,7 +66,8 @@ unless the command says it is a smoke run. ## Which copy is read By default the reviewed copy is read: `git show origin/:.gstack/eval-models.json` -(`--base`, default origin/HEAD's branch). A branch cannot loosen the rules it +(`--base`, default origin/HEAD's branch unless that is the checked-out branch, +then the PR's base, `main` or `master`). A branch cannot loosen the rules it is checked against. When the working-tree copy differs, an `EVAL_MODELS_NOTE:` line says so. `--models-from head` lints with the working-tree copy instead. Use it for the PR that adds or changes the file; the source label diff --git a/lib/ship-preflight.ts b/lib/ship-preflight.ts index fbbe0a006..da008876c 100644 --- a/lib/ship-preflight.ts +++ b/lib/ship-preflight.ts @@ -31,14 +31,16 @@ 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 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; } + if (!branch || branch === base) { 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); r.add(`STATUS: modified=${status.filter(l => !l.startsWith('??')).length} untracked=${status.filter(l => l.startsWith('??')).length} (uncommitted changes ship with this run)`); const fetch = git(cwd, ['fetch', 'origin', `+refs/heads/${base}:refs/remotes/origin/${base}`], 300_000); if (fetch.status !== 0) { r.stop(`git fetch origin ${base} failed: ${firstLine(fetch.stderr)}; restore access first`); return false; } + const remoteHead = git(cwd, ['ls-remote', '--symref', 'origin', 'HEAD'], 60_000); + if (remoteHead.status !== 0) { r.stop(`git ls-remote origin HEAD failed: ${firstLine(remoteHead.stderr)}; restore access first`); return false; } + const head = /^ref: refs\/heads\/(\S+)\s+HEAD$/m.exec(remoteHead.stdout)?.[1] ?? git(cwd, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']).stdout.trim().replace(/^origin\//, ''); + if (branch === head) { r.stop(`on the base branch (${branch}): ship from a feature branch`); return false; } const stat = git(cwd, ['diff', forkPoint(cwd, base), '--shortstat']).stdout.trim(); const lines = [...stat.matchAll(/(\d+) (insertion|deletion)/g)].reduce((n, m) => n + Number(m[1]), 0); const commits = git(cwd, ['rev-list', '--count', `origin/${base}..HEAD`]).stdout.trim(); diff --git a/test/ship-preflight.test.ts b/test/ship-preflight.test.ts index d83f9a2d2..4882bdc7b 100644 --- a/test/ship-preflight.test.ts +++ b/test/ship-preflight.test.ts @@ -57,6 +57,28 @@ describe('gstack-ship-preflight', () => { expect(r.status).toBe(0); }); + // Value: protects=a failed lookup of the remote's HEAD stops with "restore access first" instead of guessing the default branch from a local origin/HEAD; + // fails_when=a failed ls-remote falls back to the local ref, so the default branch with no local origin/HEAD merges another base and goes on, and a feature + // branch a Capy checkout's origin/HEAD names is refused as the base branch; why_new=GST-511 follow-up from #3718; seam=a git wrapper on PATH that fails + // ls-remote only; no_claim=which access failure it was + test('a failed remote HEAD lookup stops before merging, even when the fetch works', () => { + const f = fx(); + fs.writeFileSync(path.join(f.root, 'stub', 'git'), `#!/bin/sh\n[ "$1" = ls-remote ] && { echo "ls-remote: unreachable" >&2; exit 128; }\nexec ${Bun.which('git')} "$@"\n`, { mode: 0o755 }); + f.git('push', '-q', 'origin', 'main:release'); + f.git('checkout', '-q', 'main'); + f.git('remote', 'set-head', 'origin', '-d'); + const onDefault = f.run(PREFLIGHT, ['--base', 'release']); + f.git('checkout', '-q', 'feat'); + f.git('update-ref', 'refs/remotes/origin/feat', 'HEAD'); + f.git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/feat'); + const onFeature = f.run(PREFLIGHT, ['--base', 'main']); + for (const r of [onDefault, onFeature]) { + expect(r.status).toBe(3); + expect(r.stdout).toContain('STOP: git ls-remote origin HEAD failed: ls-remote: unreachable; restore access first'); + expect(r.stdout).not.toContain('MERGE:'); + } + }); + // 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', () => { From 1ec7fe19950fd41e5ad5c55a1cc5d0d84ed077f9 Mon Sep 17 00:00:00 2001 From: garrytan Date: Tue, 3 Nov 2026 02:02:50 +0000 Subject: [PATCH 2/3] fix(ship-preflight): a remote HEAD with no branch falls back to the default-base order; docs name the whole order Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .../capy-ship-preflight-lsremote-stop.md | 6 ++--- docs/eval-calls.md | 5 +++-- docs/eval-models.md | 5 +++-- lib/ship-preflight.ts | 3 ++- test/ship-preflight.test.ts | 22 +++++++++++++++++++ 5 files changed, 33 insertions(+), 8 deletions(-) diff --git a/changelog.d/capy-ship-preflight-lsremote-stop.md b/changelog.d/capy-ship-preflight-lsremote-stop.md index 4f50c4748..d1e23ca38 100644 --- a/changelog.d/capy-ship-preflight-lsremote-stop.md +++ b/changelog.d/capy-ship-preflight-lsremote-stop.md @@ -1,7 +1,7 @@ -**`gstack-ship-preflight` stops when it can't read origin's default branch, instead of guessing it from a local ref.** When `git ls-remote --symref origin HEAD` failed and the fetch worked, preflight fell back to the local `origin/HEAD`. With no local `origin/HEAD` it went on from the default branch itself and merged the given base into it. When a PR-branch clone's `origin/HEAD` named the feature branch, it refused that branch as "on the base branch". Both now stop with `git ls-remote origin HEAD failed: …; restore access first` before anything merges. The default-branch check now runs after the fetch, so a failed fetch still reports the fetch. +**`gstack-ship-preflight` no longer guesses origin's default branch from a local ref.** Preflight fell back to the local `origin/HEAD` whenever `git ls-remote --symref origin HEAD` failed or named no branch. With no local `origin/HEAD`, it went on from the default branch itself and merged the given base into it. When a PR-branch clone's `origin/HEAD` named the feature branch, it refused that branch as "on the base branch". Now a failed `ls-remote` stops with `git ls-remote origin HEAD failed: …; restore access first` before anything merges. A remote HEAD that names no branch (a detached HEAD, or a server that sends no symref) falls back to the shared default-base order in `lib/default-base.ts` instead of the local ref. The default-branch check now runs after the fetch, so a failed fetch still reports the fetch. ### Fixed -- **`docs/eval-calls.md`, `docs/eval-models.md`**: the default `--base` is described as base resolution now works: origin/HEAD's branch unless that is the checked-out branch, then the PR's base, `main` or `master`. +- **`docs/eval-calls.md`, `docs/eval-models.md`**: the default `--base` now matches base resolution: origin/HEAD's branch unless it names the checked-out branch, its upstream or HEAD's commit, then the PR's base, the remote's live HEAD, `main` or `master`. ### Tests -- `test/ship-preflight.test.ts`: with `ls-remote` failing and the fetch working, preflight stops before merging both on the default branch with no local `origin/HEAD` (on main it exited 0 and merged `release` into `main`) and on a feature branch that the local `origin/HEAD` names (on main it stopped as "on the base branch"). +- `test/ship-preflight.test.ts`: with `ls-remote` failing and the fetch working, preflight stops before merging in two cases. One is the default branch with no local `origin/HEAD`, where main exited 0 and merged `release` into `main`. The other is a feature branch that the local `origin/HEAD` names, where main stopped as "on the base branch". When `ls-remote` prints only a sha, the default branch still stops as the base branch (main exited 0) and the feature branch goes on. diff --git a/docs/eval-calls.md b/docs/eval-calls.md index 176a589f5..d3b5029c0 100644 --- a/docs/eval-calls.md +++ b/docs/eval-calls.md @@ -22,8 +22,9 @@ dropped), so a command with quotes or `$` never passes through a shell string; 1. `--plan-file `: an operator-given file, read from disk. 2. Otherwise `price_file` in `.gstack/pregate.json` as committed on - `origin/` (`--base`, default origin/HEAD's branch unless that is the - checked-out branch, then the PR's base, `main` or `master`): a repo-relative + `origin/` (`--base`, default origin/HEAD's branch unless it names the + checked-out branch, its upstream or HEAD's commit, then the PR's base, the + remote's live HEAD, `main` or `master`): a repo-relative path whose bytes are read from the **HEAD commit** (`git show HEAD:`), never from the working tree. diff --git a/docs/eval-models.md b/docs/eval-models.md index eabefb8e2..a31d7765b 100644 --- a/docs/eval-models.md +++ b/docs/eval-models.md @@ -66,8 +66,9 @@ unless the command says it is a smoke run. ## Which copy is read By default the reviewed copy is read: `git show origin/:.gstack/eval-models.json` -(`--base`, default origin/HEAD's branch unless that is the checked-out branch, -then the PR's base, `main` or `master`). A branch cannot loosen the rules it +(`--base`, default origin/HEAD's branch unless it names the checked-out branch, +its upstream or HEAD's commit, then the PR's base, the remote's live HEAD, `main` +or `master`). A branch cannot loosen the rules it is checked against. When the working-tree copy differs, an `EVAL_MODELS_NOTE:` line says so. `--models-from head` lints with the working-tree copy instead. Use it for the PR that adds or changes the file; the source label diff --git a/lib/ship-preflight.ts b/lib/ship-preflight.ts index da008876c..1adc89530 100644 --- a/lib/ship-preflight.ts +++ b/lib/ship-preflight.ts @@ -8,6 +8,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import { defaultBaseBranch } from './default-base'; import { classifyDiff } from './review-depth'; import { audit } from './ship-audit'; import { bin, firstLine, flagReader, gh, git, hostedOrigin, laneConcurrency, laneLine, lanes, Report, runLanes, shipPolicy, short } from './ship-bin-common'; @@ -39,7 +40,7 @@ function integrate(cwd: string, base: string, r: Report): boolean { if (fetch.status !== 0) { r.stop(`git fetch origin ${base} failed: ${firstLine(fetch.stderr)}; restore access first`); return false; } const remoteHead = git(cwd, ['ls-remote', '--symref', 'origin', 'HEAD'], 60_000); if (remoteHead.status !== 0) { r.stop(`git ls-remote origin HEAD failed: ${firstLine(remoteHead.stderr)}; restore access first`); return false; } - const head = /^ref: refs\/heads\/(\S+)\s+HEAD$/m.exec(remoteHead.stdout)?.[1] ?? git(cwd, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']).stdout.trim().replace(/^origin\//, ''); + const head = /^ref: refs\/heads\/(\S+)\s+HEAD$/m.exec(remoteHead.stdout)?.[1] ?? defaultBaseBranch(cwd).branch; if (branch === head) { r.stop(`on the base branch (${branch}): ship from a feature branch`); return false; } const stat = git(cwd, ['diff', forkPoint(cwd, base), '--shortstat']).stdout.trim(); const lines = [...stat.matchAll(/(\d+) (insertion|deletion)/g)].reduce((n, m) => n + Number(m[1]), 0); diff --git a/test/ship-preflight.test.ts b/test/ship-preflight.test.ts index 4882bdc7b..ea12627af 100644 --- a/test/ship-preflight.test.ts +++ b/test/ship-preflight.test.ts @@ -79,6 +79,28 @@ describe('gstack-ship-preflight', () => { } }); + // Value: protects=a remote whose HEAD names no branch (detached, or a server that sends no symref) falls back to the default-base ladder, not the local origin/HEAD; + // fails_when=the fallback reads the local ref, so the default branch with no local origin/HEAD merges another base and goes on, and a feature branch a + // Capy checkout's origin/HEAD names is refused as the base branch; why_new=pass-1 review of the ls-remote stop; seam=a git wrapper whose ls-remote prints + // only a sha; no_claim=a repository whose default is neither main nor master + test('with no symbolic remote HEAD, the default branch still stops and a feature branch named by a local origin/HEAD goes on', () => { + const f = fx(); + fs.writeFileSync(path.join(f.root, 'stub', 'git'), `#!/bin/sh\n[ "$1" = ls-remote ] && { echo "0000000000000000000000000000000000000000\tHEAD"; exit 0; }\nexec ${Bun.which('git')} "$@"\n`, { mode: 0o755 }); + f.git('push', '-q', 'origin', 'main:release'); + f.git('checkout', '-q', 'main'); + f.git('remote', 'set-head', 'origin', '-d'); + const onDefault = f.run(PREFLIGHT, ['--base', 'release']); + expect(onDefault.status).toBe(3); + expect(onDefault.stdout).toContain('STOP: on the base branch (main)'); + expect(onDefault.stdout).not.toContain('MERGE:'); + f.git('checkout', '-q', 'feat'); + f.git('update-ref', 'refs/remotes/origin/feat', 'HEAD'); + f.git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/feat'); + const onFeature = f.run(PREFLIGHT, ['--base', 'main']); + expect(onFeature.stdout).not.toContain('STOP: on the base branch'); + expect(onFeature.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', () => { From ef4d28d251450c8cb3ca747612952ecb3626af06 Mon Sep 17 00:00:00 2001 From: garrytan Date: Tue, 3 Nov 2026 02:14:22 +0000 Subject: [PATCH 3/3] changelog: say when the default-base order still reads the local origin/HEAD Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- changelog.d/capy-ship-preflight-lsremote-stop.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/capy-ship-preflight-lsremote-stop.md b/changelog.d/capy-ship-preflight-lsremote-stop.md index d1e23ca38..cacd98b25 100644 --- a/changelog.d/capy-ship-preflight-lsremote-stop.md +++ b/changelog.d/capy-ship-preflight-lsremote-stop.md @@ -1,4 +1,4 @@ -**`gstack-ship-preflight` no longer guesses origin's default branch from a local ref.** Preflight fell back to the local `origin/HEAD` whenever `git ls-remote --symref origin HEAD` failed or named no branch. With no local `origin/HEAD`, it went on from the default branch itself and merged the given base into it. When a PR-branch clone's `origin/HEAD` named the feature branch, it refused that branch as "on the base branch". Now a failed `ls-remote` stops with `git ls-remote origin HEAD failed: …; restore access first` before anything merges. A remote HEAD that names no branch (a detached HEAD, or a server that sends no symref) falls back to the shared default-base order in `lib/default-base.ts` instead of the local ref. The default-branch check now runs after the fetch, so a failed fetch still reports the fetch. +**`gstack-ship-preflight` no longer trusts a local `origin/HEAD` when origin can't say its default branch.** Preflight fell back to the local `origin/HEAD` whenever `git ls-remote --symref origin HEAD` failed or named no branch. With no local `origin/HEAD`, it went on from the default branch itself and merged the given base into it. When a PR-branch clone's `origin/HEAD` named the feature branch, it refused that branch as "on the base branch". Now a failed `ls-remote` stops with `git ls-remote origin HEAD failed: …; restore access first` before anything merges. A remote HEAD that names no branch (a detached HEAD, or a server that sends no symref) falls back to the shared default-base order in `lib/default-base.ts`, which uses the local `origin/HEAD` only when it doesn't name the checked-out branch, its upstream or HEAD's commit. The default-branch check now runs after the fetch, so a failed fetch still reports the fetch. ### Fixed - **`docs/eval-calls.md`, `docs/eval-models.md`**: the default `--base` now matches base resolution: origin/HEAD's branch unless it names the checked-out branch, its upstream or HEAD's commit, then the PR's base, the remote's live HEAD, `main` or `master`.