From 4aa052350b9e26ac990a6eda1a86675e75b9738b Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 16:30:53 +0000 Subject: [PATCH 1/4] fix(gate-diff): read a bare base as origin/ in gate-diff and diff-scope (#3650) gstack-gate-diff and gstack-diff-scope resolved a bare base such as main to the local branch whenever it shared history with HEAD. A local main that is behind origin/main (a linked worktree, or a Capy clone, where local main never moves) then set the merge base, so /ship's gate scan and specialist scope also read commits already upstream. Both now prefer origin/ when it resolves, the base /ship fetches and merges against. Fixes #3650 Co-authored-by: exGeni <135786749+exGeni@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-diff-scope | 7 +++++ bin/gstack-gate-diff | 2 +- changelog.d/gsta246-gate-diff-origin-base.md | 1 + lib/gate-diff/git.ts | 27 ++++++++++---------- test/diff-scope.test.ts | 19 ++++++++++++++ test/gate-diff-cli.test.ts | 26 +++++++++++++++++++ 6 files changed, 68 insertions(+), 14 deletions(-) create mode 100644 changelog.d/gsta246-gate-diff-origin-base.md diff --git a/bin/gstack-diff-scope b/bin/gstack-diff-scope index 3ec81cf09..83f7ba620 100755 --- a/bin/gstack-diff-scope +++ b/bin/gstack-diff-scope @@ -34,6 +34,13 @@ _default_base() { echo "main" } BASE="${1:-$(_default_base)}" +# A bare branch name reads its remote-tracking twin when there is one: /ship +# merges against origin/, and a linked worktree or a Capy clone never +# moves the local branch, so a stale one widens the scope (#3650). +case "$BASE" in + */*|*[~^:@]*) ;; + *) git rev-parse --verify -q "refs/remotes/origin/${BASE}^{commit}" >/dev/null 2>&1 && BASE="origin/${BASE}" ;; +esac HEAD_REF="" if [ "${2:-}" = "--head" ]; then HEAD_REF="${3:-}"; fi diff --git a/bin/gstack-gate-diff b/bin/gstack-gate-diff index 0a1af715c..d09b9bf7c 100755 --- a/bin/gstack-gate-diff +++ b/bin/gstack-gate-diff @@ -148,7 +148,7 @@ function main(argv: string[]): number { process.stdout.write(`GATE_ERROR=no_base ref=${baseRef} fix=${noBaseFix(baseRef, root)}\n`); return 2; } - if (base.local) console.error(`gstack-gate-diff: ${base.local === 'unrelated' ? `local ${baseRef} shares no history with HEAD` : `no local branch ${baseRef}`}; base is ${base.ref}`); + if (base.local) console.error(`gstack-gate-diff: ${base.local === 'unrelated' ? `local ${baseRef} shares no history with HEAD` : base.local === 'differs' ? `local ${baseRef} is not ${base.ref}` : `no local branch ${baseRef}`}; base is ${base.ref}`); const mb = mergeBase(base.sha, head, root); if (!mb) { process.stdout.write(`GATE_ERROR=no_base ref=${baseRef} fix=${noBaseFix(baseRef, root)}\n`); diff --git a/changelog.d/gsta246-gate-diff-origin-base.md b/changelog.d/gsta246-gate-diff-origin-base.md new file mode 100644 index 000000000..7f68b21e3 --- /dev/null +++ b/changelog.d/gsta246-gate-diff-origin-base.md @@ -0,0 +1 @@ +- `gstack-gate-diff` and `gstack-diff-scope` now read a bare base name such as `main` as `origin/main` when that ref exists, the base /ship fetches and merges against. Before, a local `main` that was behind `origin/main` (a linked worktree, or a Capy clone, where local `main` never moves) set the merge base, so the gate scan and the specialist scope also read commits already on `origin/main`. One /ship run in #3650 listed a `.github/workflows/ci.yml` hunk from upstream and dispatched an extra performance reviewer. `gstack-gate-diff` says so on stderr (`local main is not origin/main; base is origin/main`). An explicit `origin/main` or commit is unchanged (#3650). diff --git a/lib/gate-diff/git.ts b/lib/gate-diff/git.ts index a9d4f07c0..ee60115b9 100644 --- a/lib/gate-diff/git.ts +++ b/lib/gate-diff/git.ts @@ -85,22 +85,23 @@ export function isShallow(cwd: string): boolean { /** * The base as a ref that resolves here and, when `head` is given, shares - * history with it: the ref itself, else its remote-tracking branch - * (`origin/main` for `main`). A clone that checked out only the feature - * branch has no local `main`, and the merge-base against `origin/main` is the - * same commit; a stale local `main` that shares no history with HEAD (a - * fetched-over clone) yields to the twin too, so the fix line's fetch into the - * tracking ref is a fix and not a loop. + * history with it: for a branch name its remote-tracking branch first + * (`origin/main` for `main`), the base /ship merges against, else the ref + * itself. A local `main` that is missing, behind (a linked worktree or a Capy + * clone never moves it, #3650) or shares no history with HEAD (a fetched-over + * clone) yields to the twin, so the fix line's fetch into the tracking ref is + * a fix and not a loop. */ -export function resolveBase(ref: string, cwd: string, head?: string): { ref: string; sha: string; local?: 'missing' | 'unrelated' } | null { +export function resolveBase(ref: string, cwd: string, head?: string): { ref: string; sha: string; local?: 'missing' | 'unrelated' | 'differs' } | null { const related = (sha: string) => !head || mergeBase(sha, head, cwd) !== null; const direct = resolvesToCommit(ref, cwd); - if (direct && related(direct)) return { ref, sha: direct }; - if (!isBranchName(ref)) return direct ? { ref, sha: direct } : null; - for (const remote of remoteNames(cwd)) { - const tracking = `${remote}/${ref}`; - const sha = resolvesToCommit(tracking, cwd); - if (sha && related(sha)) return { ref: tracking, sha, local: direct ? 'unrelated' : 'missing' }; + if (isBranchName(ref)) { + for (const remote of remoteNames(cwd)) { + const tracking = `${remote}/${ref}`; + const sha = resolvesToCommit(tracking, cwd); + if (!sha || !related(sha)) continue; + return sha === direct ? { ref: tracking, sha } : { ref: tracking, sha, local: !direct ? 'missing' : related(direct) ? 'differs' : 'unrelated' }; + } } return direct ? { ref, sha: direct } : null; } diff --git a/test/diff-scope.test.ts b/test/diff-scope.test.ts index e33469c1a..67c01b06a 100644 --- a/test/diff-scope.test.ts +++ b/test/diff-scope.test.ts @@ -330,6 +330,25 @@ describe('exit-code contract (#2526)', () => { }); }); +describe('a bare base name reads origin/ when it exists (#3650)', () => { + test('a local main behind origin/main does not widen the scope to upstream commits', () => { + const dir = createRepo(['README2.md']); + const git = (...args: string[]) => spawnSync('git', args, { cwd: dir, stdio: 'pipe', timeout: 5000 }).stdout.toString().trim(); + const seed = git('rev-parse', 'main'); + git('checkout', '-q', 'main'); + writeFileSync(join(dir, 'service.py'), 'print(1)\n'); + git('add', '.'); + git('commit', '-qm', 'upstream backend change'); + git('update-ref', 'refs/remotes/origin/main', 'HEAD'); + git('checkout', '-q', 'feature/test'); + git('rebase', '-q', 'origin/main'); + git('branch', '-f', 'main', seed); + expect(git('diff', '--name-only', 'main...HEAD').split('\n').sort()).toEqual(['README2.md', 'service.py']); + const scope = runScope(dir); + expect([scope.SCOPE_BACKEND, scope.SCOPE_DOCS]).toEqual(['false', 'true']); + }); +}); + describe('uncommitted work is visible (#2299)', () => { test('uncommitted change on a branch with no commits sets the scope', () => { const dir = mkdtempSync(join(tmpdir(), 'diff-scope-dirty-')); diff --git a/test/gate-diff-cli.test.ts b/test/gate-diff-cli.test.ts index 3a8545e09..ce8aace18 100644 --- a/test/gate-diff-cli.test.ts +++ b/test/gate-diff-cli.test.ts @@ -153,6 +153,32 @@ describe('gstack-gate-diff arguments and exit contract', () => { // fetches from it; fails_when=remoteNames or resolveBase hard-code origin, or noBaseFix falls back to the literal 'origin' // while a real remote exists; why_new=every existing fixture has either an origin remote or none; seam=none; // no_claim=nothing about the order when origin and another remote both carry the branch. + // Value: protects=a bare base name scans from the same merge base /ship merges against (origin/) when the local + // branch is related but behind (#3650); fails_when=resolveBase prefers any related local branch, so a stale local main + // widens the scan to commits already upstream; why_new=the stale-main test above covers only unrelated history; + // seam=none; no_claim=nothing about a local base that is ahead of its remote-tracking twin. + test('a related local main that is behind origin/main yields to origin/main (#3650)', () => { + const src = repo({ fixture: 'rh1-skip', commit: true }); + const seed = gitIn(src, ['rev-parse', 'main']).trim(); + gitIn(src, ['checkout', '-q', 'main']); + fs.mkdirSync(path.join(src, '.github', 'workflows'), { recursive: true }); + fs.writeFileSync(path.join(src, '.github', 'workflows', 'ci.yml'), 'on: push\njobs: {}\n'); + gitIn(src, ['add', '-A']); + gitIn(src, ['commit', '-q', '-m', 'upstream ci change']); + gitIn(src, ['checkout', '-q', 'feature']); + gitIn(src, ['rebase', '-q', 'main']); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-behind-')); + repos.push(dir); + gitIn(dir, ['clone', '-q', '--branch', 'feature', `file://${src}`, '.']); + gitIn(dir, ['branch', '-f', 'main', seed]); + const bare = run(dir, ['main']); + const remote = run(dir, ['origin/main']); + expect([bare.status, remote.status]).toEqual([0, 0]); + expect(summary(bare.stdout).candidate).toBe(summary(remote.stdout).candidate); + expect(bare.stdout).toBe(remote.stdout); + expect(bare.stderr).toContain('gstack-gate-diff: local main is not origin/main; base is origin/main'); + }); + test('a clone whose only remote is upstream resolves the base to upstream/main, and the fix line fetches from upstream', () => { const src = repo({ fixture: 'rh1-skip', commit: true }); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-upstream-')); From 87b7c922e07e7371185787b58c792b980d898ac8 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 16:45:45 +0000 Subject: [PATCH 2/4] fix: pre-landing review fixes (diff-scope reads slash-named bases through origin too) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-diff-scope | 8 ++++---- test/diff-scope.test.ts | 27 +++++++++++++++++++++------ 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/bin/gstack-diff-scope b/bin/gstack-diff-scope index 83f7ba620..d481cbb8a 100755 --- a/bin/gstack-diff-scope +++ b/bin/gstack-diff-scope @@ -37,10 +37,10 @@ BASE="${1:-$(_default_base)}" # A bare branch name reads its remote-tracking twin when there is one: /ship # merges against origin/, and a linked worktree or a Capy clone never # moves the local branch, so a stale one widens the scope (#3650). -case "$BASE" in - */*|*[~^:@]*) ;; - *) git rev-parse --verify -q "refs/remotes/origin/${BASE}^{commit}" >/dev/null 2>&1 && BASE="origin/${BASE}" ;; -esac +_NOT_BRANCH='^[0-9a-f]{7,64}$|[~^]|@\{|^[A-Z_]*HEAD$|^refs/' +if [[ ! "$BASE" =~ $_NOT_BRANCH ]] && git rev-parse --verify -q "refs/remotes/origin/${BASE}^{commit}" >/dev/null 2>&1; then + BASE="origin/${BASE}" +fi HEAD_REF="" if [ "${2:-}" = "--head" ]; then HEAD_REF="${3:-}"; fi diff --git a/test/diff-scope.test.ts b/test/diff-scope.test.ts index 67c01b06a..f9d31df84 100644 --- a/test/diff-scope.test.ts +++ b/test/diff-scope.test.ts @@ -331,22 +331,37 @@ describe('exit-code contract (#2526)', () => { }); describe('a bare base name reads origin/ when it exists (#3650)', () => { - test('a local main behind origin/main does not widen the scope to upstream commits', () => { + function behindBase(base: string) { const dir = createRepo(['README2.md']); const git = (...args: string[]) => spawnSync('git', args, { cwd: dir, stdio: 'pipe', timeout: 5000 }).stdout.toString().trim(); const seed = git('rev-parse', 'main'); - git('checkout', '-q', 'main'); + git('checkout', '-q', '-B', base, 'main'); writeFileSync(join(dir, 'service.py'), 'print(1)\n'); git('add', '.'); git('commit', '-qm', 'upstream backend change'); - git('update-ref', 'refs/remotes/origin/main', 'HEAD'); + git('update-ref', `refs/remotes/origin/${base}`, 'HEAD'); git('checkout', '-q', 'feature/test'); - git('rebase', '-q', 'origin/main'); - git('branch', '-f', 'main', seed); - expect(git('diff', '--name-only', 'main...HEAD').split('\n').sort()).toEqual(['README2.md', 'service.py']); + git('rebase', '-q', `origin/${base}`); + git('branch', '-f', base, seed); + expect(git('diff', '--name-only', `${base}...HEAD`).split('\n').sort()).toEqual(['README2.md', 'service.py']); + return { dir, git }; + } + const scopeAt = (dir: string, base: string) => Object.fromEntries(spawnSync('bash', [SCRIPT, base], { cwd: dir, stdio: 'pipe', timeout: 5000 }).stdout.toString().trim().split('\n').map(l => l.split('='))); + + test('a local main behind origin/main does not widen the scope to upstream commits', () => { + const { dir } = behindBase('main'); const scope = runScope(dir); expect([scope.SCOPE_BACKEND, scope.SCOPE_DOCS]).toEqual(['false', 'true']); }); + + test('a slash-named base reads its remote-tracking twin too, and HEAD or a sha stays as given', () => { + const { dir, git } = behindBase('release/x'); + expect(scopeAt(dir, 'release/x').SCOPE_BACKEND).toBe('false'); + git('update-ref', 'refs/remotes/origin/HEAD', git('rev-parse', 'release/x')); + expect(scopeAt(dir, 'HEAD').SCOPE_DOCS).toBe('false'); + git('update-ref', `refs/remotes/origin/${git('rev-parse', 'release/x')}`, 'HEAD'); + expect(scopeAt(dir, git('rev-parse', 'release/x')).SCOPE_BACKEND).toBe('true'); + }); }); describe('uncommitted work is visible (#2299)', () => { From 2a1f123d9eec9719a5aab475cc2fe7b896bbedba Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 16:54:11 +0000 Subject: [PATCH 3/4] fix: Step 11 review fixes (a fork branch, a colliding tag or another remote never replaces the base) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-diff-scope | 21 ++++++++++++++------- lib/gate-diff/git.ts | 34 +++++++++++++++++++++------------- test/diff-scope.test.ts | 18 ++++++++++++++++++ test/gate-diff-cli.test.ts | 27 +++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 20 deletions(-) diff --git a/bin/gstack-diff-scope b/bin/gstack-diff-scope index d481cbb8a..6bfef3252 100755 --- a/bin/gstack-diff-scope +++ b/bin/gstack-diff-scope @@ -34,15 +34,22 @@ _default_base() { echo "main" } BASE="${1:-$(_default_base)}" -# A bare branch name reads its remote-tracking twin when there is one: /ship -# merges against origin/, and a linked worktree or a Capy clone never -# moves the local branch, so a stale one widens the scope (#3650). -_NOT_BRANCH='^[0-9a-f]{7,64}$|[~^]|@\{|^[A-Z_]*HEAD$|^refs/' -if [[ ! "$BASE" =~ $_NOT_BRANCH ]] && git rev-parse --verify -q "refs/remotes/origin/${BASE}^{commit}" >/dev/null 2>&1; then - BASE="origin/${BASE}" -fi HEAD_REF="" if [ "${2:-}" = "--head" ]; then HEAD_REF="${3:-}"; fi +# A bare branch name reads origin's tracking branch, by its full ref, when that +# shares history with HEAD: /ship merges against origin/, and a linked +# worktree or a Capy clone never moves the local branch, so a stale one widens +# the scope (#3650). A remote-qualified name is taken as given. +_NOT_BRANCH='^[0-9a-f]{7,64}$|[~^]|@\{|^[A-Z_]*HEAD$|^refs/' +_QUALIFIED=false +for _remote in $(git remote 2>/dev/null); do + case "$BASE" in "$_remote"/*) _QUALIFIED=true ;; esac +done +if [[ ! "$BASE" =~ $_NOT_BRANCH ]] && [ "$_QUALIFIED" = false ] \ + && git rev-parse --verify -q "refs/remotes/origin/${BASE}^{commit}" >/dev/null 2>&1 \ + && git merge-base "refs/remotes/origin/${BASE}" "${HEAD_REF:-HEAD}" >/dev/null 2>&1; then + BASE="refs/remotes/origin/${BASE}" +fi FRONTEND=false BACKEND=false diff --git a/lib/gate-diff/git.ts b/lib/gate-diff/git.ts index ee60115b9..cac2f298d 100644 --- a/lib/gate-diff/git.ts +++ b/lib/gate-diff/git.ts @@ -85,23 +85,31 @@ export function isShallow(cwd: string): boolean { /** * The base as a ref that resolves here and, when `head` is given, shares - * history with it: for a branch name its remote-tracking branch first - * (`origin/main` for `main`), the base /ship merges against, else the ref - * itself. A local `main` that is missing, behind (a linked worktree or a Capy - * clone never moves it, #3650) or shares no history with HEAD (a fetched-over - * clone) yields to the twin, so the fix line's fetch into the tracking ref is - * a fix and not a loop. + * history with it. For a bare branch name that is origin's tracking branch + * (`refs/remotes/origin/main` for `main`), the base /ship merges against, + * else the related local branch, else another remote's tracking branch. A + * local `main` that is behind (a linked worktree or a Capy clone never moves + * it, #3650), missing or unrelated to HEAD (a fetched-over clone) yields to the + * twin, so the fix line's fetch into the tracking ref is a fix and not a loop. + * A remote-qualified name (`upstream/main`) is taken as given, and the twin is + * read by its full ref, so a fork's branch or a tag cannot stand in for it. */ export function resolveBase(ref: string, cwd: string, head?: string): { ref: string; sha: string; local?: 'missing' | 'unrelated' | 'differs' } | null { const related = (sha: string) => !head || mergeBase(sha, head, cwd) !== null; const direct = resolvesToCommit(ref, cwd); - if (isBranchName(ref)) { - for (const remote of remoteNames(cwd)) { - const tracking = `${remote}/${ref}`; - const sha = resolvesToCommit(tracking, cwd); - if (!sha || !related(sha)) continue; - return sha === direct ? { ref: tracking, sha } : { ref: tracking, sha, local: !direct ? 'missing' : related(direct) ? 'differs' : 'unrelated' }; - } + const remotes = remoteNames(cwd); + if (!isBranchName(ref) || remotes.some(remote => ref.startsWith(`${remote}/`))) return direct ? { ref, sha: direct } : null; + const twin = (remote: string) => { + const sha = resolvesToCommit(`refs/remotes/${remote}/${ref}`, cwd); + if (!sha || !related(sha)) return null; + return sha === direct ? { ref: `${remote}/${ref}`, sha } : { ref: `${remote}/${ref}`, sha, local: !direct ? 'missing' as const : related(direct) ? 'differs' as const : 'unrelated' as const }; + }; + const origin = remotes.includes('origin') ? twin('origin') : null; + if (origin) return origin; + if (direct && related(direct)) return { ref, sha: direct }; + for (const remote of remotes) { + const other = twin(remote); + if (other) return other; } return direct ? { ref, sha: direct } : null; } diff --git a/test/diff-scope.test.ts b/test/diff-scope.test.ts index f9d31df84..05192b07e 100644 --- a/test/diff-scope.test.ts +++ b/test/diff-scope.test.ts @@ -362,6 +362,24 @@ describe('a bare base name reads origin/ when it exists (#3650)', () => { git('update-ref', `refs/remotes/origin/${git('rev-parse', 'release/x')}`, 'HEAD'); expect(scopeAt(dir, git('rev-parse', 'release/x')).SCOPE_BACKEND).toBe('true'); }); + + test('a colliding tag, a fork branch over an explicit remote base, or an unrelated twin never replaces the base (#3650 review)', () => { + const { dir, git } = behindBase('main'); + const upstreamCommit = git('rev-parse', 'origin/main'); + git('tag', 'origin/main', 'HEAD'); + expect([scopeAt(dir, 'main').SCOPE_BACKEND, scopeAt(dir, 'main').SCOPE_DOCS]).toEqual(['false', 'true']); + git('remote', 'add', 'upstream', 'file:///nonexistent-upstream'); + git('update-ref', 'refs/remotes/upstream/main', git('rev-parse', 'main')); + git('update-ref', 'refs/remotes/origin/upstream/main', 'HEAD'); + expect(scopeAt(dir, 'upstream/main').SCOPE_BACKEND).toBe('true'); + git('tag', '-d', 'origin/main'); + git('checkout', '-q', '--orphan', 'island'); + git('commit', '-q', '--allow-empty', '-m', 'unrelated'); + git('update-ref', 'refs/remotes/origin/main', 'HEAD'); + git('checkout', '-q', 'feature/test'); + expect(scopeAt(dir, 'main').SCOPE_BACKEND).toBe('true'); + expect(upstreamCommit).not.toBe(''); + }); }); describe('uncommitted work is visible (#2299)', () => { diff --git a/test/gate-diff-cli.test.ts b/test/gate-diff-cli.test.ts index ce8aace18..315bfccda 100644 --- a/test/gate-diff-cli.test.ts +++ b/test/gate-diff-cli.test.ts @@ -179,6 +179,33 @@ describe('gstack-gate-diff arguments and exit contract', () => { expect(bare.stderr).toContain('gstack-gate-diff: local main is not origin/main; base is origin/main'); }); + // Value: protects=a ref a contributor controls (a fork's origin/upstream/main branch, a tag named origin/main, another + // remote's main) never replaces the base the caller named or a related local base, so it cannot hide a gate edit; + // fails_when=resolveBase rewrites remote-qualified names, resolves the twin by its short name, or lets any remote beat a + // related local base; why_new=the cases above have one remote and no colliding refs; seam=none; no_claim=nothing about + // a remote's HEAD symref. + test('a fork branch, a colliding tag or another remote never replaces the base (#3650 review)', () => { + const src = repo({ fixture: 'rh1-skip', commit: true }); + const seed = gitIn(src, ['rev-parse', 'main']).trim(); + const clone = (remote: string) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-refs-')); + repos.push(dir); + gitIn(dir, ['clone', '-q', '-o', remote, '--branch', 'feature', `file://${src}`, '.']); + return dir; + }; + const named = clone('upstream'); + gitIn(named, ['remote', 'add', 'origin', 'file:///nonexistent-fork']); + gitIn(named, ['update-ref', 'refs/remotes/origin/upstream/main', 'HEAD']); + expect(summary(run(named, ['upstream/main']).stdout).tagged).toBe('RH-1:1'); + const tagged = clone('origin'); + gitIn(tagged, ['tag', 'origin/main', 'HEAD']); + expect(summary(run(tagged, ['main']).stdout).tagged).toBe('RH-1:1'); + const fork = clone('zfork'); + gitIn(fork, ['branch', '-f', 'main', seed]); + gitIn(fork, ['update-ref', 'refs/remotes/zfork/main', 'HEAD']); + expect(summary(run(fork, ['main']).stdout).tagged).toBe('RH-1:1'); + }); + test('a clone whose only remote is upstream resolves the base to upstream/main, and the fix line fetches from upstream', () => { const src = repo({ fixture: 'rh1-skip', commit: true }); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-upstream-')); From 3675e472744b9b7d2848798fb55a0737447dbd89 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 16:58:14 +0000 Subject: [PATCH 4/4] docs(changelog): describe the fork, tag and remote guards (#3650) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- changelog.d/gsta246-gate-diff-origin-base.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/gsta246-gate-diff-origin-base.md b/changelog.d/gsta246-gate-diff-origin-base.md index 7f68b21e3..9ee4db95d 100644 --- a/changelog.d/gsta246-gate-diff-origin-base.md +++ b/changelog.d/gsta246-gate-diff-origin-base.md @@ -1 +1 @@ -- `gstack-gate-diff` and `gstack-diff-scope` now read a bare base name such as `main` as `origin/main` when that ref exists, the base /ship fetches and merges against. Before, a local `main` that was behind `origin/main` (a linked worktree, or a Capy clone, where local `main` never moves) set the merge base, so the gate scan and the specialist scope also read commits already on `origin/main`. One /ship run in #3650 listed a `.github/workflows/ci.yml` hunk from upstream and dispatched an extra performance reviewer. `gstack-gate-diff` says so on stderr (`local main is not origin/main; base is origin/main`). An explicit `origin/main` or commit is unchanged (#3650). +- `gstack-gate-diff` and `gstack-diff-scope` now read a bare base name such as `main` as origin's tracking branch (`refs/remotes/origin/main`) when it exists and shares history with HEAD. That's the base /ship fetches and merges against. Before, a local `main` that was behind `origin/main` set the merge base. That happens in a linked worktree, and on a Capy machine, where local `main` never moves. So the gate scan and the specialist scope also read commits already on `origin/main`. One /ship run in #3650 listed a `.github/workflows/ci.yml` hunk from upstream and dispatched an extra performance reviewer. `gstack-gate-diff` now says `local main is not origin/main; base is origin/main` on stderr when that happens. A base you name with its remote (`upstream/main`), a commit or `HEAD` is used as given. A tag named `origin/main` or a fork's `origin/upstream/main` branch can't stand in for the base, and another remote's branch only replaces a local base that is missing or unrelated (#3650).