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
+21 -2
View File
@@ -1417,12 +1417,32 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
}));
}, [remotes, remoteBranches, remoteUrl, status?.tracking]);
const currentBranch = status?.current ?? null;
// The repository's own default branch, so a repo whose default is neither
// main, master nor develop stops being compared against a branch that does
// not exist.
const defaultBranch = React.useMemo(() => {
const trackingRemote = status?.tracking?.trim().split('/')[0];
return (trackingRemote && branches?.defaultBranches?.[trackingRemote])
?? branches?.defaultBranches?.origin;
}, [branches, status?.tracking]);
const baseBranch = React.useMemo(() => deriveBaseBranch({
remoteNames: new Set(effectiveRemotes.map((remote) => remote.name)),
localBranches,
worktreeCreatedFromBranch: worktreeMetadata?.createdFromBranch,
rootBranchHint,
}), [effectiveRemotes, localBranches, rootBranchHint, worktreeMetadata?.createdFromBranch]);
defaultBranch,
headBranch: currentBranch,
}), [
currentBranch,
defaultBranch,
effectiveRemotes,
localBranches,
rootBranchHint,
worktreeMetadata?.createdFromBranch,
]);
const updateTargetBranch = React.useMemo(() => {
const remoteNames = effectiveRemotes.map((remote) => remote.name);
@@ -1511,7 +1531,6 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
const stagedCount = stagedChangeEntries.length;
const isBusy = isLoading || syncAction !== null || commitAction !== null;
const currentBranch = status?.current ?? null;
const canShowIntegrateCommitsSection = Boolean(
worktreeMetadata && repoRootForIntegrate && sourceBranchForIntegrate && shouldShowIntegrateCommits
);
@@ -213,14 +213,32 @@ export const PullRequestView: React.FC = () => {
}));
}, [remotes, remoteBranches, remoteUrl, status?.tracking]);
const currentBranch = status?.current ?? null;
// A pull request opened against a branch that does not exist is worse than a
// broken walkthrough, so this surface reads the repository's default branch
// too rather than guessing at main/master/develop.
const defaultBranch = React.useMemo(() => {
const trackingRemote = status?.tracking?.trim().split('/')[0];
return (trackingRemote && branches?.defaultBranches?.[trackingRemote])
?? branches?.defaultBranches?.origin;
}, [branches, status?.tracking]);
const baseBranch = React.useMemo(() => deriveBaseBranch({
remoteNames: new Set(effectiveRemotes.map((remote) => remote.name)),
localBranches,
worktreeCreatedFromBranch: worktreeMetadata?.createdFromBranch,
rootBranchHint,
}), [effectiveRemotes, localBranches, rootBranchHint, worktreeMetadata?.createdFromBranch]);
const currentBranch = status?.current ?? null;
defaultBranch,
headBranch: currentBranch,
}), [
currentBranch,
defaultBranch,
effectiveRemotes,
localBranches,
rootBranchHint,
worktreeMetadata?.createdFromBranch,
]);
if (!currentDirectory || !currentBranch) {
return (
@@ -2,13 +2,49 @@ import { describe, expect, test } from 'bun:test';
import { deriveBaseBranch, hasResolvableBaseBranch } from './baseBranch';
describe('deriveBaseBranch', () => {
test('prefers the remote default branch hint over conventional fallbacks', () => {
test('prefers the repository default branch over conventional fallbacks', () => {
expect(deriveBaseBranch({
remoteNames: new Set(['origin']),
localBranches: ['next'],
rootBranchHint: 'origin/react',
defaultBranch: 'react',
})).toBe('react');
});
test('accepts a remote-qualified default branch', () => {
expect(deriveBaseBranch({
remoteNames: new Set(['origin']),
localBranches: ['next'],
defaultBranch: 'origin/react',
})).toBe('react');
});
test('keeps the more specific worktree origin ahead of the default branch', () => {
expect(deriveBaseBranch({
remoteNames: new Set(['origin']),
localBranches: ['next', 'react', 'feature'],
worktreeCreatedFromBranch: 'feature',
defaultBranch: 'react',
})).toBe('feature');
});
test('skips a hint that is the branch being compared', () => {
// In a plain checkout the project root is the current worktree, so the root
// branch hint is the current branch — a branch is never its own base.
expect(deriveBaseBranch({
remoteNames: new Set(['origin']),
localBranches: ['next', 'react'],
rootBranchHint: 'next',
defaultBranch: 'react',
headBranch: 'next',
})).toBe('react');
});
test('falls back to conventional names when nothing is known', () => {
expect(deriveBaseBranch({
remoteNames: new Set(['origin']),
localBranches: ['master', 'next'],
})).toBe('master');
});
});
describe('hasResolvableBaseBranch', () => {
@@ -21,10 +57,28 @@ describe('hasResolvableBaseBranch', () => {
});
test('accepts a base branch available through a remote-tracking ref', () => {
// Safe because getRangeDiff resolves a base that exists only on a remote
// through that remote rather than passing the bare name to git.
expect(hasResolvableBaseBranch({
baseBranch: 'main',
localBranches: ['next'],
remoteBranches: ['origin/main', 'origin/next'],
})).toBe(true);
});
test('does not accept a differently-scoped branch that merely ends the same way', () => {
expect(hasResolvableBaseBranch({
baseBranch: 'main',
localBranches: ['next'],
remoteBranches: ['origin/feature/main'],
})).toBe(false);
});
test('matches a base branch whose own name contains a slash', () => {
expect(hasResolvableBaseBranch({
baseBranch: 'release/2.0',
localBranches: ['next'],
remoteBranches: ['origin/release/2.0'],
})).toBe(true);
});
});
@@ -8,8 +8,30 @@ export const deriveBaseBranch = (options: {
localBranches: readonly string[];
worktreeCreatedFromBranch?: string | null;
rootBranchHint?: string | null;
/**
* The repository's own default branch, read from a `remote/HEAD` symbolic
* ref. Its own option rather than another hint: `rootBranchHint` means "the
* branch the project root worktree is on", and a parameter that means two
* things is one the next caller gets wrong.
*/
defaultBranch?: string | null;
/**
* The branch being compared. A branch is never its own base, so a candidate
* equal to it is skipped in a plain checkout `rootBranchHint` *is* the
* current branch, and taking it produced a comparison with itself.
*/
headBranch?: string | null;
}): string => {
const { remoteNames, localBranches, worktreeCreatedFromBranch, rootBranchHint } = options;
const {
remoteNames,
localBranches,
worktreeCreatedFromBranch,
rootBranchHint,
defaultBranch,
headBranch,
} = options;
const head = typeof headBranch === 'string' ? headBranch.trim() : '';
const normalizeBaseCandidate = (value: string): string => {
if (!value) {
@@ -49,14 +71,22 @@ export const deriveBaseBranch = (options: {
return normalized;
};
const fromMeta = normalizeBaseCandidate(
typeof worktreeCreatedFromBranch === 'string' ? worktreeCreatedFromBranch : ''
);
const candidate = (value: unknown): string => {
const normalized = normalizeBaseCandidate(typeof value === 'string' ? value : '');
return normalized && normalized !== head ? normalized : '';
};
const fromMeta = candidate(worktreeCreatedFromBranch);
if (fromMeta) return fromMeta;
const fromHint = normalizeBaseCandidate(typeof rootBranchHint === 'string' ? rootBranchHint : '');
const fromHint = candidate(rootBranchHint);
if (fromHint) return fromHint;
// Authoritative where the hints are guesses: this is what the repository says
// its default branch is, so it outranks the conventional names below.
const fromDefault = candidate(defaultBranch);
if (fromDefault) return fromDefault;
if (localBranches.includes('main')) return 'main';
if (localBranches.includes('master')) return 'master';
if (localBranches.includes('develop')) return 'develop';
@@ -67,6 +97,11 @@ export const deriveBaseBranch = (options: {
* Whether a base branch can be resolved locally or through one of the active
* remote-tracking refs. Callers must not offer comparisons against the `main`
* fallback when that ref does not actually exist in the repository.
*
* `remoteBranches` are remote-relative (`origin/main`, `origin/feature/x`), so
* the remote name is dropped and the rest compared whole. A suffix test matched
* `origin/feature/main` for a base of `main`, which passes the check and then
* fails the comparison it was meant to prevent.
*/
export const hasResolvableBaseBranch = (options: {
baseBranch: string;
@@ -74,6 +109,9 @@ export const hasResolvableBaseBranch = (options: {
remoteBranches: readonly string[];
}): boolean => {
const { baseBranch, localBranches, remoteBranches } = options;
return localBranches.includes(baseBranch)
|| remoteBranches.some((branch) => branch.endsWith(`/${baseBranch}`));
if (localBranches.includes(baseBranch)) return true;
return remoteBranches.some((branch) => {
const slashIndex = branch.indexOf('/');
return slashIndex > 0 && branch.slice(slashIndex + 1) === baseBranch;
});
};
@@ -185,9 +185,14 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
.filter(Boolean)
);
const trackingRemote = status?.tracking?.split('/')[0];
const rootBranchHint = (trackingRemote && branches?.defaultBranches?.[trackingRemote])
const defaultBranch = (trackingRemote && branches?.defaultBranches?.[trackingRemote])
?? branches?.defaultBranches?.origin;
const baseRef = deriveBaseBranch({ remoteNames, localBranches, rootBranchHint });
const baseRef = deriveBaseBranch({
remoteNames,
localBranches,
defaultBranch,
headBranch: headRef,
});
if (!baseRef || baseRef === headRef || !hasResolvableBaseBranch({ baseBranch: baseRef, localBranches, remoteBranches })) {
return null;
}
+6 -1
View File
@@ -105,13 +105,18 @@ The following functions are internal helpers used by exported functions:
- `ahead`: Number of commits ahead of upstream.
- `behind`: Number of commits behind upstream.
- `upstreamComparison`: Optional comparison against `upstream/<current-branch>`, with `{ remote, branch, ahead, behind }`.
- `defaultBranches`: Remote default branches derived from local symbolic refs such as `remotes/origin/HEAD -> origin/main`, keyed by remote name. Omitted by runtimes that do not provide this Git metadata.
- `files`: Array of file objects with `path`, `index`, `working_dir` status codes.
- `isClean`: Boolean indicating if working tree is clean.
- `diffStats`: Object mapping file paths to `{ insertions, deletions }`.
- `mergeInProgress`: Object with `{ head, message }` if merge in progress.
- `rebaseInProgress`: Object with `{ headName, onto }` if rebase in progress.
### Branches Response
- `all`: Local branches plus remote-tracking branches that still exist on their remote. A remote that fails to answer keeps its branches in the list: "we could not ask" must not be reported as "these branches are gone", because callers use this list to decide whether a base branch exists at all.
- `current`: Current branch name.
- `branches`: Per-branch detail keyed by branch name, as reported by `git branch`.
- `defaultBranches`: Each remote's default branch, keyed by remote name. Read from the local `remotes/<name>/HEAD` symbolic ref; for a remote that has none — clone writes it, a hand-added remote may not — the remote itself is asked once with `ls-remote --symref`. A remote that answers neither is absent rather than guessed, and consumers fall back to conventional branch names. Omitted entirely by runtimes that do not provide this Git metadata.
### Runtime availability of range diffs
- `GET /api/git/range-diff` is served by the OpenChamber web server, so it is available to web, desktop, and mobile clients. The shared `GitAPI.getGitRangeDiff` is therefore optional: web supplies the HTTP implementation, and VS Code does not implement it because the extension host serves Git through its own bridge rather than these routes. Features built on range diffs (currently the AI diff walkthrough) are not offered in VS Code.
+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) {
+61 -13
View File
@@ -11,6 +11,7 @@ import {
createWorktree,
getWorktreeBootstrapStatus,
getBranches,
getRangeDiff,
getStatus,
isGitRepository,
populateWorktreeWithLockRecovery,
@@ -48,6 +49,28 @@ const runGit = (cwd, args) =>
stdio: ['ignore', 'pipe', 'pipe'],
});
/**
* A repository on `next` whose only remote publishes `defaultBranch` and has it
* recorded as that remote's HEAD the shape of every repository whose default
* branch is not one of the conventional names.
*/
const createRepositoryWithRemote = ({ remoteName = 'origin', defaultBranch = 'react' } = {}) => {
const remote = createTempDir();
const repository = createTempDir();
runGit(remote, ['init', '--bare', `--initial-branch=${defaultBranch}`]);
runGit(repository, ['init', '-b', 'next']);
runGit(repository, ['config', 'user.email', 'test@example.com']);
runGit(repository, ['config', 'user.name', 'Test']);
fs.writeFileSync(path.join(repository, 'README.md'), '# Test\n');
runGit(repository, ['add', 'README.md']);
runGit(repository, ['commit', '-m', 'init']);
runGit(repository, ['remote', 'add', remoteName, remote]);
runGit(repository, ['push', remoteName, `HEAD:${defaultBranch}`]);
runGit(repository, ['fetch', remoteName]);
runGit(repository, ['remote', 'set-head', remoteName, '--auto']);
return { remote, repository };
};
const canRunGit = () => {
try {
execFileSync('git', ['--version'], { stdio: 'ignore' });
@@ -992,22 +1015,47 @@ describe('hash validation', () => {
describe.runIf(canRunGit())('getBranches', () => {
it('returns a remote default branch whose name is not a conventional fallback', async () => {
const remote = createTempDir();
const repository = createTempDir();
runGit(remote, ['init', '--bare', '--initial-branch=react']);
runGit(repository, ['init', '-b', 'next']);
runGit(repository, ['config', 'user.email', 'test@example.com']);
runGit(repository, ['config', 'user.name', 'Test']);
fs.writeFileSync(path.join(repository, 'README.md'), '# Test\n');
runGit(repository, ['add', 'README.md']);
runGit(repository, ['commit', '-m', 'init']);
runGit(repository, ['remote', 'add', 'origin', remote]);
runGit(repository, ['push', 'origin', 'HEAD:react']);
runGit(repository, ['fetch', 'origin']);
runGit(repository, ['remote', 'set-head', 'origin', '--auto']);
const { repository } = createRepositoryWithRemote({ remoteName: 'origin', defaultBranch: 'react' });
await expect(getBranches(repository)).resolves.toMatchObject({
defaultBranches: { origin: 'react' },
});
});
it('asks the remote when no local remote/HEAD exists', async () => {
const { repository } = createRepositoryWithRemote({ remoteName: 'origin', defaultBranch: 'react' });
// A hand-added remote can end up without this ref; the branch it points at
// is still knowable, and guessing instead is the bug this data replaces.
runGit(repository, ['remote', 'set-head', 'origin', '--delete']);
await expect(getBranches(repository)).resolves.toMatchObject({
defaultBranches: { origin: 'react' },
});
});
it('keeps the branches of a remote that cannot be reached', async () => {
const { repository, remote } = createRepositoryWithRemote({ remoteName: 'origin', defaultBranch: 'react' });
fs.rmSync(remote, { recursive: true, force: true });
const branches = await getBranches(repository);
// "We could not ask" is not "the branch is gone": callers read this list to
// decide whether a base branch exists at all.
expect(branches.all).toContain('remotes/origin/react');
});
});
describe.runIf(canRunGit())('getRangeDiff', () => {
it('resolves a base that exists only on a remote other than origin', async () => {
const { repository } = createRepositoryWithRemote({ remoteName: 'upstream', defaultBranch: 'react' });
// Only refs/remotes/upstream/react carries the base — git cannot resolve the
// bare name, so an unqualified `react...next` fails with "ambiguous argument".
fs.writeFileSync(path.join(repository, 'feature.txt'), 'work\n');
runGit(repository, ['add', 'feature.txt']);
runGit(repository, ['commit', '-m', 'feature']);
const diff = await getRangeDiff(repository, { base: 'react', head: 'next' });
expect(diff).toContain('feature.txt');
});
});
@@ -55,10 +55,17 @@ written against staged code never silently re-anchors onto an unstaged edit.
| `branch` | `branch` | `getRangeDiff` uses three-dot `base...head`, so work merged in from the base branch is excluded |
| `pr` | `pr:<number>` | GitHub returns the merge-base diff, matching the branch semantics |
For the current-branch source, the UI prefers the default branch of the current
branch's tracking remote (from its local `remote/HEAD` symbolic ref), then uses
the existing conventional-branch fallback. It does not offer the source when the
chosen base cannot be resolved locally or through a remote-tracking ref.
For the current-branch source, the UI takes the base from the default branch of
the current branch's tracking remote (`defaultBranches` in the branches
response), and only then falls back to the conventional names. It does not offer
the source at all when the chosen base exists neither locally nor on a remote
a repository whose default is neither `main`, `master` nor `develop` used to be
handed `main...<head>`, which git rejects outright.
A base that exists only on a remote still works: `getRangeDiff` prefers
`origin/<base>` when it exists, and otherwise resolves the base through whichever
remote carries it, because a bare branch name git cannot find in `refs/heads`
fails the same way.
The panel offers the current branch's pull request on its own: it registers with
the shared GitHub PR status store (`useGitHubPrStatusStore`) rather than waiting