Handle clean local merges when hosting provider reports conflicts (#5942)

When the hosting provider (GitHub) reports conflicts but a local merge
simulation is clean, we now mark the conflict summary as locally clean.

This state is surfaced in both the desktop and mobile sidebars with a
clear explanation and a copyable set of commands to trigger a remote
mergeability recalculation via an empty commit and push.
This commit is contained in:
Jinjing 2026-06-20 17:08:58 -07:00 committed by GitHub
parent 8a4f10b35c
commit 16347ab799
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 413 additions and 23 deletions

View File

@ -91,6 +91,8 @@ function createMockAgent(index: number, now: number): RuntimeWorktreeAgentRow {
state: index % 12 === 0 ? 'waiting' : 'working',
agentType: index % 3 === 0 ? 'claude' : 'codex',
prompt: `Investigate mobile lag scenario ${index + 1}`,
taskTitle: null,
displayName: null,
lastAssistantMessage: index % 6 === 0 ? 'Running focused checks' : null,
toolName: null,
toolInput: null,

View File

@ -1,5 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native'
import { FileWarning, Sparkles } from 'lucide-react-native'
import * as Clipboard from 'expo-clipboard'
import { Check, Copy, FileWarning, Sparkles } from 'lucide-react-native'
import { colors } from '../../theme/mobile-theme'
import type { PRInfo } from '../../../../src/shared/types'
import { PRSection } from './PRSection'
@ -27,10 +29,47 @@ type Props = {
// list is not yet available. Ports the desktop ConflictingFilesSection +
// MergeConflictNotice into the mobile card shell.
export function PRConflictingFilesSection({ pr, isRefreshing = false, triage }: Props) {
const [commandsCopied, setCommandsCopied] = useState(false)
const copiedResetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const conflict = resolveConflictDisplay(pr)
useEffect(() => {
return () => {
if (copiedResetTimerRef.current) {
clearTimeout(copiedResetTimerRef.current)
}
}
}, [])
if (!conflict) {
return null
}
let noticeBody = 'Conflict file details are unavailable'
if (isRefreshing) {
noticeBody = 'Refreshing conflict details…'
} else if (conflict.localMergeClean) {
noticeBody =
'GitHub reports conflicts, but local Git did not reproduce them. Refresh the PR or push the branch to recalculate mergeability.'
}
const copyRefreshCommands = async () => {
if (!conflict.mergeabilityRefreshCommands) {
return
}
try {
await Clipboard.setStringAsync(conflict.mergeabilityRefreshCommands)
} catch {
return
}
if (copiedResetTimerRef.current) {
clearTimeout(copiedResetTimerRef.current)
}
setCommandsCopied(true)
copiedResetTimerRef.current = setTimeout(() => {
copiedResetTimerRef.current = null
setCommandsCopied(false)
}, 1500)
}
return (
<PRSection title="Conflicts">
@ -44,11 +83,35 @@ export function PRConflictingFilesSection({ pr, isRefreshing = false, triage }:
{conflict.fileDetailsUnavailable ? (
<View>
<Text style={styles.noticeTitle}>This branch has conflicts that must be resolved</Text>
<Text style={styles.noticeBody}>
{isRefreshing
? 'Refreshing conflict details…'
: 'Conflict file details are unavailable'}
</Text>
<Text style={styles.noticeBody}>{noticeBody}</Text>
{conflict.mergeabilityRefreshCommands ? (
<View style={styles.commandBox}>
<View style={styles.commandHeader}>
<Text style={styles.commandLabel}>Run from this worktree</Text>
<Pressable
style={({ pressed }) => [
styles.copyCommandButton,
pressed && styles.copyCommandButtonPressed
]}
onPress={() => void copyRefreshCommands()}
accessibilityRole="button"
accessibilityLabel="Copy mergeability refresh commands"
>
{commandsCopied ? (
<Check size={13} color={colors.textPrimary} strokeWidth={2.2} />
) : (
<Copy size={13} color={colors.textPrimary} strokeWidth={2.2} />
)}
<Text style={styles.copyCommandText}>
{commandsCopied ? 'Copied' : 'Copy commands'}
</Text>
</Pressable>
</View>
<Text selectable style={styles.commandText}>
{conflict.mergeabilityRefreshCommands}
</Text>
</View>
) : null}
</View>
) : (
<View>

View File

@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest'
import type { PRInfo } from '../../../../src/shared/types'
import { hasMergeConflicts, resolveConflictDisplay } from './pr-conflict-presentation'
import {
buildMergeabilityRefreshCommands,
hasMergeConflicts,
resolveConflictDisplay
} from './pr-conflict-presentation'
function pr(over: Partial<PRInfo>): PRInfo {
return {
@ -24,6 +28,16 @@ describe('hasMergeConflicts', () => {
})
describe('resolveConflictDisplay', () => {
it('builds safe mergeability refresh commands', () => {
expect(buildMergeabilityRefreshCommands()).toBe(
[
'git fetch origin',
'git commit --allow-empty --only -m "chore: refresh PR mergeability"',
'git push'
].join('\n')
)
})
it('returns null when there are no conflicts', () => {
expect(resolveConflictDisplay(pr({ mergeable: 'MERGEABLE' }))).toBeNull()
expect(resolveConflictDisplay(pr({ mergeable: 'UNKNOWN' }))).toBeNull()
@ -45,7 +59,9 @@ describe('resolveConflictDisplay', () => {
files: ['src/a.ts', 'src/b.ts'],
commitsBehind: 3,
baseCommit: 'abc1234',
fileDetailsUnavailable: false
fileDetailsUnavailable: false,
localMergeClean: false,
mergeabilityRefreshCommands: null
})
})
@ -55,7 +71,9 @@ describe('resolveConflictDisplay', () => {
files: [],
commitsBehind: null,
baseCommit: null,
fileDetailsUnavailable: true
fileDetailsUnavailable: true,
localMergeClean: false,
mergeabilityRefreshCommands: null
})
})
@ -68,4 +86,41 @@ describe('resolveConflictDisplay', () => {
)
expect(display?.fileDetailsUnavailable).toBe(true)
})
it('flags locally clean when GitHub reports a conflict that local git does not reproduce', () => {
const display = resolveConflictDisplay(
pr({
mergeable: 'CONFLICTING',
conflictSummary: {
baseRef: 'main',
baseCommit: 'x',
commitsBehind: 1,
files: [],
localMergeState: 'clean'
}
})
)
expect(display?.localMergeClean).toBe(true)
expect(display?.mergeabilityRefreshCommands).toContain('git fetch origin')
expect(display?.mergeabilityRefreshCommands).toContain('git commit --allow-empty --only')
})
it('does not interpolate shell-sensitive base refs into copyable commands', () => {
const display = resolveConflictDisplay(
pr({
mergeable: 'CONFLICTING',
conflictSummary: {
baseRef: 'release/$USER;echo unsafe',
baseCommit: 'x',
commitsBehind: 1,
files: [],
localMergeState: 'clean'
}
})
)
expect(display?.mergeabilityRefreshCommands).toContain('git fetch origin')
expect(display?.mergeabilityRefreshCommands).not.toContain('$USER')
expect(display?.mergeabilityRefreshCommands).not.toContain('echo unsafe')
})
})

View File

@ -14,6 +14,9 @@ export type ConflictDisplay = {
// True when conflicts exist but no file list is available — desktop shows a
// fallback notice instead of the file list in this case.
fileDetailsUnavailable: boolean
// True when the host reports conflicts but the local merge simulation is clean.
localMergeClean: boolean
mergeabilityRefreshCommands: string | null
}
// Conflicts exist only when the host reports CONFLICTING. Anything else (MERGEABLE
@ -22,6 +25,14 @@ export function hasMergeConflicts(pr: Pick<PRInfo, 'mergeable'>): boolean {
return pr.mergeable === 'CONFLICTING'
}
export function buildMergeabilityRefreshCommands(): string {
return [
'git fetch origin',
'git commit --allow-empty --only -m "chore: refresh PR mergeability"',
'git push'
].join('\n')
}
// Resolve the conflict view-model, or null when there is nothing to show. Returns
// a model both when files are listed AND when conflicts exist without a file list
// (so the section can render the fallback notice, matching desktop).
@ -32,10 +43,13 @@ export function resolveConflictDisplay(
return null
}
const files = pr.conflictSummary?.files ?? []
const localMergeClean = pr.conflictSummary?.localMergeState === 'clean'
return {
files,
commitsBehind: pr.conflictSummary?.commitsBehind ?? null,
baseCommit: pr.conflictSummary?.baseCommit ?? null,
fileDetailsUnavailable: files.length === 0
fileDetailsUnavailable: files.length === 0,
localMergeClean,
mergeabilityRefreshCommands: localMergeClean ? buildMergeabilityRefreshCommands() : null
}
}

View File

@ -56,5 +56,49 @@ export const prConflictStyles = StyleSheet.create({
color: colors.textSecondary,
fontSize: 11,
marginTop: spacing.xs
},
commandBox: {
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
backgroundColor: colors.bgRaised,
borderRadius: radii.button,
marginTop: spacing.sm,
padding: spacing.sm
},
commandHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.sm
},
commandLabel: {
color: colors.textSecondary,
fontSize: 10,
fontWeight: '600'
},
copyCommandButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
borderRadius: radii.button,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs
},
copyCommandButtonPressed: {
backgroundColor: colors.borderSubtle
},
copyCommandText: {
color: colors.textPrimary,
fontSize: 11,
fontWeight: '600'
},
commandText: {
color: colors.textPrimary,
fontFamily: typography.monoFamily,
fontSize: 10,
lineHeight: 15,
marginTop: spacing.sm
}
})

View File

@ -1632,6 +1632,42 @@ describe('getPRForBranch', () => {
expect(pr?.conflictSummary).toBeUndefined()
})
it('marks the conflict summary as locally clean when GitHub reports dirty but merge-tree has no conflicted files', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 42,
title: 'Fix PR discovery',
state: 'open',
html_url: 'https://github.com/acme/widgets/pull/42',
updated_at: '2026-06-20T22:16:43Z',
draft: false,
mergeable_state: 'dirty',
base: { ref: 'main', sha: 'base-oid' },
head: { ref: 'feature/test', sha: 'head-oid' }
}
])
})
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '' })
.mockResolvedValueOnce({ stdout: 'latest-base-oid\n' })
.mockResolvedValueOnce({ stdout: 'merge-base-oid\n' })
.mockResolvedValueOnce({ stdout: '1\n' })
.mockResolvedValueOnce({ stdout: 'result-tree-oid\u0000' })
const pr = await getPRForBranch('/repo-root', 'feature/test')
expect(pr?.mergeable).toBe('CONFLICTING')
expect(pr?.conflictSummary).toEqual({
baseRef: 'main',
baseCommit: 'latest-',
commitsBehind: 1,
files: [],
localMergeState: 'clean'
})
})
it('falls back to GitHub baseRefOid when fetching or resolving the base ref fails', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValueOnce({

View File

@ -37,7 +37,8 @@ export async function getPRConflictSummary(
baseRef: baseRefName,
baseCommit: latestBaseOid.slice(0, 7),
commitsBehind,
files
files,
...(files.length === 0 ? { localMergeState: 'clean' as const } : {})
}
} catch {
return undefined

View File

@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import type { PRCheckDetail, PRComment, PRInfo } from '../../../../shared/types'
import {
buildMergeabilityRecalculationCommands,
CheckJobLogTail,
ChecksList,
ConflictTriageStrip,
@ -41,6 +42,16 @@ function renderNotice(pr: PRInfo, isRefreshingConflictDetails = false): string {
}
describe('MergeConflictNotice', () => {
it('builds safe mergeability recalculation commands', () => {
expect(buildMergeabilityRecalculationCommands()).toBe(
[
'git fetch origin',
'git commit --allow-empty --only -m "chore: refresh PR mergeability"',
'git push'
].join('\n')
)
})
it('does not claim conflict details are refreshing after the refresh has settled', () => {
const markup = renderNotice(makePR())
@ -54,6 +65,47 @@ describe('MergeConflictNotice', () => {
expect(markup).toContain('Refreshing conflict details')
})
it('explains when the hosting provider reports conflicts but local git simulates a clean merge', () => {
const markup = renderNotice(
makePR({
conflictSummary: {
baseRef: 'main',
baseCommit: 'abc1234',
commitsBehind: 1,
files: [],
localMergeState: 'clean'
}
})
)
expect(markup).toContain('local Git did not reproduce them')
expect(markup).toContain('Run from this worktree')
expect(markup).toContain('hosting provider reports conflicts')
expect(markup).toContain('git fetch origin')
expect(markup).toContain('git commit --allow-empty --only')
expect(markup).toContain('git push')
expect(markup).toContain('Copy commands')
expect(markup).not.toContain('Conflict file details are unavailable')
})
it('does not interpolate shell-sensitive base refs into copyable commands', () => {
const markup = renderNotice(
makePR({
conflictSummary: {
baseRef: 'release/$USER;echo unsafe',
baseCommit: 'abc1234',
commitsBehind: 1,
files: [],
localMergeState: 'clean'
}
})
)
expect(markup).toContain('git fetch origin')
expect(markup).not.toContain('$USER')
expect(markup).not.toContain('echo unsafe')
})
it('hides when the conflicting file list is available', () => {
const markup = renderNotice(
makePR({

View File

@ -108,6 +108,14 @@ type ConflictReview = {
conflictSummary?: PRConflictSummary
}
export function buildMergeabilityRecalculationCommands(): string {
return [
'git fetch origin',
'git commit --allow-empty --only -m "chore: refresh PR mergeability"',
'git push'
].join('\n')
}
export function ConflictingFilesSection({ pr }: { pr: ConflictReview }): React.JSX.Element | null {
const files = pr.conflictSummary?.files ?? []
if (pr.mergeable !== 'CONFLICTING' || files.length === 0) {
@ -164,6 +172,23 @@ export function MergeConflictNotice({
if (pr.mergeable !== 'CONFLICTING' || (pr.conflictSummary?.files.length ?? 0) > 0) {
return null
}
const locallyClean = pr.conflictSummary?.localMergeState === 'clean'
let noticeBody = translate(
'auto.components.right.sidebar.checks.panel.content.ae8a04ef17',
'Conflict file details are unavailable'
)
if (isRefreshingConflictDetails) {
noticeBody = translate(
'auto.components.right.sidebar.checks.panel.content.73d0675356',
'Refreshing conflict details…'
)
} else if (locallyClean) {
noticeBody = translate(
'auto.components.right.sidebar.checks.panel.content.f5bc5c4cf1',
'The hosting provider reports conflicts, but local Git did not reproduce them. Refresh the review or push the branch to recalculate mergeability.'
)
}
const refreshCommands = locallyClean ? buildMergeabilityRecalculationCommands() : null
return (
<div className="border-t border-border px-3 py-3">
@ -173,17 +198,89 @@ export function MergeConflictNotice({
'This branch has conflicts that must be resolved'
)}
</div>
<div className="mt-1 text-[11px] text-muted-foreground">
{isRefreshingConflictDetails
? translate(
'auto.components.right.sidebar.checks.panel.content.73d0675356',
'Refreshing conflict details…'
)
: translate(
'auto.components.right.sidebar.checks.panel.content.ae8a04ef17',
'Conflict file details are unavailable'
)}
<div className="mt-1 text-[11px] text-muted-foreground">{noticeBody}</div>
{refreshCommands ? <MergeabilityRecalculationCommandBox commands={refreshCommands} /> : null}
</div>
)
}
function MergeabilityRecalculationCommandBox({
commands
}: {
commands: string
}): React.JSX.Element {
const [copied, setCopied] = useState(false)
const copiedResetTimerRef = useRef<number | null>(null)
const isMountedRef = useRef(false)
const clearCopiedResetTimer = useCallback((): void => {
if (copiedResetTimerRef.current !== null) {
window.clearTimeout(copiedResetTimerRef.current)
copiedResetTimerRef.current = null
}
}, [])
const setCopyButtonRef = useCallback(
(node: HTMLButtonElement | null) => {
isMountedRef.current = node !== null
if (node === null) {
clearCopiedResetTimer()
}
},
[clearCopiedResetTimer]
)
const copyCommands = useCallback((): void => {
void window.api.ui
.writeClipboardText(commands)
.then(() => {
if (!isMountedRef.current) {
return
}
clearCopiedResetTimer()
setCopied(true)
copiedResetTimerRef.current = window.setTimeout(() => {
copiedResetTimerRef.current = null
setCopied(false)
}, 1500)
})
.catch(() => {
/* best-effort */
})
}, [clearCopiedResetTimer, commands])
return (
<div className="mt-3 rounded-md border border-border bg-accent/20 p-2.5">
<div className="flex items-center justify-between gap-2">
<div className="text-[10px] font-medium text-muted-foreground">
{translate(
'auto.components.right.sidebar.checks.panel.content.5bc9bda2af',
'Run from this worktree'
)}
</div>
<Button
ref={setCopyButtonRef}
type="button"
variant="outline"
size="xs"
onClick={copyCommands}
aria-label={translate(
'auto.components.right.sidebar.checks.panel.content.e87fb3d929',
'Copy mergeability refresh commands'
)}
>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
{copied
? translate('auto.components.right.sidebar.checks.panel.content.1e53e45072', 'Copied')
: translate(
'auto.components.right.sidebar.checks.panel.content.084c516efb',
'Copy commands'
)}
</Button>
</div>
<pre className="scrollbar-sleek mt-2 max-h-28 overflow-auto whitespace-pre-wrap break-all rounded-md border border-border bg-background px-2 py-1.5 font-mono text-[10px] leading-4 text-foreground">
{commands}
</pre>
</div>
)
}

View File

@ -8795,6 +8795,11 @@
"066fedd446": "Failed jobs",
"ae8a04ef17": "Conflict file details are unavailable",
"73d0675356": "Refreshing conflict details…",
"f5bc5c4cf1": "The hosting provider reports conflicts, but local Git did not reproduce them. Refresh the review or push the branch to recalculate mergeability.",
"5bc9bda2af": "Run from this worktree",
"e87fb3d929": "Copy mergeability refresh commands",
"1e53e45072": "Copied",
"084c516efb": "Copy commands",
"5dc3af25c0": "Select comment",
"d7a2f9c401": "Send unresolved {{value0}} comments",
"d91f2a6c39": "Send {{value0}} queued comments",

View File

@ -2307,7 +2307,7 @@
"TerminalContextMenu": {
"b4cdd9314e": "Borrar pantalla",
"8c17d6786d": "Cerrar panel",
"copyTerminalId": "Copiar ID de terminal",
"copyTerminalId": "Copy Terminal ID",
"2cf85a6a55": "Copiar ID del panel",
"39809d152f": "Establecer título…",
"06c2b0f043": "Igualar tamaños de paneles",
@ -8794,6 +8794,11 @@
"066fedd446": "Trabajos fallidos",
"ae8a04ef17": "Los detalles del archivo de conflicto no están disponibles",
"73d0675356": "Detalles refrescantes del conflicto...",
"f5bc5c4cf1": "The hosting provider reports conflicts, but local Git did not reproduce them. Refresh the review or push the branch to recalculate mergeability.",
"5bc9bda2af": "Run from this worktree",
"e87fb3d929": "Copy mergeability refresh commands",
"1e53e45072": "Copied",
"084c516efb": "Copy commands",
"5dc3af25c0": "Seleccionar comentario",
"d7a2f9c401": "Send unresolved {{value0}} comments",
"d91f2a6c39": "Enviar {{value0}} comentarios en cola",

View File

@ -8794,6 +8794,11 @@
"066fedd446": "失敗したジョブ",
"ae8a04ef17": "競合ファイルの詳細は利用できません",
"73d0675356": "競合の詳細を更新しています…",
"f5bc5c4cf1": "The hosting provider reports conflicts, but local Git did not reproduce them. Refresh the review or push the branch to recalculate mergeability.",
"5bc9bda2af": "Run from this worktree",
"e87fb3d929": "Copy mergeability refresh commands",
"1e53e45072": "Copied",
"084c516efb": "Copy commands",
"5dc3af25c0": "コメントを選択",
"d7a2f9c401": "Send unresolved {{value0}} comments",
"d91f2a6c39": "キュー内の {{value0}} 件のコメントを送信",

View File

@ -8794,6 +8794,11 @@
"066fedd446": "실패한 작업",
"ae8a04ef17": "충돌 파일 세부정보를 사용할 수 없습니다.",
"73d0675356": "충돌 세부정보 새로고침 중…",
"f5bc5c4cf1": "The hosting provider reports conflicts, but local Git did not reproduce them. Refresh the review or push the branch to recalculate mergeability.",
"5bc9bda2af": "Run from this worktree",
"e87fb3d929": "Copy mergeability refresh commands",
"1e53e45072": "Copied",
"084c516efb": "Copy commands",
"5dc3af25c0": "댓글 선택",
"d7a2f9c401": "해결되지 않은 댓글 {{value0}}개 보내기",
"d91f2a6c39": "대기 중인 댓글 {{value0}}개 보내기",

View File

@ -2307,7 +2307,7 @@
"TerminalContextMenu": {
"b4cdd9314e": "清晰的屏幕",
"8c17d6786d": "关闭窗格",
"copyTerminalId": "复制终端 ID",
"copyTerminalId": "Copy Terminal ID",
"2cf85a6a55": "复制窗格 ID",
"39809d152f": "设置标题...",
"06c2b0f043": "均衡窗格大小",
@ -8794,6 +8794,11 @@
"066fedd446": "失败的工作",
"ae8a04ef17": "冲突文件详细信息不可用",
"73d0675356": "刷新冲突细节...",
"f5bc5c4cf1": "The hosting provider reports conflicts, but local Git did not reproduce them. Refresh the review or push the branch to recalculate mergeability.",
"5bc9bda2af": "Run from this worktree",
"e87fb3d929": "Copy mergeability refresh commands",
"1e53e45072": "Copied",
"084c516efb": "Copy commands",
"5dc3af25c0": "选择评论",
"d7a2f9c401": "Send unresolved {{value0}} comments",
"d91f2a6c39": "发送 {{value0}} 条已排队评论",

View File

@ -1067,6 +1067,7 @@ export type PRConflictSummary = {
baseCommit: string
commitsBehind: number
files: string[]
localMergeState?: 'clean'
}
export type GitHubRepositoryIdentity = { owner: string; repo: string }