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
This commit is contained in:
Jinjing 2026-05-19 21:59:25 -07:00 committed by GitHub
parent d9e38cb469
commit dae696ab8f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 82 additions and 2 deletions

View File

@ -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' })

View File

@ -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

View File

@ -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()

View File

@ -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<string, unknown>; entries: Record<string, unknown>[] }
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', () => {