diff --git a/bin/gstack-diff-scope b/bin/gstack-diff-scope index dad11e551..6c17a10e8 100755 --- a/bin/gstack-diff-scope +++ b/bin/gstack-diff-scope @@ -28,7 +28,8 @@ set -euo pipefail # Detect the repo's default branch when no arg is given (#703-class # platform-agnostic rule, lib/default-base.ts in bash): origin/HEAD unless it # names the checked-out branch (a PR flow's clone can point it at the PR -# branch, GST-511) -> the PR's base (gh, killed after 5 s) -> origin/main -> +# branch, GST-511) -> the PR's base (gh, killed after 5 s; none for a PR from +# another repository) -> origin/main -> # origin/master -> main. An origin/HEAD skipped that way leaves BASE empty when # nothing past it resolves, and that fails closed. SKIPPED_HEAD="" @@ -41,8 +42,8 @@ _default_base() { if [ -n "$head" ] && [ "$head" != "$cur" ]; then BASE="$head"; return; fi SKIPPED_HEAD="$head" if [ -n "$cur" ] && command -v gh >/dev/null 2>&1 && out=$(mktemp "${TMPDIR:-/tmp}/gstack-diff-scope.XXXXXX" 2>/dev/null); then - GH_PROMPT_DISABLED=1 gh pr view --json baseRefName -q .baseRefName "$out" 2>/dev/null & g=$! - { sleep 5; kill "$g"; } >/dev/null 2>&1 & w=$! + GH_PROMPT_DISABLED=1 gh pr view --json baseRefName,isCrossRepository -q 'if .isCrossRepository then "" else .baseRefName end' "$out" 2>/dev/null & g=$! + ( trap 'kill "$s"' TERM; sleep 5 & s=$!; wait "$s" && kill "$g" ) >/dev/null 2>&1 & w=$! wait "$g" 2>/dev/null || true kill "$w" 2>/dev/null || true pr=$(head -n 1 "$out" 2>/dev/null || true) @@ -98,7 +99,7 @@ _print_flags() { if [ -z "$BASE" ]; then _print_flags echo "SCOPE_ERROR=no_base" - echo "# origin/HEAD names the checked-out branch ${SKIPPED_HEAD}, and no PR base, origin/main or origin/master resolves; pass the base (gstack-diff-scope ) or run git remote set-head origin " + echo "# origin/HEAD names the checked-out branch ${SKIPPED_HEAD}, and no PR base, origin/main or origin/master resolves; pass the base explicitly or run git remote set-head origin " exit 2 fi diff --git a/lib/default-base.ts b/lib/default-base.ts index fdb9dde51..b7b86f3e4 100644 --- a/lib/default-base.ts +++ b/lib/default-base.ts @@ -4,7 +4,7 @@ * checked-out one: a clone made by a PR flow can point origin/HEAD at the PR * branch, and that base diffs the branch against itself, an empty diff every * gate passes. Past it: the PR's base from gh (a short timeout, any failure - * ignored), then `/main`, then `/master`. A detached HEAD keeps + * ignored; none for a PR from another repository), then `/main`, then `/master`. A detached HEAD keeps * the remote's HEAD. bin/gstack-diff-scope carries the same chain in bash. */ import { spawnSync } from 'node:child_process'; @@ -18,6 +18,9 @@ export interface DefaultBase { skipped?: { remote: string; branch: string }; } +/** gh's jq for the PR's base branch; a PR from another repository names a branch origin may not hold, so it names none. */ +const PR_BASE = 'if .isCrossRepository then "" else .baseRefName end'; + function read(cmd: string, args: string[], cwd: string, timeout: number): string { const r = spawnSync(cmd, args, { cwd, encoding: 'utf8', timeout, stdio: ['ignore', 'pipe', 'pipe'], @@ -37,7 +40,7 @@ export function defaultBaseBranch(cwd: string, remotes: readonly string[] = ['or if (branch !== current) return { branch, remote }; skipped ??= { remote, branch }; } - const pr = current ? read('gh', ['pr', 'view', '--json', 'baseRefName', '-q', '.baseRefName'], cwd, 5_000).split('\n')[0] : ''; + const pr = current ? read('gh', ['pr', 'view', '--json', 'baseRefName,isCrossRepository', '-q', PR_BASE], cwd, 5_000).split('\n')[0] : ''; if (pr) return { branch: pr, skipped }; for (const name of ['main', 'master']) { if (remotes.some((remote) => read('git', ['rev-parse', '--verify', '--quiet', `refs/remotes/${remote}/${name}^{commit}`], cwd, 30_000))) return { branch: name, skipped }; @@ -47,5 +50,5 @@ export function defaultBaseBranch(cwd: string, remotes: readonly string[] = ['or /** Why there is no default base: a remote HEAD named the checked-out branch and nothing past it resolved. */ export function noDefaultBaseLine({ remote, branch }: { remote: string; branch: string }): string { - return `${remote}/HEAD names the checked-out branch ${branch}, and no PR base, ${remote}/main or ${remote}/master resolves; pass --base or run git remote set-head ${remote} `; + return `${remote}/HEAD names the checked-out branch ${branch}, and no PR base, ${remote}/main or ${remote}/master resolves; pass the base explicitly or run git remote set-head ${remote} `; } diff --git a/lib/pregate/remote-receipt.ts b/lib/pregate/remote-receipt.ts index 1d5fa7810..3f8d5a133 100644 --- a/lib/pregate/remote-receipt.ts +++ b/lib/pregate/remote-receipt.ts @@ -59,7 +59,7 @@ export function baseBranchOf(base: string | undefined): string | null { /** * The fix for a pregate.json recorded against `recorded` when the ship's base - * is `base`. `resolveBaseBranch` falls back to `main` when origin/HEAD is unset, + * is `base`. `resolveBaseBranch` falls back to `main` when no default base resolves, * so a repo whose base is another branch is told how to set it rather than * sent to rerun against a branch it may not have. */ diff --git a/lib/ship-preflight.ts b/lib/ship-preflight.ts index fd0c4d134..b950ba467 100644 --- a/lib/ship-preflight.ts +++ b/lib/ship-preflight.ts @@ -29,13 +29,15 @@ 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 = /^ref: refs\/heads\/(\S+)\s+HEAD$/m.exec(git(cwd, ['ls-remote', '--symref', 'origin', 'HEAD'], 60_000).stdout)?.[1]; 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; } + if (branch === /^ref: refs\/heads\/(\S+)\s+HEAD$/m.exec(remoteHead.stdout)?.[1]) { 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/gate-diff-cli.test.ts b/test/gate-diff-cli.test.ts index f28deba08..14076dc12 100644 --- a/test/gate-diff-cli.test.ts +++ b/test/gate-diff-cli.test.ts @@ -500,7 +500,7 @@ describe('gstack-gate-diff candidate model and output', () => { const none = runNoBase(); expect(none.status).toBe(2); expect(none.stdout.trim()).toBe('GATE_ERROR=no_base ref=origin/HEAD target=origin/feature fix=git remote set-head origin '); - expect(none.stderr).toContain('gstack-gate-diff: origin/HEAD names the checked-out branch feature, and no PR base, origin/main or origin/master resolves; pass --base or run git remote set-head origin '); + expect(none.stderr).toContain('gstack-gate-diff: origin/HEAD names the checked-out branch feature, and no PR base, origin/main or origin/master resolves; pass the base explicitly or run git remote set-head origin '); }); test('base defaults to main when no ref is given and no origin/HEAD exists', () => { diff --git a/test/gstack-ship-policy.test.ts b/test/gstack-ship-policy.test.ts index 40635fa4f..03b79afbe 100644 --- a/test/gstack-ship-policy.test.ts +++ b/test/gstack-ship-policy.test.ts @@ -342,7 +342,7 @@ describe('resolveBaseBranch: origin/HEAD naming the checked-out branch (GST-511) git('update-ref', '-d', 'refs/remotes/origin/main'); const none = base('fail'); expect(none.out).toBe('main'); - expect(none.err).toContain('origin/HEAD names the checked-out branch feat, and no PR base, origin/main or origin/master resolves; pass --base or run git remote set-head origin '); + expect(none.err).toContain('origin/HEAD names the checked-out branch feat, and no PR base, origin/main or origin/master resolves; pass the base explicitly or run git remote set-head origin '); git('checkout', '-q', '--detach'); expect(base('fail')).toEqual({ out: 'feat', err: '' }); } finally { diff --git a/test/ship-preflight.test.ts b/test/ship-preflight.test.ts index ad339aab6..41c43b8ab 100644 --- a/test/ship-preflight.test.ts +++ b/test/ship-preflight.test.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, setDefaultTimeout, test } from 'bun:test'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { spawnSync } from 'node:child_process'; import { lineOf, PREFLIGHT, shipFixture, type ShipFx } from './helpers/ship-bin-fixture'; setDefaultTimeout(60_000); @@ -68,6 +69,21 @@ describe('gstack-ship-preflight', () => { expect(r.status).toBe(0); }); + // Value: protects=a failed lookup of the remote's HEAD stops before merging instead of skipping the default-branch abort; fails_when=on the remote's default + // branch with another --base, a failed ls-remote reads as "no default" and the base is merged in; why_new=GST-511 removed the local fallback; 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(); + f.git('push', '-q', 'origin', 'main:release'); + f.git('checkout', '-q', 'main'); + const real = spawnSync('sh', ['-c', 'command -v git'], { encoding: 'utf8', timeout: 5_000 }).stdout.trim(); + fs.writeFileSync(path.join(f.root, 'stub', 'git'), `#!/bin/sh\n[ "$1" = ls-remote ] && { echo "ls-remote: unreachable" >&2; exit 128; }\nexec ${real} "$@"\n`, { mode: 0o755 }); + const r = f.run(PREFLIGHT, ['--base', 'release']); + 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', () => {