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
+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');
});
});