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', () => {