From dae696ab8f174aa22be70ded1541aa6a1e2ffa47 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 19 May 2026 21:59:25 -0700 Subject: [PATCH] Handle unborn worktrees with resolvable base (#2398) - Treat new remote worktrees without a HEAD commit as an empty compare when the base ref exists, avoiding a broken source-control compare state - Keep the existing unborn-head error for cases where the base cannot resolve --- src/main/git/status.test.ts | 22 ++++++++++++++++++++++ src/main/git/status.ts | 16 +++++++++++++++- src/relay/git-handler-ops.ts | 17 ++++++++++++++++- src/relay/git-handler.test.ts | 29 +++++++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 2 deletions(-) diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index 7501db48e64..10b58838cba 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -617,6 +617,7 @@ describe('getBranchCompare', () => { gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: 'main\n' }) .mockRejectedValueOnce(new Error('unborn')) + .mockRejectedValueOnce(new Error('missing base')) const result = await getBranchCompare('/repo', 'origin/main') @@ -625,6 +626,27 @@ describe('getBranchCompare', () => { expect(result.entries).toEqual([]) }) + it('treats an unborn branch with a resolvable base as having no committed branch changes', async () => { + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'feature\n' }) + .mockRejectedValueOnce(new Error('unborn')) + .mockResolvedValueOnce({ stdout: 'base-oid\n' }) + + const result = await getBranchCompare('/repo', 'origin/main') + + expect(result.summary).toEqual({ + baseRef: 'origin/main', + baseOid: 'base-oid', + compareRef: 'feature', + headOid: null, + mergeBase: null, + changedFiles: 0, + commitsAhead: 0, + status: 'ready' + }) + expect(result.entries).toEqual([]) + }) + it('returns no-merge-base when histories do not intersect', async () => { gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: 'main\n' }) diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 699efd3217d..fe933856bc3 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -438,17 +438,31 @@ export async function getBranchCompare( summary.compareRef = compareRef let headOid = '' + let baseOid = '' try { headOid = await resolveRefOid(worktreePath, 'HEAD') summary.headOid = headOid } catch { + try { + baseOid = await resolveRefOid(worktreePath, baseRef) + summary.baseOid = baseOid + // Why: new remote worktrees can be on an unborn branch until the first + // commit. There are no committed branch changes yet; surfacing this as a + // compare error makes the source-control panel look broken. + summary.changedFiles = 0 + summary.commitsAhead = 0 + summary.status = 'ready' + return { summary, entries: [] } + } catch { + // Preserve the existing unborn-head message when even the base is not + // resolvable; callers cannot compare or present a useful empty state. + } summary.status = 'unborn-head' summary.errorMessage = 'This branch does not have a committed HEAD yet, so compare-to-base is unavailable.' return { summary, entries: [] } } - let baseOid = '' try { baseOid = await resolveRefOid(worktreePath, baseRef) summary.baseOid = baseOid diff --git a/src/relay/git-handler-ops.ts b/src/relay/git-handler-ops.ts index 7c7b1746d7d..504130f817e 100644 --- a/src/relay/git-handler-ops.ts +++ b/src/relay/git-handler-ops.ts @@ -151,18 +151,33 @@ export async function branchCompare( } let headOid: string + let baseOid = '' try { const { stdout } = await git(['rev-parse', '--verify', 'HEAD'], worktreePath) headOid = stdout.trim() summary.headOid = headOid } catch { + try { + const { stdout } = await git(['rev-parse', '--verify', baseRef], worktreePath) + baseOid = stdout.trim() + summary.baseOid = baseOid + // Why: new remote worktrees can be on an unborn branch until the first + // commit. There are no committed branch changes yet; surfacing this as a + // compare error makes the source-control panel look broken. + summary.changedFiles = 0 + summary.commitsAhead = 0 + summary.status = 'ready' + return { summary, entries: [] } + } catch { + // Preserve the existing unborn-head message when even the base is not + // resolvable; callers cannot compare or present a useful empty state. + } summary.status = 'unborn-head' summary.errorMessage = 'This branch does not have a committed HEAD yet, so compare-to-base is unavailable.' return { summary, entries: [] } } - let baseOid: string try { const { stdout } = await git(['rev-parse', '--verify', baseRef], worktreePath) baseOid = stdout.trim() diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts index 2c1b063362b..f1150eb24b0 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -433,6 +433,35 @@ describe('GitHandler', () => { expect(entry).toBeDefined() expect(entry!.path).toBe('docs/日本語/sample.md') }) + + it('treats an unborn branch with a resolvable base as having no committed branch changes', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'base.txt'), 'base') + gitCommit(tmpDir, 'initial') + const baseRef = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { + cwd: tmpDir, + encoding: 'utf-8' + }).trim() + + execFileSync('git', ['checkout', '--orphan', 'feature'], { cwd: tmpDir, stdio: 'pipe' }) + execFileSync('git', ['rm', '-rf', '.'], { cwd: tmpDir, stdio: 'pipe' }) + + const result = (await dispatcher.callRequest('git.branchCompare', { + worktreePath: tmpDir, + baseRef + })) as { summary: Record; entries: Record[] } + + expect(result.summary).toMatchObject({ + baseRef, + compareRef: 'feature', + headOid: null, + changedFiles: 0, + commitsAhead: 0, + status: 'ready' + }) + expect(result.summary.baseOid).toMatch(/^[0-9a-f]{40}$/) + expect(result.entries).toEqual([]) + }) }) describe('branchDiff', () => {