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>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-11-03 01:54:43 +00:00
1 parent 54606c525c
commit c3b3c37d03
5 files changed
+38 -5

No files matched your search

@@ -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").
+2 -1
View File
@@ -22,7 +22,8 @@ dropped), so a command with quotes or `$` never passes through a shell string;
1. `--plan-file <file>`: an operator-given file, read from disk.
2. Otherwise `price_file` in `.gstack/pregate.json` as committed on
`origin/<base>` (`--base`, default origin/HEAD's branch): a repo-relative
`origin/<base>` (`--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:<path>`),
never from the working tree.
+2 -1
View File
@@ -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/<base>:.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
+5 -3
View File
@@ -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();
+22
View File
@@ -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', () => {