diff --git a/packages/ui/src/components/views/git/baseBranch.test.ts b/packages/ui/src/components/views/git/baseBranch.test.ts new file mode 100644 index 00000000..18aaffb8 --- /dev/null +++ b/packages/ui/src/components/views/git/baseBranch.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from 'bun:test'; +import { deriveBaseBranch, hasResolvableBaseBranch } from './baseBranch'; + +describe('deriveBaseBranch', () => { + test('prefers the remote default branch hint over conventional fallbacks', () => { + expect(deriveBaseBranch({ + remoteNames: new Set(['origin']), + localBranches: ['next'], + rootBranchHint: 'origin/react', + })).toBe('react'); + }); +}); + +describe('hasResolvableBaseBranch', () => { + test('rejects the main fallback when it does not exist', () => { + expect(hasResolvableBaseBranch({ + baseBranch: 'main', + localBranches: ['next', 'react'], + remoteBranches: ['origin/next', 'origin/react'], + })).toBe(false); + }); + + test('accepts a base branch available through a remote-tracking ref', () => { + expect(hasResolvableBaseBranch({ + baseBranch: 'main', + localBranches: ['next'], + remoteBranches: ['origin/main', 'origin/next'], + })).toBe(true); + }); +}); diff --git a/packages/ui/src/components/views/git/baseBranch.ts b/packages/ui/src/components/views/git/baseBranch.ts index 19e22b59..6409597b 100644 --- a/packages/ui/src/components/views/git/baseBranch.ts +++ b/packages/ui/src/components/views/git/baseBranch.ts @@ -62,3 +62,18 @@ export const deriveBaseBranch = (options: { if (localBranches.includes('develop')) return 'develop'; return 'main'; }; + +/** + * 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. + */ +export const hasResolvableBaseBranch = (options: { + baseBranch: string; + localBranches: readonly string[]; + remoteBranches: readonly string[]; +}): boolean => { + const { baseBranch, localBranches, remoteBranches } = options; + return localBranches.includes(baseBranch) + || remoteBranches.some((branch) => branch.endsWith(`/${baseBranch}`)); +}; diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx index 246a818c..a2c2527b 100644 --- a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx +++ b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx @@ -14,10 +14,10 @@ import { useI18n, type Locale } from '@/lib/i18n'; import { buildWalkthroughView } from '@/lib/walkthrough/model'; import type { WalkthroughSource, WalkthroughWorkingTreeScope } from '@/lib/walkthrough/types'; import { ModelSelector } from '@/components/sections/agents/ModelSelector'; -import { deriveBaseBranch } from '@/components/views/git/baseBranch'; +import { deriveBaseBranch, hasResolvableBaseBranch } from '@/components/views/git/baseBranch'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { useConfigStore } from '@/stores/useConfigStore'; -import { useGitBranches, useGitStatus } from '@/stores/useGitStore'; +import { useGitBranches, useGitStatus, useGitStore } from '@/stores/useGitStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { getFreshestPrStatusForBranch, @@ -152,6 +152,12 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { const status = useGitStatus(directory || null); const branches = useGitBranches(directory || null); + const ensureAll = useGitStore((state) => state.ensureAll); + const { github, git } = useRuntimeAPIs(); + + useEffect(() => { + if (directory) void ensureAll(directory, git); + }, [directory, ensureAll, git]); // The branch source reviews everything on this branch that is not on its // base. Three-dot semantics server-side mean merges from the base are @@ -162,22 +168,28 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { if (!headRef) return null; const all = branches?.all ?? []; const localBranches = all.filter((name) => !name.startsWith('remotes/')); + const remoteBranches = all + .filter((name) => name.startsWith('remotes/')) + .map((name) => name.slice('remotes/'.length)); const remoteNames = new Set( - all - .filter((name) => name.startsWith('remotes/')) - .map((name) => name.slice('remotes/'.length).split('/')[0]) + remoteBranches + .map((name) => name.split('/')[0]) .filter(Boolean) ); - const baseRef = deriveBaseBranch({ remoteNames, localBranches }); - if (!baseRef || baseRef === headRef) return null; + const trackingRemote = status?.tracking?.split('/')[0]; + const rootBranchHint = (trackingRemote && branches?.defaultBranches?.[trackingRemote]) + ?? branches?.defaultBranches?.origin; + const baseRef = deriveBaseBranch({ remoteNames, localBranches, rootBranchHint }); + if (!baseRef || baseRef === headRef || !hasResolvableBaseBranch({ baseBranch: baseRef, localBranches, remoteBranches })) { + return null; + } return { kind: 'branch', baseRef, headRef }; - }, [branches, currentBranch]); + }, [branches, currentBranch, status?.tracking]); // The pull request for this branch used to appear only after visiting the PR // panel, because nothing else asked GitHub about it. Ask here too: the status // store already dedupes by signature and throttles by TTL, so several panels // wanting the same answer produce one request. - const { github } = useRuntimeAPIs(); const githubConnected = useGitHubAuthStore((state) => state.status?.connected ?? false); const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked); const ensurePrStatusEntry = useGitHubPrStatusStore((state) => state.ensureEntry); diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 9b1ae0b7..9c5859af 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -183,6 +183,7 @@ export interface GitBranch { all: string[]; current: string; branches: Record; + defaultBranches?: Record; } interface GitCommitSummary { diff --git a/packages/web/server/lib/git/DOCUMENTATION.md b/packages/web/server/lib/git/DOCUMENTATION.md index 7dbd31ce..8bcbb964 100644 --- a/packages/web/server/lib/git/DOCUMENTATION.md +++ b/packages/web/server/lib/git/DOCUMENTATION.md @@ -105,6 +105,7 @@ 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/`, 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 }`. diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index a3a99f62..ed64b7c5 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -3367,6 +3367,7 @@ export async function getBranches(directory) { const allBranches = result.all; const remoteBranches = allBranches.filter(branch => branch.startsWith('remotes/')); const activeRemoteBranches = await filterActiveRemoteBranches(git, remoteBranches); + const defaultBranches = await getRemoteDefaultBranches(git); const filteredAll = [ ...allBranches.filter(branch => !branch.startsWith('remotes/')), @@ -3376,7 +3377,8 @@ export async function getBranches(directory) { return { all: filteredAll, current: result.current, - branches: result.branches + branches: result.branches, + defaultBranches, }; } catch (error) { console.error('Failed to get branches:', error); @@ -3384,6 +3386,28 @@ export async function getBranches(directory) { } } +async function getRemoteDefaultBranches(git) { + try { + const refs = await git.raw([ + 'for-each-ref', + '--format=%(refname) %(symref)', + 'refs/remotes', + ]); + return Object.fromEntries( + refs.trim().split('\n').flatMap((line) => { + const [ref, symbolicRef] = line.split(' '); + const match = ref.match(/^refs\/remotes\/([^/]+)\/HEAD$/); + const prefix = match ? `refs/remotes/${match[1]}/` : ''; + return match && typeof symbolicRef === 'string' && symbolicRef.startsWith(prefix) + ? [[match[1], symbolicRef.slice(prefix.length)]] + : []; + }) + ); + } catch { + return {}; + } +} + async function filterActiveRemoteBranches(git, remoteBranches) { try { const remotes = await git.getRemotes(); diff --git a/packages/web/server/lib/git/service.test.js b/packages/web/server/lib/git/service.test.js index e5110995..40f0c054 100644 --- a/packages/web/server/lib/git/service.test.js +++ b/packages/web/server/lib/git/service.test.js @@ -10,6 +10,7 @@ import { cherryPick, createWorktree, getWorktreeBootstrapStatus, + getBranches, getStatus, isGitRepository, populateWorktreeWithLockRecovery, @@ -988,3 +989,25 @@ describe('hash validation', () => { ).rejects.not.toThrow('Invalid commit hash'); }); }); + +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']); + + await expect(getBranches(repository)).resolves.toMatchObject({ + defaultBranches: { origin: 'react' }, + }); + }); +}); diff --git a/packages/web/server/lib/walkthrough/DOCUMENTATION.md b/packages/web/server/lib/walkthrough/DOCUMENTATION.md index 0f23fac5..b2d43ad1 100644 --- a/packages/web/server/lib/walkthrough/DOCUMENTATION.md +++ b/packages/web/server/lib/walkthrough/DOCUMENTATION.md @@ -55,6 +55,11 @@ 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:` | 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. + 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 for the pull request panel to have been visited. That store already dedupes