diff --git a/bin/gstack-diff-scope b/bin/gstack-diff-scope index e7c6d4f1f..dad11e551 100755 --- a/bin/gstack-diff-scope +++ b/bin/gstack-diff-scope @@ -28,20 +28,25 @@ 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, bounded; skipped without a timeout -# binary) -> origin/main -> origin/master -> main. An origin/HEAD skipped that -# way leaves BASE empty when nothing past it resolves, and that fails closed. +# branch, GST-511) -> the PR's base (gh, killed after 5 s) -> 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="" _default_base() { - local cur head pr to - cur=$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true) + local cur head pr out g w + cur=$(git symbolic-ref --quiet HEAD 2>/dev/null || true) + cur="${cur#refs/heads/}" head=$(git symbolic-ref --quiet refs/remotes/origin/HEAD 2>/dev/null || true) head="${head#refs/remotes/origin/}" if [ -n "$head" ] && [ "$head" != "$cur" ]; then BASE="$head"; return; fi SKIPPED_HEAD="$head" - to=$(command -v gtimeout 2>/dev/null || command -v timeout 2>/dev/null || true) - if [ -n "$cur" ] && [ -n "$to" ]; then - pr=$(GH_PROMPT_DISABLED=1 "$to" 5 gh pr view --json baseRefName -q .baseRefName /dev/null | head -n 1 || true) + 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=$! + wait "$g" 2>/dev/null || true + kill "$w" 2>/dev/null || true + pr=$(head -n 1 "$out" 2>/dev/null || true) + rm -f "$out" if [ -n "$pr" ]; then BASE="$pr"; return; fi fi git rev-parse --verify -q origin/main >/dev/null 2>&1 && { BASE="main"; return; } diff --git a/lib/default-base.ts b/lib/default-base.ts index a4326bf7a..fdb9dde51 100644 --- a/lib/default-base.ts +++ b/lib/default-base.ts @@ -27,7 +27,7 @@ function read(cmd: string, args: string[], cwd: string, timeout: number): string } export function defaultBaseBranch(cwd: string, remotes: readonly string[] = ['origin']): DefaultBase { - const current = read('git', ['symbolic-ref', '--quiet', '--short', 'HEAD'], cwd, 30_000); + const current = read('git', ['symbolic-ref', '--quiet', 'HEAD'], cwd, 30_000).replace(/^refs\/heads\//, ''); let skipped: DefaultBase['skipped']; for (const remote of remotes) { const prefix = `refs/remotes/${remote}/`; diff --git a/test/diff-scope.test.ts b/test/diff-scope.test.ts index 1731d5f6d..e273313e9 100644 --- a/test/diff-scope.test.ts +++ b/test/diff-scope.test.ts @@ -451,8 +451,9 @@ describe('uncommitted work is visible (#2299)', () => { // Value: protects=a clone whose origin/HEAD names the checked-out PR branch scopes the PR against origin/main, and with no // other base fails closed naming origin/HEAD (GST-511); fails_when=the default base takes origin/HEAD's branch when it is the - // checked-out one (every SCOPE_*=false, exit 0); why_new=no test pointed origin/HEAD at the feature branch; seam=a gh stub on - // PATH; no_claim=remotes other than origin. + // checked-out one (every SCOPE_*=false, exit 0), including when a tag shares the branch's name, or the default branch's own + // uncommitted work stops being scoped; why_new=no test pointed origin/HEAD at the feature branch; seam=a gh stub on PATH; + // no_claim=remotes other than origin. test('origin/HEAD naming the checked-out branch is skipped: the PR base, then origin/main, and none fails closed', () => { const dir = createRepo(['lib/server.ts']); const git = (...a: string[]) => spawnSync('git', a, { cwd: dir, stdio: 'pipe', timeout: 5000 }); @@ -469,6 +470,17 @@ describe('uncommitted work is visible (#2299)', () => { expect(found.status).toBe(0); expect(found.out).toContain('SCOPE_BACKEND=true'); expect(scope('json', 'no-such-base').out).toContain('SCOPE_ERROR=no_base'); + git('tag', 'feature/test'); + expect(scope('fail').out).toContain('SCOPE_BACKEND=true'); + git('tag', '-d', 'feature/test'); + git('checkout', '-q', 'main'); + git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main'); + mkdirSync(join(dir, 'lib'), { recursive: true }); + writeFileSync(join(dir, 'lib', 'local.ts'), 'export const local = 1;\n'); + expect(scope('fail').out).toContain('SCOPE_BACKEND=true'); + rmSync(join(dir, 'lib', 'local.ts')); + git('checkout', '-q', 'feature/test'); + git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/feature/test'); git('update-ref', '-d', 'refs/remotes/origin/main'); const none = scope('fail'); expect(none.status).toBe(2); diff --git a/test/gstack-ship-policy.test.ts b/test/gstack-ship-policy.test.ts index 2ab777004..40635fa4f 100644 --- a/test/gstack-ship-policy.test.ts +++ b/test/gstack-ship-policy.test.ts @@ -335,6 +335,9 @@ describe('resolveBaseBranch: origin/HEAD naming the checked-out branch (GST-511) return { out: r.stdout ?? '', err: r.stderr ?? '' }; }; expect(base('fail')).toEqual({ out: 'main', err: '' }); + git('tag', 'feat'); + expect(base('fail')).toEqual({ out: 'main', err: '' }); + git('tag', '-d', 'feat'); expect(base('json', 'release').out).toBe('release'); git('update-ref', '-d', 'refs/remotes/origin/main'); const none = base('fail');