From a50702f2b6eec85badaa09e9430d925135c038ab Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Sat, 25 Jul 2026 19:30:55 -0700 Subject: [PATCH 01/11] Detect the diff base instead of assuming main/master context-signals hardcoded ['main', 'master'] as diff-base candidates, so repos integrating through develop (or any other branch) diffed against the wrong base: git.changedFiles carried the entire divergence and downstream commands scanned the wrong set (issue #302). The base is now detected, most specific signal first: the branch's configured upstream (@{u}; a branch pushed with -u tracks itself and is skipped by the self-check), then the remote's default-branch symref (origin/HEAD), then the conventional integration names including develop. The conventional fallbacks are withheld when the current branch is itself one of them, so sitting on main in a repo that also has develop keeps the working-tree scope instead of diffing two integration branches against each other. Five tests (three failing-first): develop-based feature branch, origin/HEAD detection with a non-standard default name, upstream tracking, on-the-integration-branch fallback, and the integration-vs-integration guard. Prepared with AI assistance (Claude Code), directed by @pbakaus. Co-Authored-By: Claude Code --- skill/scripts/context-signals.mjs | 21 ++++++- tests/context-signals.test.mjs | 99 +++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 1 deletion(-) diff --git a/skill/scripts/context-signals.mjs b/skill/scripts/context-signals.mjs index ab36da497..d480e77e5 100644 --- a/skill/scripts/context-signals.mjs +++ b/skill/scripts/context-signals.mjs @@ -86,8 +86,27 @@ function gitSignals(cwd) { return { isRepo: false, branch: null, base: null, changedFiles: [], changedCount: 0 }; } const branch = run(['rev-parse', '--abbrev-ref', 'HEAD']); + // The merge target is detected, not assumed. A hardcoded main/master list + // diffed develop-based repos against the wrong base, so git.changedFiles + // carried the whole develop/main divergence into scan.targets (issue + // #302). Signals, most specific first: the branch's configured upstream + // (@{u}; a branch pushed with -u tracks itself and is skipped by the + // self-check), then the remote's default-branch symref (origin/HEAD), + // then the conventional integration names. The conventional fallbacks + // are withheld when the current branch IS one of them: sitting on main + // in a repo that also has develop must not diff the two integration + // branches against each other. + const stripOrigin = (ref) => (ref && ref.startsWith('origin/') ? ref.slice('origin/'.length) : null); + const upstreamBase = stripOrigin(run(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])); + const remoteHeadBase = stripOrigin(run(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])); + const conventional = ['develop', 'main', 'master']; + const candidates = new Set([ + ...[upstreamBase, remoteHeadBase].filter(Boolean), + ...(conventional.includes(branch) ? [] : conventional), + ]); let base = null; - for (const b of ['main', 'master']) { + for (const b of candidates) { + if (b === branch) continue; if (run(['rev-parse', '--verify', '--quiet', b]) !== null) { base = b; break; diff --git a/tests/context-signals.test.mjs b/tests/context-signals.test.mjs index a5b6dc7c6..62ef39491 100644 --- a/tests/context-signals.test.mjs +++ b/tests/context-signals.test.mjs @@ -185,6 +185,105 @@ describe('gatherSignals', () => { assert.deepEqual(s.scan.targets, ['src']); }); + it('diffs a feature branch against a develop integration branch (#302)', async () => { + const { execFileSync } = await import('node:child_process'); + const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); + git('init', '-q', '-b', 'develop'); + 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('checkout', '-q', '-b', 'feature/x'); + write('src/Hero.tsx', 'export const Hero = () => null;\n'); + git('add', '.'); + git('commit', '-qm', 'feature work'); + const s = await gatherSignals(scratch); + // The hardcoded main/master candidate list found no base here, so the + // committed feature work was invisible to the scan targets. + assert.equal(s.git.base, 'develop'); + assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']); + assert.deepEqual(s.scan.targets, ['src/Hero.tsx']); + }); + + it('prefers the remote default branch (origin/HEAD) as the diff base (#302)', async () => { + const { execFileSync } = await import('node:child_process'); + const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); + git('init', '-q', '-b', 'trunk'); + 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'); + // Fabricate the remote's default-branch symref without a network remote. + git('update-ref', 'refs/remotes/origin/trunk', 'HEAD'); + git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/trunk'); + git('checkout', '-q', '-b', 'feature/y'); + 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, 'trunk'); + assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']); + }); + + it('a branch tracking the integration branch diffs against its upstream (#302)', async () => { + const { execFileSync } = await import('node:child_process'); + const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); + git('init', '-q', '-b', 'release'); + 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'); + // A self-pointing remote gives git the fetch refspec it needs to map + // refs/heads/release -> refs/remotes/origin/release; no network involved. + git('remote', 'add', 'origin', '.'); + git('update-ref', 'refs/remotes/origin/release', 'HEAD'); + git('checkout', '-q', '-b', 'feature/z'); + git('branch', '-q', '--set-upstream-to=origin/release'); + 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, 'release'); + assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']); + }); + + it('sitting on the integration branch itself falls back to the working tree', async () => { + const { execFileSync } = await import('node:child_process'); + const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); + git('init', '-q', '-b', 'develop'); + 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'); + write('src/App.tsx', 'export default 2;\n'); // dirty, uncommitted + const s = await gatherSignals(scratch); + // No self-diff: base must be null and the dirty working tree is the scope. + assert.equal(s.git.base, null); + assert.deepEqual(s.git.changedFiles, ['src/App.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' }); + git('init', '-q', '-b', 'main'); + 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('branch', '-q', 'develop'); // both integration branches exist + write('src/App.tsx', 'export default 2;\n'); // dirty on main + const s = await gatherSignals(scratch); + // Sitting on main must not pick develop as a base; the dirty working + // tree is the scope, exactly as before this change. + assert.equal(s.git.base, null); + assert.deepEqual(s.git.changedFiles, ['src/App.tsx']); + }); + it('has empty scan.targets only when there is no code at all', async () => { const s = await gatherSignals(scratch); assert.deepEqual(s.scan.targets, []); From ea098ceb963e4e93651ebc163a9613a06a84be89 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Sat, 25 Jul 2026 19:40:40 -0700 Subject: [PATCH 02/11] Accept remote refs as diff bases; honor non-origin upstreams Both review bots found real gaps in the first pass: candidates were verified as local branch names only, so an origin/HEAD target with no local checkout fell through, and stripOrigin() dropped upstreams on any remote not named origin (fork workflows tracking upstream/release). Candidates now carry a display name plus the revs to try in order: the upstream's remote rev wins outright (it tracks the actual merge target, so it beats a possibly stale local branch of the same name), origin/HEAD tries the local branch then the remote-tracking ref, and the conventional names each try local then origin/. git.base keeps reporting the friendly branch name while the diff runs against whichever rev resolved. Two new failing-first tests: remote-only default branch, and an upstream on a remote named upstream with no local base branch. Prepared with AI assistance (Claude Code), directed by @pbakaus. Co-Authored-By: Claude Code --- skill/scripts/context-signals.mjs | 43 ++++++++++++++++++++++--------- tests/context-signals.test.mjs | 43 +++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 12 deletions(-) diff --git a/skill/scripts/context-signals.mjs b/skill/scripts/context-signals.mjs index d480e77e5..0140dccbd 100644 --- a/skill/scripts/context-signals.mjs +++ b/skill/scripts/context-signals.mjs @@ -96,24 +96,43 @@ function gitSignals(cwd) { // are withheld when the current branch IS one of them: sitting on main // in a repo that also has develop must not diff the two integration // branches against each other. - const stripOrigin = (ref) => (ref && ref.startsWith('origin/') ? ref.slice('origin/'.length) : null); - const upstreamBase = stripOrigin(run(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])); - const remoteHeadBase = stripOrigin(run(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])); + // Candidates carry a display name (what git.base reports) and the revs to + // try, in order. A remote ref like `upstream/release` (fork workflows) or + // an origin/HEAD target with no local checkout is a perfectly good diff + // base, so revs are not limited to local branch names. + const splitRemoteRef = (ref) => { + const i = ref ? ref.indexOf('/') : -1; + return i > 0 ? { name: ref.slice(i + 1), rev: ref } : null; + }; + const upstream = splitRemoteRef(run(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])); + const remoteHead = splitRemoteRef(run(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])); const conventional = ['develop', 'main', 'master']; - const candidates = new Set([ - ...[upstreamBase, remoteHeadBase].filter(Boolean), - ...(conventional.includes(branch) ? [] : conventional), - ]); + const candidates = []; + const seen = new Set(); + const addCandidate = (name, revs) => { + if (!name || name === branch || seen.has(name)) return; + seen.add(name); + candidates.push({ name, revs }); + }; + // The upstream tracks the actual merge target, so its remote rev wins over + // a possibly stale local branch of the same name. + if (upstream) addCandidate(upstream.name, [upstream.rev]); + if (remoteHead) addCandidate(remoteHead.name, [remoteHead.name, remoteHead.rev]); + if (!conventional.includes(branch)) { + for (const name of conventional) addCandidate(name, [name, `origin/${name}`]); + } let base = null; - for (const b of candidates) { - if (b === branch) continue; - if (run(['rev-parse', '--verify', '--quiet', b]) !== null) { - base = b; + let baseRev = null; + for (const c of candidates) { + const rev = c.revs.find((r) => run(['rev-parse', '--verify', '--quiet', r]) !== null); + if (rev) { + base = c.name; + baseRev = rev; break; } } const diffBase = base && branch && branch !== base ? base : null; - const fromDiff = diffBase ? run(['diff', '--name-only', `${diffBase}...HEAD`]) : null; + const fromDiff = diffBase ? run(['diff', '--name-only', `${baseRev}...HEAD`]) : null; // porcelain lines are `XY PATH`: a 2-char status + a space, then the path. // Don't trim the combined output — an unstaged-modified line starts with a // leading space (` M path`), and a global trim would eat the first line's diff --git a/tests/context-signals.test.mjs b/tests/context-signals.test.mjs index 62ef39491..b75101518 100644 --- a/tests/context-signals.test.mjs +++ b/tests/context-signals.test.mjs @@ -266,6 +266,49 @@ describe('gatherSignals', () => { assert.deepEqual(s.git.changedFiles, ['src/App.tsx']); }); + it('uses the remote-tracking ref when the base has no local branch (#302)', async () => { + const { execFileSync } = await import('node:child_process'); + const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); + git('init', '-q', '-b', 'develop'); + 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('update-ref', 'refs/remotes/origin/develop', 'HEAD'); + git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/develop'); + git('checkout', '-q', '-b', 'feature/w'); + git('branch', '-q', '-D', 'develop'); // remote default exists, local doesn't + 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('honors an upstream on a non-origin remote (fork workflow) (#302)', async () => { + const { execFileSync } = await import('node:child_process'); + const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); + git('init', '-q', '-b', 'release'); + 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', 'upstream', '.'); + git('update-ref', 'refs/remotes/upstream/release', 'HEAD'); + git('checkout', '-q', '-b', 'feature/v'); + git('branch', '-q', '--set-upstream-to=upstream/release'); + git('branch', '-q', '-D', 'release'); // the tracked base lives only on the fork parent + 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, 'release'); + 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' }); From b9d294b29c70f2f650996ee6243511819ddbd18b Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Sat, 25 Jul 2026 19:49:21 -0700 Subject: [PATCH 03/11] Close the integration-branch guard bypass; accept local upstreams Cursor Bugbot round two, both real: an upstream or origin/HEAD naming a DIFFERENT integration branch bypassed the conventional-name guard, so sitting on develop with the remote default at main still produced the integration-vs-integration divergence this detection exists to prevent. And splitRemoteRef returned null for a slashless @{u}, silently dropping local upstreams (branch..remote = "."). Base detection is now skipped entirely on an integration branch: no signal may override the working-tree scope there. A slashless upstream resolves as its own name and rev. Two failing-first tests: origin/HEAD pointing at main while sitting on develop, and a feature branch tracking a local canary branch. Prepared with AI assistance (Claude Code), directed by @pbakaus. Co-Authored-By: Claude Code --- skill/scripts/context-signals.mjs | 53 ++++++++++++++++++------------- tests/context-signals.test.mjs | 42 ++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 22 deletions(-) diff --git a/skill/scripts/context-signals.mjs b/skill/scripts/context-signals.mjs index 0140dccbd..d37610343 100644 --- a/skill/scripts/context-signals.mjs +++ b/skill/scripts/context-signals.mjs @@ -104,31 +104,40 @@ function gitSignals(cwd) { const i = ref ? ref.indexOf('/') : -1; return i > 0 ? { name: ref.slice(i + 1), rev: ref } : null; }; - const upstream = splitRemoteRef(run(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])); - const remoteHead = splitRemoteRef(run(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])); + // A slashless @{u} is a LOCAL upstream (branch..remote = "."); it names + // a merge target just as validly as a remote-tracking ref does. + const asUpstream = (ref) => splitRemoteRef(ref) || (ref ? { name: ref, rev: ref } : null); const conventional = ['develop', 'main', 'master']; - const candidates = []; - const seen = new Set(); - const addCandidate = (name, revs) => { - if (!name || name === branch || seen.has(name)) return; - seen.add(name); - candidates.push({ name, revs }); - }; - // The upstream tracks the actual merge target, so its remote rev wins over - // a possibly stale local branch of the same name. - if (upstream) addCandidate(upstream.name, [upstream.rev]); - if (remoteHead) addCandidate(remoteHead.name, [remoteHead.name, remoteHead.rev]); - if (!conventional.includes(branch)) { - for (const name of conventional) addCandidate(name, [name, `origin/${name}`]); - } + // On an integration branch itself the scope hint is the working tree. No + // signal may override that: an origin/HEAD or upstream naming a DIFFERENT + // integration branch (sitting on develop while the remote default is + // main) would produce exactly the integration-vs-integration divergence + // this detection exists to prevent. + const onIntegrationBranch = conventional.includes(branch); let base = null; let baseRev = null; - for (const c of candidates) { - const rev = c.revs.find((r) => run(['rev-parse', '--verify', '--quiet', r]) !== null); - if (rev) { - base = c.name; - baseRev = rev; - break; + if (!onIntegrationBranch) { + const upstream = asUpstream(run(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])); + const remoteHead = splitRemoteRef(run(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])); + const candidates = []; + const seen = new Set(); + const addCandidate = (name, revs) => { + if (!name || name === branch || seen.has(name)) return; + seen.add(name); + candidates.push({ name, revs }); + }; + // The upstream tracks the actual merge target, so its own rev wins over + // a possibly stale local branch of the same name. + if (upstream) addCandidate(upstream.name, [upstream.rev]); + if (remoteHead) addCandidate(remoteHead.name, [remoteHead.name, remoteHead.rev]); + for (const name of conventional) addCandidate(name, [name, `origin/${name}`]); + for (const c of candidates) { + const rev = c.revs.find((r) => run(['rev-parse', '--verify', '--quiet', r]) !== null); + if (rev) { + base = c.name; + baseRev = rev; + break; + } } } const diffBase = base && branch && branch !== base ? base : null; diff --git a/tests/context-signals.test.mjs b/tests/context-signals.test.mjs index b75101518..3631f8d48 100644 --- a/tests/context-signals.test.mjs +++ b/tests/context-signals.test.mjs @@ -309,6 +309,48 @@ describe('gatherSignals', () => { assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']); }); + it('remote signals cannot bypass the integration-branch guard (#302)', async () => { + const { execFileSync } = await import('node:child_process'); + const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); + git('init', '-q', '-b', 'main'); + 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('branch', '-q', 'develop'); + git('checkout', '-q', 'develop'); + // The remote default is main; sitting on develop must still not produce + // a develop-vs-main integration diff via the origin/HEAD signal. + git('update-ref', 'refs/remotes/origin/main', 'main'); + git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main'); + write('src/App.tsx', 'export default 2;\n'); // dirty on develop + const s = await gatherSignals(scratch); + assert.equal(s.git.base, null); + assert.deepEqual(s.git.changedFiles, ['src/App.tsx']); + }); + + it('honors a local (slashless) upstream branch (#302)', async () => { + const { execFileSync } = await import('node:child_process'); + const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); + git('init', '-q', '-b', 'canary'); + 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('checkout', '-q', '-b', 'feature/u'); + git('branch', '-q', '--set-upstream-to=canary'); // local upstream, no remote + write('src/Hero.tsx', 'export const Hero = () => null;\n'); + git('add', '.'); + git('commit', '-qm', 'feature work'); + const s = await gatherSignals(scratch); + // canary is neither conventional nor remote, but the configured + // upstream names it as the merge target. + assert.equal(s.git.base, 'canary'); + 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' }); From e82653965c4f738990daa11b6784b9c52f2e75d7 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Sat, 25 Jul 2026 20:00:10 -0700 Subject: [PATCH 04/11] An existing develop outranks a main-pointing origin/HEAD Cursor Bugbot's remaining round-1 finding held for the current code too: in a git-flow repo whose platform default was never flipped off main, a feature branch without an upstream picked origin/HEAD's main over the develop branch features actually merge to, dragging the develop-vs-main divergence into scan targets. develop now sits between the upstream signal and origin/HEAD in the candidate order; repos without a develop branch are unaffected. Failing-first test covers the exact shape (develop exists, origin/HEAD -> main). Prepared with AI assistance (Claude Code), directed by @pbakaus. Co-Authored-By: Claude Code --- skill/scripts/context-signals.mjs | 7 ++++++- tests/context-signals.test.mjs | 25 +++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/skill/scripts/context-signals.mjs b/skill/scripts/context-signals.mjs index d37610343..61763e1bb 100644 --- a/skill/scripts/context-signals.mjs +++ b/skill/scripts/context-signals.mjs @@ -129,8 +129,13 @@ function gitSignals(cwd) { // The upstream tracks the actual merge target, so its own rev wins over // a possibly stale local branch of the same name. if (upstream) addCandidate(upstream.name, [upstream.rev]); + // A develop branch marks a git-flow repo where features merge to develop + // 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']); if (remoteHead) addCandidate(remoteHead.name, [remoteHead.name, remoteHead.rev]); - for (const name of conventional) addCandidate(name, [name, `origin/${name}`]); + for (const name of ['main', 'master']) addCandidate(name, [name, `origin/${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 3631f8d48..432737282 100644 --- a/tests/context-signals.test.mjs +++ b/tests/context-signals.test.mjs @@ -309,6 +309,31 @@ describe('gatherSignals', () => { assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']); }); + it('an existing develop outranks a main-pointing origin/HEAD (#302)', async () => { + const { execFileSync } = await import('node:child_process'); + const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); + git('init', '-q', '-b', 'main'); + 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('branch', '-q', 'develop'); + // Classic git-flow with the platform default never flipped off main. + git('update-ref', 'refs/remotes/origin/main', 'main'); + git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main'); + git('checkout', '-q', 'develop'); + git('checkout', '-q', '-b', 'feature/g'); + write('src/Hero.tsx', 'export const Hero = () => null;\n'); + git('add', '.'); + git('commit', '-qm', 'feature work'); + const s = await gatherSignals(scratch); + // Features merge to develop here; picking origin/HEAD's main would drag + // the develop-vs-main divergence into scan targets. + assert.equal(s.git.base, 'develop'); + assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']); + }); + it('remote signals cannot bypass the integration-branch guard (#302)', async () => { const { execFileSync } = await import('node:child_process'); const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); From afb5d9a479e9c766c378cc520d3f4b99bc9acc16 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Sat, 25 Jul 2026 20:07:58 -0700 Subject: [PATCH 05/11] Guard non-standard default branches like conventional ones Cursor Bugbot: sitting on a non-standard default such as trunk (the origin/HEAD target) still ran candidate selection, where develop or main could win and produce an integration-vs-integration diff. The guard now treats the remote default branch as an integration branch alongside the conventional names. Failing-first test: on trunk with a develop branch present, the scope stays the working tree. Prepared with AI assistance (Claude Code), directed by @pbakaus. Co-Authored-By: Claude Code --- skill/scripts/context-signals.mjs | 8 +++++--- tests/context-signals.test.mjs | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/skill/scripts/context-signals.mjs b/skill/scripts/context-signals.mjs index 61763e1bb..15e78213d 100644 --- a/skill/scripts/context-signals.mjs +++ b/skill/scripts/context-signals.mjs @@ -112,13 +112,15 @@ function gitSignals(cwd) { // signal may override that: an origin/HEAD or upstream naming a DIFFERENT // integration branch (sitting on develop while the remote default is // main) would produce exactly the integration-vs-integration divergence - // this detection exists to prevent. - const onIntegrationBranch = conventional.includes(branch); + // this detection exists to prevent. "Integration branch" means a + // conventional name OR the remote's default branch, so a non-standard + // default like trunk is guarded the same way. + const remoteHead = splitRemoteRef(run(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])); + const onIntegrationBranch = conventional.includes(branch) || branch === remoteHead?.name; let base = null; let baseRev = null; if (!onIntegrationBranch) { const upstream = asUpstream(run(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])); - const remoteHead = splitRemoteRef(run(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])); const candidates = []; const seen = new Set(); const addCandidate = (name, revs) => { diff --git a/tests/context-signals.test.mjs b/tests/context-signals.test.mjs index 432737282..e2172d5da 100644 --- a/tests/context-signals.test.mjs +++ b/tests/context-signals.test.mjs @@ -376,6 +376,26 @@ describe('gatherSignals', () => { assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']); }); + it('sitting on a non-standard default branch keeps the working-tree scope (#302)', async () => { + const { execFileSync } = await import('node:child_process'); + const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); + git('init', '-q', '-b', 'trunk'); + 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('branch', '-q', 'develop'); // a conventional name also exists + git('update-ref', 'refs/remotes/origin/trunk', 'HEAD'); + git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/trunk'); + write('src/App.tsx', 'export default 2;\n'); // dirty on trunk + const s = await gatherSignals(scratch); + // trunk IS the integration branch (origin/HEAD says so); develop must + // not win the candidate scan and produce a trunk-vs-develop diff. + assert.equal(s.git.base, null); + assert.deepEqual(s.git.changedFiles, ['src/App.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' }); From 46f29ca8b314e42813c5fd9702c94f27d0e80918 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Sun, 26 Jul 2026 18:14:34 -0700 Subject: [PATCH 06/11] Guard detached HEADs and non-origin remote defaults Two more real gaps from the post-rebase review round: a detached checkout reads its branch as the literal HEAD, so the integration guard never fired and candidate selection could diff a detached tip on main against develop; and the remote-default check only consulted origin, so a fork-parent layout whose only remote is upstream lost the guard on its default branch entirely. The guard now treats a detached HEAD as no-diff-base, and default-branch symrefs are collected from every remote (origin first), feeding both the guard and the candidate list. Two failing-first tests cover a detached tip beside a diverged develop and an upstream-only trunk default. Prepared with AI assistance (Claude Code), directed by @pbakaus. Co-Authored-By: Claude Code --- skill/scripts/context-signals.mjs | 20 ++++++++++++---- tests/context-signals.test.mjs | 40 +++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/skill/scripts/context-signals.mjs b/skill/scripts/context-signals.mjs index 15e78213d..743130a3c 100644 --- a/skill/scripts/context-signals.mjs +++ b/skill/scripts/context-signals.mjs @@ -113,10 +113,20 @@ function gitSignals(cwd) { // integration branch (sitting on develop while the remote default is // main) would produce exactly the integration-vs-integration divergence // this detection exists to prevent. "Integration branch" means a - // conventional name OR the remote's default branch, so a non-standard - // default like trunk is guarded the same way. - const remoteHead = splitRemoteRef(run(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])); - const onIntegrationBranch = conventional.includes(branch) || branch === remoteHead?.name; + // conventional name OR any remote's default branch (origin first, but a + // fork-parent layout may only have an `upstream` remote), so a + // non-standard default like trunk is guarded the same way. A detached + // checkout (branch reads as the literal `HEAD`) has no branch identity to + // diff for and keeps the working-tree scope too. + const remoteHeads = []; + const remotes = (run(['remote']) || '').split('\n').filter(Boolean); + for (const r of ['origin', ...remotes.filter((name) => name !== 'origin')]) { + const head = splitRemoteRef(run(['symbolic-ref', '--short', `refs/remotes/${r}/HEAD`])); + if (head) remoteHeads.push(head); + } + const onIntegrationBranch = branch === 'HEAD' + || conventional.includes(branch) + || remoteHeads.some((head) => head.name === branch); let base = null; let baseRev = null; if (!onIntegrationBranch) { @@ -136,7 +146,7 @@ function gitSignals(cwd) { // 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']); - if (remoteHead) addCandidate(remoteHead.name, [remoteHead.name, remoteHead.rev]); + for (const head of remoteHeads) addCandidate(head.name, [head.name, head.rev]); for (const name of ['main', 'master']) addCandidate(name, [name, `origin/${name}`]); for (const c of candidates) { const rev = c.revs.find((r) => run(['rev-parse', '--verify', '--quiet', r]) !== null); diff --git a/tests/context-signals.test.mjs b/tests/context-signals.test.mjs index e2172d5da..a0ce8e959 100644 --- a/tests/context-signals.test.mjs +++ b/tests/context-signals.test.mjs @@ -396,6 +396,46 @@ describe('gatherSignals', () => { assert.deepEqual(s.git.changedFiles, ['src/App.tsx']); }); + it('a detached HEAD keeps the working-tree scope (#302)', async () => { + const { execFileSync } = await import('node:child_process'); + const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); + git('init', '-q', '-b', 'main'); + 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('branch', '-q', 'develop'); + git('checkout', '-q', '--detach'); + write('src/App.tsx', 'export default 2;\n'); // dirty on a detached tip + const s = await gatherSignals(scratch); + // A detached checkout has no branch identity to diff for; picking + // develop here would refill changedFiles with integration divergence. + assert.equal(s.git.base, null); + assert.deepEqual(s.git.changedFiles, ['src/App.tsx']); + }); + + it('the integration guard sees non-origin remote defaults (#302)', async () => { + const { execFileSync } = await import('node:child_process'); + const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); + git('init', '-q', '-b', 'trunk'); + 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('branch', '-q', 'develop'); + // The only remote is upstream (fork-parent layout, no origin at all); + // its default branch is trunk, which is exactly where we're sitting. + git('remote', 'add', 'upstream', '.'); + git('update-ref', 'refs/remotes/upstream/trunk', 'HEAD'); + git('symbolic-ref', 'refs/remotes/upstream/HEAD', 'refs/remotes/upstream/trunk'); + write('src/App.tsx', 'export default 2;\n'); // dirty on trunk + const s = await gatherSignals(scratch); + assert.equal(s.git.base, null); + assert.deepEqual(s.git.changedFiles, ['src/App.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' }); From 386d3e70517d1aea18f1f7639c5ed5444d8ac05f Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Sun, 26 Jul 2026 18:23:56 -0700 Subject: [PATCH 07/11] 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' }); From f89b6c10b1d7ffe87cfdef7987df53e4cda3d010 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Sun, 26 Jul 2026 18:39:20 -0700 Subject: [PATCH 08/11] Only strip a remote prefix that names a configured remote Round-five bot findings, one real root cause: splitRemoteRef treated the first slash in any ref as a remote separator. A local upstream named release/2.0 was truncated to "2.0", and feature/foo tracking from branch foo collapsed to the current branch's own name and was self-skipped, discarding a valid base both times. The split now happens only when the prefix names a configured remote; otherwise the whole ref is one local branch name. The per-remote HEAD symref loop strips its own queried prefix directly (that remote may be fabricated in tests or partial clones without appearing in git remote). The reported pruned-upstream shape already resolves via the multi-remote rev lists from the previous round; its test now guards that. Prepared with AI assistance (Claude Code), directed by @pbakaus. Co-Authored-By: Claude Code --- skill/scripts/context-signals.mjs | 23 +++++++---- tests/context-signals.test.mjs | 65 +++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 7 deletions(-) diff --git a/skill/scripts/context-signals.mjs b/skill/scripts/context-signals.mjs index fd3613e48..5afc0246f 100644 --- a/skill/scripts/context-signals.mjs +++ b/skill/scripts/context-signals.mjs @@ -100,12 +100,19 @@ function gitSignals(cwd) { // try, in order. A remote ref like `upstream/release` (fork workflows) or // an origin/HEAD target with no local checkout is a perfectly good diff // base, so revs are not limited to local branch names. + const remotes = (run(['remote']) || '').split('\n').filter(Boolean); + // Strip a leading "/" only when that remote is actually + // configured: a slash does not make a ref remote. A local upstream named + // release/2.0 is one branch name, and truncating it to "2.0" (or + // feature/foo to "foo", which then matches the current branch and gets + // self-skipped) loses a valid base. const splitRemoteRef = (ref) => { const i = ref ? ref.indexOf('/') : -1; - return i > 0 ? { name: ref.slice(i + 1), rev: ref } : null; + if (i < 1) return null; + return remotes.includes(ref.slice(0, i)) ? { name: ref.slice(i + 1), rev: ref } : null; }; - // A slashless @{u} is a LOCAL upstream (branch..remote = "."); it names - // a merge target just as validly as a remote-tracking ref does. + // An @{u} that carries no configured remote prefix is a LOCAL upstream + // (branch..remote = "."); it names a merge target just as validly. const asUpstream = (ref) => splitRemoteRef(ref) || (ref ? { name: ref, rev: ref } : null); const conventional = ['develop', 'main', 'master']; // On an integration branch itself the scope hint is the working tree. No @@ -119,10 +126,12 @@ function gitSignals(cwd) { // checkout (branch reads as the literal `HEAD`) has no branch identity to // diff for and keeps the working-tree scope too. const remoteHeads = []; - const remotes = (run(['remote']) || '').split('\n').filter(Boolean); - for (const r of ['origin', ...remotes.filter((name) => name !== 'origin')]) { - const head = splitRemoteRef(run(['symbolic-ref', '--short', `refs/remotes/${r}/HEAD`])); - if (head) remoteHeads.push(head); + for (const r of [...new Set(['origin', ...remotes])]) { + // The symref's own prefix is the remote just queried, so it is stripped + // directly; the remote need not be in `git remote` output (tests and + // partial clones fabricate refs/remotes/origin/* without a remote). + const ref = run(['symbolic-ref', '--short', `refs/remotes/${r}/HEAD`]); + if (ref && ref.startsWith(`${r}/`)) remoteHeads.push({ name: ref.slice(r.length + 1), rev: ref }); } const onIntegrationBranch = branch === 'HEAD' || conventional.includes(branch) diff --git a/tests/context-signals.test.mjs b/tests/context-signals.test.mjs index 43daf8975..1f4599451 100644 --- a/tests/context-signals.test.mjs +++ b/tests/context-signals.test.mjs @@ -482,6 +482,71 @@ describe('gatherSignals', () => { assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']); }); + it('a local upstream with a slash in its name is not misparsed (#302)', async () => { + const { execFileSync } = await import('node:child_process'); + const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); + git('init', '-q', '-b', 'release/2.0'); + 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('checkout', '-q', '-b', 'hotfix/x'); + git('branch', '-q', '--set-upstream-to=release/2.0'); + write('src/Hero.tsx', 'export const Hero = () => null;\n'); + git('add', '.'); + git('commit', '-qm', 'hotfix work'); + const s = await gatherSignals(scratch); + // "release" is not a remote here; the whole ref is the local base name. + assert.equal(s.git.base, 'release/2.0'); + assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']); + }); + + it('a local upstream sharing the branch leaf name is not self-skipped (#302)', async () => { + const { execFileSync } = await import('node:child_process'); + const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); + git('init', '-q', '-b', 'feature/foo'); + 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('checkout', '-q', '-b', 'foo'); + git('branch', '-q', '--set-upstream-to=feature/foo'); + write('src/Hero.tsx', 'export const Hero = () => null;\n'); + git('add', '.'); + git('commit', '-qm', 'work'); + const s = await gatherSignals(scratch); + // Truncating feature/foo to "foo" made it look like the current branch + // and the valid upstream was discarded. + assert.equal(s.git.base, 'feature/foo'); + assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']); + }); + + it('a pruned upstream tracking ref falls back to other remotes (#302)', async () => { + const { execFileSync } = await import('node:child_process'); + const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); + git('init', '-q', '-b', 'feature/p'); + 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', '.'); + // The branch tracks origin/main, but that tracking ref was pruned; the + // live main exists only on the upstream remote. + git('config', 'branch.feature/p.remote', 'origin'); + git('config', 'branch.feature/p.merge', 'refs/heads/main'); + git('update-ref', 'refs/remotes/upstream/main', '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, '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' }); From a470fc777aa13939cc233ec1149b90845a584ca5 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Sun, 26 Jul 2026 18:48:14 -0700 Subject: [PATCH 09/11] Read the upstream as a full symbolic ref instead of guessing at prefixes Round six, and the upstream-parsing ambiguity dies at the root: @{u} is now resolved via rev-parse --symbolic-full-name, where refs/heads/... IS a local upstream and refs/remotes//... IS remote-tracking. The previous remote-membership heuristic still misread a local feature/foo upstream when a remote literally named "feature" existed. The adversarial test now configures exactly that remote and passes. Prepared with AI assistance (Claude Code), directed by @pbakaus. Co-Authored-By: Claude Code --- skill/scripts/context-signals.mjs | 33 +++++++++++++++++++------------ tests/context-signals.test.mjs | 4 ++++ 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/skill/scripts/context-signals.mjs b/skill/scripts/context-signals.mjs index 5afc0246f..dc3951d3b 100644 --- a/skill/scripts/context-signals.mjs +++ b/skill/scripts/context-signals.mjs @@ -101,19 +101,26 @@ function gitSignals(cwd) { // an origin/HEAD target with no local checkout is a perfectly good diff // base, so revs are not limited to local branch names. const remotes = (run(['remote']) || '').split('\n').filter(Boolean); - // Strip a leading "/" only when that remote is actually - // configured: a slash does not make a ref remote. A local upstream named - // release/2.0 is one branch name, and truncating it to "2.0" (or - // feature/foo to "foo", which then matches the current branch and gets - // self-skipped) loses a valid base. - const splitRemoteRef = (ref) => { - const i = ref ? ref.indexOf('/') : -1; - if (i < 1) return null; - return remotes.includes(ref.slice(0, i)) ? { name: ref.slice(i + 1), rev: ref } : null; + // Read @{u} as a FULL symbolic ref: refs/heads/... is a local upstream + // (branch..remote = "."), refs/remotes//... is remote-tracking. No + // string guessing on the abbreviated form survives contact with reality: + // a local upstream named release/2.0 is one branch name, and a local + // feature/foo beside a remote actually named "feature" is only told apart + // from feature's remote-tracking refs by the full ref namespace. + const resolveUpstream = () => { + const full = run(['rev-parse', '--symbolic-full-name', '@{u}']); + if (!full) return null; + if (full.startsWith('refs/heads/')) { + const name = full.slice('refs/heads/'.length); + return { name, rev: name }; + } + if (full.startsWith('refs/remotes/')) { + const rest = full.slice('refs/remotes/'.length); + const i = rest.indexOf('/'); + if (i > 0) return { name: rest.slice(i + 1), rev: rest }; + } + return null; }; - // An @{u} that carries no configured remote prefix is a LOCAL upstream - // (branch..remote = "."); it names a merge target just as validly. - const asUpstream = (ref) => splitRemoteRef(ref) || (ref ? { name: ref, rev: ref } : null); const conventional = ['develop', 'main', 'master']; // On an integration branch itself the scope hint is the working tree. No // signal may override that: an origin/HEAD or upstream naming a DIFFERENT @@ -139,7 +146,7 @@ function gitSignals(cwd) { let base = null; let baseRev = null; if (!onIntegrationBranch) { - const upstream = asUpstream(run(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])); + const upstream = resolveUpstream(); // 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 diff --git a/tests/context-signals.test.mjs b/tests/context-signals.test.mjs index 1f4599451..a5f3b8d06 100644 --- a/tests/context-signals.test.mjs +++ b/tests/context-signals.test.mjs @@ -513,6 +513,10 @@ describe('gatherSignals', () => { git('commit', '-qm', 'init'); git('checkout', '-q', '-b', 'foo'); git('branch', '-q', '--set-upstream-to=feature/foo'); + // Adversarial twist: a remote literally named "feature" exists, so any + // prefix-based guess would still misread the LOCAL feature/foo upstream + // as remote-tracking. Only the full symbolic ref disambiguates. + git('remote', 'add', 'feature', '.'); write('src/Hero.tsx', 'export const Hero = () => null;\n'); git('add', '.'); git('commit', '-qm', 'work'); From e2c1c43ee74671dd7c84df1fc600777306c67109 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Sun, 26 Jul 2026 18:56:33 -0700 Subject: [PATCH 10/11] Remote defaults lead with their own rev, like upstreams already do Round seven: a remote-advertised default candidate tried the local branch first, so a stale local main outranked the fresher origin/main the symref points at and refilled changedFiles with the divergence. The candidate now leads with the advertised remote rev, mirroring the upstream candidate's reasoning. Failing-first test: local main forced two commits behind the remote default, feature delta stays clean. Prepared with AI assistance (Claude Code), directed by @pbakaus. Co-Authored-By: Claude Code --- skill/scripts/context-signals.mjs | 5 ++++- tests/context-signals.test.mjs | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/skill/scripts/context-signals.mjs b/skill/scripts/context-signals.mjs index dc3951d3b..5c7d3f3bc 100644 --- a/skill/scripts/context-signals.mjs +++ b/skill/scripts/context-signals.mjs @@ -169,7 +169,10 @@ function gitSignals(cwd) { // main; an existing develop therefore outranks the remote default. This // is #302's own repro shape, and repos without develop are unaffected. addCandidate('develop', revsFor('develop')); - for (const head of remoteHeads) addCandidate(head.name, revsFor(head.name)); + // A remote's advertised default prefers its own remote-tracking rev over + // a possibly stale local checkout of the same name, for the same reason + // the upstream candidate leads with its rev. + for (const head of remoteHeads) addCandidate(head.name, [...new Set([head.rev, ...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); diff --git a/tests/context-signals.test.mjs b/tests/context-signals.test.mjs index a5f3b8d06..3e024b247 100644 --- a/tests/context-signals.test.mjs +++ b/tests/context-signals.test.mjs @@ -551,6 +551,32 @@ describe('gatherSignals', () => { assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']); }); + it('a remote-advertised default outranks a stale local checkout (#302)', async () => { + const { execFileSync } = await import('node:child_process'); + const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); + git('init', '-q', '-b', 'main'); + git('config', 'user.email', 't@example.com'); + git('config', 'user.name', 'Test'); + write('src/base.css', 'a{}\n'); + git('add', '.'); + git('commit', '-qm', 'A'); + write('src/extra.css', 'b{}\n'); + git('add', '.'); + git('commit', '-qm', 'A2'); + git('update-ref', 'refs/remotes/origin/main', 'HEAD'); + git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main'); + git('checkout', '-q', '-b', 'feature/s'); + write('src/Hero.tsx', 'export const Hero = () => null;\n'); + git('add', '.'); + git('commit', '-qm', 'feature work'); + // The local main checkout is stale (still at A); the remote default is + // at A2. Diffing against the stale local would drag src/extra.css in. + git('branch', '-f', 'main', 'HEAD~2'); + 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' }); From 01d5d357c5dd11aeceee261487f65b98237932e5 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Sun, 26 Jul 2026 19:04:48 -0700 Subject: [PATCH 11/11] The develop candidate leads with an advertised develop default rev Round eight closes the stale-local class completely: the develop candidate sits before the remote-default entries, so when origin/HEAD itself points at develop, its name claim let a stale local develop win over the fresher origin/develop. The candidate now leads with any remote-advertised develop rev, exactly as the remote-default and upstream candidates already lead with theirs. main/master were already covered since their remote-default entries come first in the order. Failing-first test forces local develop two commits behind. Prepared with AI assistance (Claude Code), directed by @pbakaus. Co-Authored-By: Claude Code --- skill/scripts/context-signals.mjs | 8 ++++++-- tests/context-signals.test.mjs | 24 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/skill/scripts/context-signals.mjs b/skill/scripts/context-signals.mjs index 5c7d3f3bc..743bb220a 100644 --- a/skill/scripts/context-signals.mjs +++ b/skill/scripts/context-signals.mjs @@ -168,10 +168,14 @@ 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', revsFor('develop')); // A remote's advertised default prefers its own remote-tracking rev over // a possibly stale local checkout of the same name, for the same reason - // the upstream candidate leads with its rev. + // the upstream candidate leads with its rev. That applies to the develop + // candidate too when the remote default IS develop: it sits before the + // remote-default entries in the order, so it must lead with their rev + // itself or a stale local develop would win. + const advertisedRevs = (name) => remoteHeads.filter((head) => head.name === name).map((head) => head.rev); + addCandidate('develop', [...new Set([...advertisedRevs('develop'), ...revsFor('develop')])]); for (const head of remoteHeads) addCandidate(head.name, [...new Set([head.rev, ...revsFor(head.name)])]); for (const name of ['main', 'master']) addCandidate(name, revsFor(name)); for (const c of candidates) { diff --git a/tests/context-signals.test.mjs b/tests/context-signals.test.mjs index 3e024b247..505258c76 100644 --- a/tests/context-signals.test.mjs +++ b/tests/context-signals.test.mjs @@ -577,6 +577,30 @@ describe('gatherSignals', () => { assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']); }); + it('a develop-pointing remote default outranks a stale local develop (#302)', async () => { + const { execFileSync } = await import('node:child_process'); + const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); + git('init', '-q', '-b', 'develop'); + git('config', 'user.email', 't@example.com'); + git('config', 'user.name', 'Test'); + write('src/base.css', 'a{}\n'); + git('add', '.'); + git('commit', '-qm', 'A'); + write('src/extra.css', 'b{}\n'); + git('add', '.'); + git('commit', '-qm', 'A2'); + git('update-ref', 'refs/remotes/origin/develop', 'HEAD'); + git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/develop'); + git('checkout', '-q', '-b', 'feature/t'); + write('src/Hero.tsx', 'export const Hero = () => null;\n'); + git('add', '.'); + git('commit', '-qm', 'feature work'); + git('branch', '-f', 'develop', 'HEAD~2'); // local develop is stale at A + const s = await gatherSignals(scratch); + assert.equal(s.git.base, 'develop'); + 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' });