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 <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-07-26 18:14:34 -07:00
co-authored by Claude Code
parent afb5d9a479
commit 46f29ca8b3
2 changed files with 55 additions and 5 deletions
+15 -5
View File
@@ -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);