fix(git): resolve the base branch from the repository instead of its name

Follow-up to #2629, which stopped the walkthrough from comparing against a
branch that does not exist. The same guessing, and the same near-misses in how
the answer was applied, were left elsewhere:

- The default branch travelled as `rootBranchHint`, whose documented meaning is
  "the branch the project root worktree is on". It gets its own option, because
  a parameter that means two things is one the next caller gets wrong.
- A candidate equal to the branch being compared is skipped. In a plain checkout
  the root hint *is* the current branch, so it won every time and produced a
  comparison with itself; the repository default now wins there.
- The Changes and pull-request surfaces read the default branch too. A pull
  request opened against a branch that does not exist is a worse failure than a
  walkthrough that will not generate.
- `hasResolvableBaseBranch` matched `origin/feature/main` for a base of `main`,
  passing the check and then failing the comparison it exists to prevent.
- `getRangeDiff` promoted only `origin/<base>`. A base carried by any other
  remote stayed a bare name, which git resolves against refs/heads and nowhere
  else, so it failed exactly as before.
- `getBranches` dropped every branch of a remote that did not answer, turning
  "we could not ask" into "these branches are gone" — offline, that silently
  removed comparisons that work fine against local remote-tracking refs.
- A remote with no `remote/HEAD` is asked once with `ls-remote --symref` rather
  than falling back to the guess this data exists to replace.

The `defaultBranches` contract was documented under the status response; it
belongs to the branches response, which now has a section of its own.
This commit is contained in:
Bohdan Triapitsyn
2026-08-04 22:41:09 +03:00
parent 9aa8a3e375
commit ce0e1cea27
9 changed files with 292 additions and 37 deletions
+64 -3
View File
@@ -2446,6 +2446,27 @@ export async function getRangeDiff(directory, { base, head, path: filePath, cont
// ignore
}
// Not every repository has an `origin`. When the base names a branch that
// exists only on another remote, a bare name does not resolve — git looks in
// refs/heads, not across remotes — and the diff fails with "ambiguous
// argument". Fall back to whichever remote actually carries it.
if (resolvedBase === baseRef && !/[*?[\]^~:\\]/.test(baseRef)) {
const resolvesLocally = await git
.raw(['rev-parse', '--verify', `refs/heads/${baseRef}`])
.then((value) => Boolean(String(value || '').trim()))
.catch(() => false);
if (!resolvesLocally) {
const remoteMatch = await git
.raw(['for-each-ref', '--count=1', '--format=%(refname:short)', `refs/remotes/*/${baseRef}`])
.then((value) => String(value || '').trim())
.catch(() => '');
if (remoteMatch) {
resolvedBase = remoteMatch;
}
}
}
const args = ['diff', '--no-color'];
if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
args.push(`-U${Math.max(0, contextLines)}`);
@@ -3387,13 +3408,15 @@ export async function getBranches(directory) {
}
async function getRemoteDefaultBranches(git) {
let defaults = {};
try {
const refs = await git.raw([
'for-each-ref',
'--format=%(refname) %(symref)',
'refs/remotes',
]);
return Object.fromEntries(
defaults = Object.fromEntries(
refs.trim().split('\n').flatMap((line) => {
const [ref, symbolicRef] = line.split(' ');
const match = ref.match(/^refs\/remotes\/([^/]+)\/HEAD$/);
@@ -3404,8 +3427,38 @@ async function getRemoteDefaultBranches(git) {
})
);
} catch {
return {};
defaults = {};
}
// `remote/HEAD` is written by clone and by `git remote set-head`; a remote
// added by hand may never have one. Without this the caller falls back to
// guessing main/master/develop, which is exactly the guess this data exists
// to replace — so ask the remote itself, but only for the remotes that are
// actually missing an answer.
try {
const remotes = await git.getRemotes();
const missing = remotes.filter((remote) => remote?.name && !defaults[remote.name]);
if (missing.length === 0) return defaults;
const resolved = await Promise.all(missing.map(async (remote) => {
try {
const output = await git.raw(['ls-remote', '--symref', remote.name, 'HEAD']);
const match = String(output || '').match(/^ref:\s+refs\/heads\/(.+?)\s+HEAD$/m);
return match ? [remote.name, match[1]] : null;
} catch {
// Unreachable or refusing: no answer is better than a guessed one.
return null;
}
}));
for (const entry of resolved) {
if (entry) defaults[entry[0]] = entry[1];
}
} catch {
// Remote list unavailable; the local symrefs are still valid.
}
return defaults;
}
async function filterActiveRemoteBranches(git, remoteBranches) {
@@ -3413,6 +3466,13 @@ async function filterActiveRemoteBranches(git, remoteBranches) {
const remotes = await git.getRemotes();
const branchesByRemote = new Map();
// A remote that did not answer says nothing about its branches. Dropping
// them would turn "we could not ask" into "these branches are gone", and
// callers use this list to decide whether a base branch exists at all — so
// offline would silently remove comparisons that work perfectly well
// against the local remote-tracking refs.
const unreachableRemotes = new Set();
await Promise.all(remotes.map(async (remote) => {
try {
const lsRemoteResult = await git.raw(['ls-remote', '--heads', remote.name]);
@@ -3426,7 +3486,7 @@ async function filterActiveRemoteBranches(git, remoteBranches) {
}
branchesByRemote.set(remote.name, actualRemoteBranches);
} catch {
// Skip remotes that fail (e.g., unreachable)
unreachableRemotes.add(remote.name);
}
}));
@@ -3435,6 +3495,7 @@ async function filterActiveRemoteBranches(git, remoteBranches) {
if (!match) return false;
const remoteName = remoteBranch.split('/')[1];
const branchName = match[1];
if (unreachableRemotes.has(remoteName)) return true;
return branchesByRemote.get(remoteName)?.has(branchName) ?? false;
});
} catch (error) {