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:
@@ -121,7 +121,7 @@ The following functions are internal helpers used by exported functions:
|
|||||||
- `rebaseInProgress`: Object with `{ headName, onto }` if rebase in progress.
|
- `rebaseInProgress`: Object with `{ headName, onto }` if rebase in progress.
|
||||||
|
|
||||||
### Branches Response
|
### 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.
|
- `current`: Current branch name.
|
||||||
- `branches`: Per-branch detail keyed by branch name, as reported by `git branch`.
|
- `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.
|
- `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.
|
||||||
|
|||||||
@@ -3747,7 +3747,7 @@ async function filterActiveRemoteBranches(git, remoteBranches) {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return remoteBranches.filter(remoteBranch => {
|
const activeBranches = remoteBranches.filter(remoteBranch => {
|
||||||
const match = remoteBranch.match(/^remotes\/[^\/]+\/(.+)$/);
|
const match = remoteBranch.match(/^remotes\/[^\/]+\/(.+)$/);
|
||||||
if (!match) return false;
|
if (!match) return false;
|
||||||
const remoteName = remoteBranch.split('/')[1];
|
const remoteName = remoteBranch.split('/')[1];
|
||||||
@@ -3755,6 +3755,25 @@ async function filterActiveRemoteBranches(git, remoteBranches) {
|
|||||||
if (unreachableRemotes.has(remoteName)) return true;
|
if (unreachableRemotes.has(remoteName)) return true;
|
||||||
return branchesByRemote.get(remoteName)?.has(branchName) ?? false;
|
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) {
|
} catch (error) {
|
||||||
console.warn('Failed to filter active remote branches, returning all:', error.message);
|
console.warn('Failed to filter active remote branches, returning all:', error.message);
|
||||||
return remoteBranches;
|
return remoteBranches;
|
||||||
|
|||||||
@@ -1429,6 +1429,47 @@ describe.runIf(canRunGit())('getBranches', () => {
|
|||||||
// decide whether a base branch exists at all.
|
// decide whether a base branch exists at all.
|
||||||
expect(branches.all).toContain('remotes/origin/react');
|
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', () => {
|
describe.runIf(canRunGit())('getRangeDiff', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user