diff --git a/packages/web/server/lib/git/DOCUMENTATION.md b/packages/web/server/lib/git/DOCUMENTATION.md index 818d71ad..4ccaf7ad 100644 --- a/packages/web/server/lib/git/DOCUMENTATION.md +++ b/packages/web/server/lib/git/DOCUMENTATION.md @@ -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//`. 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//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. diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index f22f4ba3..112a440f 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -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; diff --git a/packages/web/server/lib/git/service.test.js b/packages/web/server/lib/git/service.test.js index 4df73b3a..cf996cd7 100644 --- a/packages/web/server/lib/git/service.test.js +++ b/packages/web/server/lib/git/service.test.js @@ -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', () => {