fix(git): include remote-only branches from ls-remote in branch lists (#2735)

fix(git): include remote-only branches from ls-remote in branch lists
This commit is contained in:
Bohdan Triapitsyn
2026-08-28 23:40:58 +03:00
committed by GitHub
3 changed files with 62 additions and 2 deletions
+1 -1
View File
@@ -121,7 +121,7 @@ The following functions are internal helpers used by exported functions:
- `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.
- `all`: Local branches plus every branch each reachable remote reports via `ls-remote --heads`, formatted as `remotes/<remote>/<branch>`. This is a union: local remote-tracking refs deleted on the remote are pruned, and branches that exist on the remote without a local tracking ref (never fetched) are still included, so a freshly pushed branch appears without requiring a fetch. A remote that fails to answer keeps its locally known 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.
+20 -1
View File
@@ -3747,7 +3747,7 @@ async function filterActiveRemoteBranches(git, remoteBranches) {
}
}));
return remoteBranches.filter(remoteBranch => {
const activeBranches = remoteBranches.filter(remoteBranch => {
const match = remoteBranch.match(/^remotes\/[^\/]+\/(.+)$/);
if (!match) return false;
const remoteName = remoteBranch.split('/')[1];
@@ -3755,6 +3755,25 @@ async function filterActiveRemoteBranches(git, remoteBranches) {
if (unreachableRemotes.has(remoteName)) return true;
return branchesByRemote.get(remoteName)?.has(branchName) ?? false;
});
// A branch pushed to the remote that was never fetched locally has no
// remote-tracking ref, so `git branch` never reports it — but ls-remote
// just told us it exists. Add those so a freshly pushed branch shows up
// without requiring a fetch first (#2098). Unreachable remotes have no
// ls-remote data and therefore add nothing here; their local view above
// is preserved unchanged.
const seenBranches = new Set(activeBranches);
for (const [remoteName, actualRemoteBranches] of branchesByRemote) {
for (const branchName of actualRemoteBranches) {
const qualifiedBranch = `remotes/${remoteName}/${branchName}`;
if (!seenBranches.has(qualifiedBranch)) {
seenBranches.add(qualifiedBranch);
activeBranches.push(qualifiedBranch);
}
}
}
return activeBranches;
} catch (error) {
console.warn('Failed to filter active remote branches, returning all:', error.message);
return remoteBranches;
@@ -1429,6 +1429,47 @@ describe.runIf(canRunGit())('getBranches', () => {
// decide whether a base branch exists at all.
expect(branches.all).toContain('remotes/origin/react');
});
it('includes remote branches with no local tracking ref and prunes refs deleted on the remote (#2098)', async () => {
const remote = createTempDir();
runGit(remote, ['init', '--bare', '--initial-branch=main']);
const repository = createTempDir();
runGit(repository, ['init', '-b', 'main']);
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', '-u', 'origin', 'main']);
runGit(repository, ['checkout', '-b', 'feature-known']);
runGit(repository, ['push', '-u', 'origin', 'feature-known']);
// This tracking ref will go stale: the collaborator deletes the branch on
// the remote below, and the list must prune it.
runGit(repository, ['checkout', '-b', 'feature-stale']);
runGit(repository, ['push', '-u', 'origin', 'feature-stale']);
runGit(repository, ['checkout', 'main']);
runGit(repository, ['branch', '-D', 'feature-stale']);
// A collaborator pushes a branch straight to the remote and deletes
// another; this repository never fetches, so it has no local
// remote-tracking ref for feature-remote-only.
const collaborator = createTempDir();
runGit(collaborator, ['clone', remote, '.']);
runGit(collaborator, ['config', 'user.email', 'test@example.com']);
runGit(collaborator, ['config', 'user.name', 'Test']);
runGit(collaborator, ['checkout', '-b', 'feature-remote-only']);
runGit(collaborator, ['push', 'origin', 'feature-remote-only']);
runGit(collaborator, ['push', 'origin', ':feature-stale']);
const branches = await getBranches(repository);
expect(branches.all).toContain('remotes/origin/feature-remote-only');
expect(branches.all).toContain('remotes/origin/feature-known');
expect(branches.all).toContain('feature-known');
expect(branches.all).not.toContain('remotes/origin/feature-stale');
});
});
describe.runIf(canRunGit())('getRangeDiff', () => {