From 386d3e70517d1aea18f1f7639c5ed5444d8ac05f Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Sun, 26 Jul 2026 18:23:56 -0700 Subject: [PATCH] Cover every remote in each candidate's rev list Cursor and Greptile converged on one root cause from the previous round: candidate revs stopped at origin (develop tried only develop and origin/develop; a remote-default entry carried only its own rev), so the name-level dedup discarded a same-name base living on another remote. A fork-parent layout with develop only as upstream/develop, or a pruned origin/main beside a live upstream/main, lost its base entirely. revsFor(name) now expands to the local branch plus / for every remote (origin first), and all named candidates use it, which is exactly what makes the dedup safe. Two failing-first tests cover the upstream-only develop and the pruned-origin/live-upstream main shapes. Prepared with AI assistance (Claude Code), directed by @pbakaus. Co-Authored-By: Claude Code --- skill/scripts/context-signals.mjs | 13 +++++++-- tests/context-signals.test.mjs | 46 +++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/skill/scripts/context-signals.mjs b/skill/scripts/context-signals.mjs index 743130a3c..fd3613e48 100644 --- a/skill/scripts/context-signals.mjs +++ b/skill/scripts/context-signals.mjs @@ -131,6 +131,13 @@ function gitSignals(cwd) { let baseRev = null; if (!onIntegrationBranch) { const upstream = asUpstream(run(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])); + // Every named candidate tries the local branch first, then that name on + // every remote (origin first). Covering all remotes up front is what + // makes the name-level dedup below safe: a develop or main that exists + // only as upstream/ still resolves even though origin's candidate + // claimed the name first. + const remoteOrder = ['origin', ...remotes.filter((name) => name !== 'origin')]; + const revsFor = (name) => [name, ...remoteOrder.map((r) => `${r}/${name}`)]; const candidates = []; const seen = new Set(); const addCandidate = (name, revs) => { @@ -145,9 +152,9 @@ function gitSignals(cwd) { // even when the platform default (origin/HEAD) was never flipped off // main; an existing develop therefore outranks the remote default. This // is #302's own repro shape, and repos without develop are unaffected. - addCandidate('develop', ['develop', 'origin/develop']); - for (const head of remoteHeads) addCandidate(head.name, [head.name, head.rev]); - for (const name of ['main', 'master']) addCandidate(name, [name, `origin/${name}`]); + addCandidate('develop', revsFor('develop')); + for (const head of remoteHeads) addCandidate(head.name, revsFor(head.name)); + for (const name of ['main', 'master']) addCandidate(name, revsFor(name)); for (const c of candidates) { const rev = c.revs.find((r) => run(['rev-parse', '--verify', '--quiet', r]) !== null); if (rev) { diff --git a/tests/context-signals.test.mjs b/tests/context-signals.test.mjs index a0ce8e959..43daf8975 100644 --- a/tests/context-signals.test.mjs +++ b/tests/context-signals.test.mjs @@ -436,6 +436,52 @@ describe('gatherSignals', () => { assert.deepEqual(s.git.changedFiles, ['src/App.tsx']); }); + it('finds a develop that exists only on a non-origin remote (#302)', async () => { + const { execFileSync } = await import('node:child_process'); + const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); + git('init', '-q', '-b', 'feature/f'); + git('config', 'user.email', 't@example.com'); + git('config', 'user.name', 'Test'); + write('src/App.tsx', 'export default 1;\n'); + git('add', '.'); + git('commit', '-qm', 'init'); + // Fork-parent layout: develop lives only as upstream/develop, no local + // copy, no origin remote, and the feature branch has no upstream. + git('remote', 'add', 'upstream', '.'); + git('update-ref', 'refs/remotes/upstream/develop', 'HEAD'); + write('src/Hero.tsx', 'export const Hero = () => null;\n'); + git('add', '.'); + git('commit', '-qm', 'feature work'); + const s = await gatherSignals(scratch); + assert.equal(s.git.base, 'develop'); + assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']); + }); + + it('a same-name default on a second remote still resolves (#302)', async () => { + const { execFileSync } = await import('node:child_process'); + const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); + git('init', '-q', '-b', 'feature/h'); + git('config', 'user.email', 't@example.com'); + git('config', 'user.name', 'Test'); + write('src/App.tsx', 'export default 1;\n'); + git('add', '.'); + git('commit', '-qm', 'init'); + git('remote', 'add', 'origin', '.'); + git('remote', 'add', 'upstream', '.'); + // origin advertises main but its tracking ref is gone (pruned); the + // real main lives only as upstream/main. Name-level dedup must not + // discard the upstream rev. + git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main'); + git('update-ref', 'refs/remotes/upstream/main', 'HEAD'); + git('symbolic-ref', 'refs/remotes/upstream/HEAD', 'refs/remotes/upstream/main'); + write('src/Hero.tsx', 'export const Hero = () => null;\n'); + git('add', '.'); + git('commit', '-qm', 'feature work'); + const s = await gatherSignals(scratch); + assert.equal(s.git.base, 'main'); + assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']); + }); + it('never diffs one integration branch against another (#302)', async () => { const { execFileSync } = await import('node:child_process'); const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });