feat: display CI conflict details and improve merge-conflict UX (#234)

- Add PRConflictSummary type and derive conflict metadata (base ref,
  commits behind, conflicting files) via local git merge-tree
- Fix parseUnmergedEntry to use space-separated parsing (porcelain v2
  unmerged entries are not tab-separated)
- Enhance rebase detection by checking rebase-merge/ and rebase-apply/
  directories in addition to REBASE_HEAD
- Show conflicting files list and commits-behind count in ChecksPanel
- Disable merge button with tooltip when PR has conflicts
- Add operation banners (merge/rebase/cherry-pick) in SourceControl
  and WorktreeCard
- Reorder section list so unstaged changes appear above staged
- Extract conflict-summary, issues, and gh-utils into separate modules
- Add 10s timeout to git fetch in conflict summary derivation
- Fix test fixtures to match real porcelain v2 format
This commit is contained in:
Jinjing
2026-03-31 01:27:39 -07:00
committed by GitHub
parent 86ea7ad719
commit f2eb119c62
14 changed files with 749 additions and 283 deletions
+121 -4
View File
@@ -33,7 +33,11 @@ describe('getPRForBranch', () => {
statusCheckRollup: [],
updatedAt: '2026-03-28T00:00:00Z',
isDraft: false,
mergeable: 'MERGEABLE'
mergeable: 'MERGEABLE',
baseRefName: 'main',
headRefName: 'feature/test',
baseRefOid: 'base-oid',
headRefOid: 'head-oid'
}
])
})
@@ -59,7 +63,7 @@ describe('getPRForBranch', () => {
'--limit',
'1',
'--json',
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable'
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
],
{ cwd: '/repo-root', encoding: 'utf-8' }
)
@@ -78,7 +82,11 @@ describe('getPRForBranch', () => {
statusCheckRollup: [],
updatedAt: '2026-03-28T00:00:00Z',
isDraft: true,
mergeable: 'CONFLICTING'
mergeable: 'CONFLICTING',
baseRefName: 'main',
headRefName: 'feature/test',
baseRefOid: 'base-oid',
headRefOid: 'head-oid'
})
})
@@ -92,7 +100,7 @@ describe('getPRForBranch', () => {
'view',
'feature/test',
'--json',
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable'
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
],
{ cwd: '/non-github-repo', encoding: 'utf-8' }
)
@@ -101,6 +109,115 @@ describe('getPRForBranch', () => {
expect(pr?.mergeable).toBe('CONFLICTING')
})
it('derives a read-only conflict summary for conflicting PRs when the base ref exists locally', async () => {
execFileAsyncMock
.mockResolvedValueOnce({ stdout: 'git@github.com:acme/widgets.git\n' })
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 42,
title: 'Fix PR discovery',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/42',
statusCheckRollup: [],
updatedAt: '2026-03-28T00:00:00Z',
isDraft: false,
mergeable: 'CONFLICTING',
baseRefName: 'main',
headRefName: 'feature/test',
baseRefOid: 'base-oid',
headRefOid: 'head-oid'
}
])
})
.mockResolvedValueOnce({ stdout: '' })
.mockResolvedValueOnce({ stdout: 'latest-base-oid\n' })
.mockResolvedValueOnce({ stdout: 'merge-base-oid\n' })
.mockResolvedValueOnce({ stdout: '3\n' })
.mockResolvedValueOnce({ stdout: 'result-tree-oid\u0000src/a.ts\u0000src/b.ts\u0000' })
const pr = await getPRForBranch('/repo-root', 'feature/test')
expect(pr?.conflictSummary).toEqual({
baseRef: 'main',
baseCommit: 'latest-',
commitsBehind: 3,
files: ['src/a.ts', 'src/b.ts']
})
})
it('keeps conflicted file paths when git merge-tree exits 1 with stdout', async () => {
execFileAsyncMock
.mockResolvedValueOnce({ stdout: 'git@github.com:acme/widgets.git\n' })
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 42,
title: 'Fix PR discovery',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/42',
statusCheckRollup: [],
updatedAt: '2026-03-28T00:00:00Z',
isDraft: false,
mergeable: 'CONFLICTING',
baseRefName: 'main',
headRefName: 'feature/test',
baseRefOid: 'base-oid',
headRefOid: 'head-oid'
}
])
})
.mockResolvedValueOnce({ stdout: '' })
.mockResolvedValueOnce({ stdout: 'latest-base-oid\n' })
.mockResolvedValueOnce({ stdout: 'merge-base-oid\n' })
.mockResolvedValueOnce({ stdout: '2\n' })
.mockRejectedValueOnce({
stdout: 'result-tree-oid\u0000src/conflict.ts\u0000'
})
const pr = await getPRForBranch('/repo-root', 'feature/test')
expect(pr?.conflictSummary?.files).toEqual(['src/conflict.ts'])
})
it('falls back to GitHub baseRefOid when fetching or resolving the base ref fails', async () => {
execFileAsyncMock
.mockResolvedValueOnce({ stdout: 'git@github.com:acme/widgets.git\n' })
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 42,
title: 'Fix PR discovery',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/42',
statusCheckRollup: [],
updatedAt: '2026-03-28T00:00:00Z',
isDraft: false,
mergeable: 'CONFLICTING',
baseRefName: 'main',
headRefName: 'feature/test',
baseRefOid: 'base-oid',
headRefOid: 'head-oid'
}
])
})
.mockRejectedValueOnce(new Error('fetch failed'))
.mockRejectedValueOnce(new Error('missing refs/remotes/origin/main'))
.mockRejectedValueOnce(new Error('missing origin/main'))
.mockResolvedValueOnce({ stdout: 'merge-base-oid\n' })
.mockResolvedValueOnce({ stdout: '1\n' })
.mockResolvedValueOnce({ stdout: 'result-tree-oid\u0000src/fallback.ts\u0000' })
const pr = await getPRForBranch('/repo-root', 'feature/test')
expect(pr?.conflictSummary).toEqual({
baseRef: 'main',
baseCommit: 'base-oi',
commitsBehind: 1,
files: ['src/fallback.ts']
})
})
it('returns null for empty branch (e.g. during rebase with detached HEAD)', async () => {
const pr = await getPRForBranch('/repo-root', '')
expect(pr).toBeNull()
+19 -140
View File
@@ -1,74 +1,17 @@
import { execFile } from 'child_process'
import { promisify } from 'util'
import type { PRInfo, PRMergeableState, IssueInfo, PRCheckDetail } from '../../shared/types'
import type { PRInfo, PRMergeableState, PRCheckDetail } from '../../shared/types'
import { getPRConflictSummary } from './conflict-summary'
import { execFileAsync, acquire, release, getOwnerRepo } from './gh-utils'
export { _resetOwnerRepoCache } from './gh-utils'
export { getIssue, listIssues } from './issues'
import {
mapCheckRunRESTStatus,
mapCheckRunRESTConclusion,
mapCheckStatus,
mapCheckConclusion,
mapPRState,
deriveCheckStatus,
mapIssueInfo
deriveCheckStatus
} from './mappers'
const execFileAsync = promisify(execFile)
// Concurrency limiter - max 4 parallel gh processes
const MAX_CONCURRENT = 4
let running = 0
const queue: (() => void)[] = []
function acquire(): Promise<void> {
if (running < MAX_CONCURRENT) {
running++
return Promise.resolve()
}
return new Promise((resolve) =>
queue.push(() => {
running++
resolve()
})
)
}
function release(): void {
running--
const next = queue.shift()
if (next) {
next()
}
}
// ── Owner/repo resolution for gh api --cache ──────────────────────────
const ownerRepoCache = new Map<string, { owner: string; repo: string } | null>()
/** @internal — exposed for tests only */
export function _resetOwnerRepoCache(): void {
ownerRepoCache.clear()
}
async function getOwnerRepo(repoPath: string): Promise<{ owner: string; repo: string } | null> {
if (ownerRepoCache.has(repoPath)) {
return ownerRepoCache.get(repoPath)!
}
try {
const { stdout } = await execFileAsync('git', ['remote', 'get-url', 'origin'], {
cwd: repoPath,
encoding: 'utf-8'
})
const match = stdout.trim().match(/github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/)
if (match) {
const result = { owner: match[1], repo: match[2] }
ownerRepoCache.set(repoPath, result)
return result
}
} catch {
// ignore — non-GitHub remote or no remote
}
ownerRepoCache.set(repoPath, null)
return null
}
/**
* Get PR info for a given branch using gh CLI.
* Returns null if gh is not installed, or no PR exists for the branch.
@@ -95,6 +38,10 @@ export async function getPRForBranch(repoPath: string, branch: string): Promise<
updatedAt: string
isDraft?: boolean
mergeable: string
baseRefName?: string
headRefName?: string
baseRefOid?: string
headRefOid?: string
} | null = null
if (ownerRepo) {
@@ -112,7 +59,7 @@ export async function getPRForBranch(repoPath: string, branch: string): Promise<
'--limit',
'1',
'--json',
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable'
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
],
{
cwd: repoPath,
@@ -129,7 +76,7 @@ export async function getPRForBranch(repoPath: string, branch: string): Promise<
'view',
branchName,
'--json',
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable'
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
],
{
cwd: repoPath,
@@ -143,6 +90,11 @@ export async function getPRForBranch(repoPath: string, branch: string): Promise<
return null
}
const conflictSummary =
data.mergeable === 'CONFLICTING' && data.baseRefName && data.baseRefOid && data.headRefOid
? await getPRConflictSummary(repoPath, data.baseRefName, data.baseRefOid, data.headRefOid)
: undefined
return {
number: data.number,
title: data.title,
@@ -150,7 +102,8 @@ export async function getPRForBranch(repoPath: string, branch: string): Promise<
url: data.url,
checksStatus: deriveCheckStatus(data.statusCheckRollup),
updatedAt: data.updatedAt,
mergeable: (data.mergeable as PRMergeableState) ?? 'UNKNOWN'
mergeable: (data.mergeable as PRMergeableState) ?? 'UNKNOWN',
conflictSummary
}
} catch {
return null
@@ -159,80 +112,6 @@ export async function getPRForBranch(repoPath: string, branch: string): Promise<
}
}
/**
* Get a single issue by number.
* Uses gh api --cache so 304 Not Modified responses don't count against the rate limit.
*/
export async function getIssue(repoPath: string, issueNumber: number): Promise<IssueInfo | null> {
const ownerRepo = await getOwnerRepo(repoPath)
await acquire()
try {
if (ownerRepo) {
const { stdout } = await execFileAsync(
'gh',
[
'api',
'--cache',
'300s',
`repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${issueNumber}`
],
{ cwd: repoPath, encoding: 'utf-8' }
)
const data = JSON.parse(stdout)
return mapIssueInfo(data)
}
// Fallback for non-GitHub remotes
const { stdout } = await execFileAsync(
'gh',
['issue', 'view', String(issueNumber), '--json', 'number,title,state,url,labels'],
{ cwd: repoPath, encoding: 'utf-8' }
)
const data = JSON.parse(stdout)
return mapIssueInfo(data)
} catch {
return null
} finally {
release()
}
}
/**
* List issues for a repo.
* Uses gh api --cache so 304 Not Modified responses don't count against the rate limit.
*/
export async function listIssues(repoPath: string, limit = 20): Promise<IssueInfo[]> {
const ownerRepo = await getOwnerRepo(repoPath)
await acquire()
try {
if (ownerRepo) {
const { stdout } = await execFileAsync(
'gh',
[
'api',
'--cache',
'120s',
`repos/${ownerRepo.owner}/${ownerRepo.repo}/issues?per_page=${limit}&state=open&sort=updated&direction=desc`
],
{ cwd: repoPath, encoding: 'utf-8' }
)
const data = JSON.parse(stdout) as unknown[]
return data.map((d) => mapIssueInfo(d as Parameters<typeof mapIssueInfo>[0]))
}
// Fallback for non-GitHub remotes
const { stdout } = await execFileAsync(
'gh',
['issue', 'list', '--json', 'number,title,state,url,labels', '--limit', String(limit)],
{ cwd: repoPath, encoding: 'utf-8' }
)
const data = JSON.parse(stdout) as unknown[]
return data.map((d) => mapIssueInfo(d as Parameters<typeof mapIssueInfo>[0]))
} catch {
return []
} finally {
release()
}
}
/**
* Get detailed check statuses for a PR.
* When branch is provided, uses gh api --cache with the check-runs REST endpoint
+148
View File
@@ -0,0 +1,148 @@
import { execFile } from 'child_process'
import { promisify } from 'util'
import type { PRConflictSummary } from '../../shared/types'
const execFileAsync = promisify(execFile)
export async function getPRConflictSummary(
repoPath: string,
baseRefName: string,
baseRefOid: string,
headRefOid: string
): Promise<PRConflictSummary | undefined> {
try {
// Why: the renderer only needs a read-only merge-conflict snapshot. We
// derive it from local git state so the PR card can show GitHub-style
// detail without spending additional gh API calls on every refresh. We use
// GitHub's head OID directly because the registered repo path may not have
// a matching local branch name for the PR head. For the base side, prefer a
// freshly-fetched remote-tracking ref so Orca matches GitHub's portal,
// which compares against the latest base branch tip rather than the PR's
// older pinned baseRefOid snapshot.
const latestBaseOid = await resolveLatestBaseOid(repoPath, baseRefName, baseRefOid)
const mergeBase = await resolveMergeBase(repoPath, headRefOid, latestBaseOid)
const [commitsBehind, files] = await Promise.all([
countCommits(repoPath, `${headRefOid}..${latestBaseOid}`),
loadConflictingFiles(repoPath, mergeBase, headRefOid, latestBaseOid)
])
return {
baseRef: baseRefName,
baseCommit: latestBaseOid.slice(0, 7),
commitsBehind,
files
}
} catch {
return undefined
}
}
async function resolveLatestBaseOid(
repoPath: string,
baseRefName: string,
fallbackBaseOid: string
): Promise<string> {
const remoteName = 'origin'
try {
// Why: cap the fetch at 10 s so slow or unreachable remotes don't block
// the conflict-summary derivation indefinitely.
await execFileAsync('git', ['fetch', '--quiet', remoteName, baseRefName], {
cwd: repoPath,
encoding: 'utf-8',
timeout: 10_000
})
} catch {
// Why: fetching the base ref keeps the conflict list aligned with GitHub's
// live mergeability view, but the card must still render offline. If fetch
// fails, fall back to the base OID GitHub already gave us.
}
for (const ref of [`refs/remotes/${remoteName}/${baseRefName}`, `${remoteName}/${baseRefName}`]) {
try {
const { stdout } = await execFileAsync('git', ['rev-parse', '--verify', ref], {
cwd: repoPath,
encoding: 'utf-8'
})
const oid = stdout.trim()
if (oid) {
return oid
}
} catch {
// Try the next ref form before falling back to GitHub's baseRefOid.
}
}
return fallbackBaseOid
}
async function resolveMergeBase(
repoPath: string,
headOid: string,
baseOid: string
): Promise<string> {
const { stdout } = await execFileAsync('git', ['merge-base', headOid, baseOid], {
cwd: repoPath,
encoding: 'utf-8'
})
return stdout.trim()
}
async function countCommits(repoPath: string, range: string): Promise<number> {
const { stdout } = await execFileAsync('git', ['rev-list', '--count', range], {
cwd: repoPath,
encoding: 'utf-8'
})
return Number.parseInt(stdout.trim(), 10) || 0
}
async function loadConflictingFiles(
repoPath: string,
mergeBase: string,
headOid: string,
baseOid: string
): Promise<string[]> {
let stdout = ''
try {
const result = await execFileAsync(
'git',
[
'merge-tree',
'--write-tree',
'--name-only',
'-z',
'--no-messages',
'--merge-base',
mergeBase,
headOid,
baseOid
],
{
cwd: repoPath,
encoding: 'utf-8'
}
)
stdout = result.stdout
} catch (error) {
const stdoutFromError =
typeof error === 'object' && error && 'stdout' in error && typeof error.stdout === 'string'
? error.stdout
: ''
// Why: `git merge-tree --write-tree` exits with status 1 when it finds
// conflicts, but still writes the conflicted file list to stdout. Treat
// that stdout as the useful result instead of dropping the summary.
if (!stdoutFromError) {
throw error
}
stdout = stdoutFromError
}
const entries = stdout.split('\0').filter(Boolean)
if (entries.length === 0) {
return []
}
const [, ...files] = entries
return files
}
+62
View File
@@ -0,0 +1,62 @@
import { execFile } from 'child_process'
import { promisify } from 'util'
export const execFileAsync = promisify(execFile)
// Concurrency limiter - max 4 parallel gh processes
const MAX_CONCURRENT = 4
let running = 0
const queue: (() => void)[] = []
export function acquire(): Promise<void> {
if (running < MAX_CONCURRENT) {
running++
return Promise.resolve()
}
return new Promise((resolve) =>
queue.push(() => {
running++
resolve()
})
)
}
export function release(): void {
running--
const next = queue.shift()
if (next) {
next()
}
}
// ── Owner/repo resolution for gh api --cache ──────────────────────────
const ownerRepoCache = new Map<string, { owner: string; repo: string } | null>()
/** @internal — exposed for tests only */
export function _resetOwnerRepoCache(): void {
ownerRepoCache.clear()
}
export async function getOwnerRepo(
repoPath: string
): Promise<{ owner: string; repo: string } | null> {
if (ownerRepoCache.has(repoPath)) {
return ownerRepoCache.get(repoPath)!
}
try {
const { stdout } = await execFileAsync('git', ['remote', 'get-url', 'origin'], {
cwd: repoPath,
encoding: 'utf-8'
})
const match = stdout.trim().match(/github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/)
if (match) {
const result = { owner: match[1], repo: match[2] }
ownerRepoCache.set(repoPath, result)
return result
}
} catch {
// ignore — non-GitHub remote or no remote
}
ownerRepoCache.set(repoPath, null)
return null
}
+77
View File
@@ -0,0 +1,77 @@
import type { IssueInfo } from '../../shared/types'
import { mapIssueInfo } from './mappers'
import { execFileAsync, acquire, release, getOwnerRepo } from './gh-utils'
/**
* Get a single issue by number.
* Uses gh api --cache so 304 Not Modified responses don't count against the rate limit.
*/
export async function getIssue(repoPath: string, issueNumber: number): Promise<IssueInfo | null> {
const ownerRepo = await getOwnerRepo(repoPath)
await acquire()
try {
if (ownerRepo) {
const { stdout } = await execFileAsync(
'gh',
[
'api',
'--cache',
'300s',
`repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${issueNumber}`
],
{ cwd: repoPath, encoding: 'utf-8' }
)
const data = JSON.parse(stdout)
return mapIssueInfo(data)
}
// Fallback for non-GitHub remotes
const { stdout } = await execFileAsync(
'gh',
['issue', 'view', String(issueNumber), '--json', 'number,title,state,url,labels'],
{ cwd: repoPath, encoding: 'utf-8' }
)
const data = JSON.parse(stdout)
return mapIssueInfo(data)
} catch {
return null
} finally {
release()
}
}
/**
* List issues for a repo.
* Uses gh api --cache so 304 Not Modified responses don't count against the rate limit.
*/
export async function listIssues(repoPath: string, limit = 20): Promise<IssueInfo[]> {
const ownerRepo = await getOwnerRepo(repoPath)
await acquire()
try {
if (ownerRepo) {
const { stdout } = await execFileAsync(
'gh',
[
'api',
'--cache',
'120s',
`repos/${ownerRepo.owner}/${ownerRepo.repo}/issues?per_page=${limit}&state=open&sort=updated&direction=desc`
],
{ cwd: repoPath, encoding: 'utf-8' }
)
const data = JSON.parse(stdout) as unknown[]
return data.map((d) => mapIssueInfo(d as Parameters<typeof mapIssueInfo>[0]))
}
// Fallback for non-GitHub remotes
const { stdout } = await execFileAsync(
'gh',
['issue', 'list', '--json', 'number,title,state,url,labels', '--limit', String(limit)],
{ cwd: repoPath, encoding: 'utf-8' }
)
const data = JSON.parse(stdout) as unknown[]
return data.map((d) => mapIssueInfo(d as Parameters<typeof mapIssueInfo>[0]))
} catch {
return []
} finally {
release()
}
}