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
+3 -3
View File
@@ -165,7 +165,7 @@ describe('getStatus', () => {
existsSyncMock.mockImplementation((target: string) => target.endsWith('MERGE_HEAD'))
execFileAsyncMock.mockResolvedValueOnce({
stdout:
'u UU N... 100644 100644 100644 100644 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb cccccccccccccccccccccccccccccccccccccccc\tsrc/app.ts\n'
'u UU N... 100644 100644 100644 100644 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb cccccccccccccccccccccccccccccccccccccccc src/app.ts\n'
})
const result = await getStatus('/repo')
@@ -187,7 +187,7 @@ describe('getStatus', () => {
existsSyncMock.mockReturnValue(false)
execFileAsyncMock.mockResolvedValueOnce({
stdout:
'u UD N... 100644 100644 000000 100644 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb cccccccccccccccccccccccccccccccccccccccc\tsrc/deleted.ts\n'
'u UD N... 100644 100644 000000 100644 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb cccccccccccccccccccccccccccccccccccccccc src/deleted.ts\n'
})
const result = await getStatus('/repo')
@@ -208,7 +208,7 @@ describe('getStatus', () => {
})
execFileAsyncMock.mockResolvedValueOnce({
stdout:
'u AU N... 100644 100644 100644 100644 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb cccccccccccccccccccccccccccccccccccccccc\tsrc/new.ts\n'
'u AU N... 100644 100644 100644 100644 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb cccccccccccccccccccccccccccccccccccccccc src/new.ts\n'
})
const result = await getStatus('/repo')
+27 -14
View File
@@ -130,18 +130,21 @@ async function parseUnmergedEntry(
worktreePath: string,
line: string
): Promise<GitStatusEntry | null> {
const tabIndex = line.indexOf('\t')
if (tabIndex === -1) {
return null
}
const metadata = line.slice(0, tabIndex)
const filePath = line.slice(tabIndex + 1)
const parts = metadata.split(' ')
// Why: porcelain v2 unmerged entries are fully space-separated (like type-1
// ordinary entries), NOT tab-separated. The format is:
// u <XY> <sub> <m1> <m2> <m3> <mW> <h1> <h2> <h3> <path>
// The path starts at field index 10 and may contain spaces, so we join the
// remaining fields. The earlier tab-based parsing silently dropped all
// unmerged entries because the tab was never present.
const parts = line.split(' ')
const xy = parts[1]
const modeStage1 = parts[3]
const modeStage2 = parts[4]
const modeStage3 = parts[5]
const filePath = parts.slice(10).join(' ')
if (!filePath) {
return null
}
// Why: submodule conflicts (mode 160000) are out of scope for v1.
// Presenting them with normal file-conflict UX would be misleading because
@@ -227,35 +230,45 @@ async function getConflictCompatibilityStatus(
// cleaned up by the time we check. In that case we fall back to 'unknown' for
// one poll cycle, which is acceptable. The renderer uses this to label the
// merge summary ("Merge conflicts" vs "Rebase conflicts" vs generic "Conflicts").
//
// Why we also check rebase-merge/ and rebase-apply/ directories: during a
// rebase, REBASE_HEAD only exists when the *current* step has a conflict.
// Between steps (or after resolving and before `git rebase --continue`), the
// rebase is still in progress but REBASE_HEAD is absent. The rebase-merge/ or
// rebase-apply/ directory persists for the entire rebase, so checking it
// catches the "rebase in progress, no conflicts on current step" case.
async function detectConflictOperation(worktreePath: string): Promise<GitConflictOperation> {
const gitDir = await resolveGitDir(worktreePath)
const mergeHead = path.join(gitDir, 'MERGE_HEAD')
const rebaseHead = path.join(gitDir, 'REBASE_HEAD')
const cherryPickHead = path.join(gitDir, 'CHERRY_PICK_HEAD')
const rebaseMergeDir = path.join(gitDir, 'rebase-merge')
const rebaseApplyDir = path.join(gitDir, 'rebase-apply')
let hasMergeHead = false
let hasRebaseHead = false
let hasCherryPickHead = false
let hasRebaseDir = false
try {
hasMergeHead = existsSync(mergeHead)
hasRebaseHead = existsSync(rebaseHead)
hasCherryPickHead = existsSync(cherryPickHead)
hasRebaseDir = existsSync(rebaseMergeDir) || existsSync(rebaseApplyDir)
} catch {
return 'unknown'
}
if (Number(hasMergeHead) + Number(hasRebaseHead) + Number(hasCherryPickHead) !== 1) {
return 'unknown'
}
if (hasMergeHead) {
return 'merge'
}
if (hasRebaseHead) {
if (hasRebaseHead || hasRebaseDir) {
return 'rebase'
}
return 'cherry-pick'
if (hasCherryPickHead) {
return 'cherry-pick'
}
return 'unknown'
}
async function resolveGitDir(worktreePath: string): Promise<string> {
+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()
}
}
@@ -1,5 +1,5 @@
import React from 'react'
import { CircleCheck, GitMerge, TriangleAlert, X } from 'lucide-react'
import { CircleCheck, GitMerge, RefreshCw, TriangleAlert, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import type { OpenFile } from '@/store/slices/editor'
@@ -16,7 +16,7 @@ export const CONFLICT_KIND_LABELS: Record<GitConflictKind, string> = {
}
export const CONFLICT_HINT_MAP: Record<GitConflictKind, string> = {
both_modified: 'Open and edit the final contents',
both_modified: 'Resolve the conflict markers',
both_added: 'Choose which version to keep, or combine them',
deleted_by_us: 'Decide whether to restore the file',
deleted_by_them: 'Decide whether to keep the file or accept deletion',
@@ -59,7 +59,11 @@ export function ConflictBanner({
{label} conflict · {CONFLICT_KIND_LABELS[conflict.conflictKind]}
</span>
</div>
<div className="mt-1 text-muted-foreground">{CONFLICT_HINT_MAP[conflict.conflictKind]}</div>
{/* Why: the hint is omitted here because the file is already open in the
editor below. Showing "Open and edit…" or similar would be confusing
when the user is already viewing/editing the working-tree contents.
The hint is still shown in ConflictPlaceholderView and the review
list where it provides actionable guidance. */}
{!isUnresolved && (
<div className="mt-1 text-muted-foreground">
Session-local continuity state. Git is no longer reporting this file as unmerged.
@@ -151,10 +155,8 @@ export function ConflictReviewPanel({
</div>
<div className="mt-2 flex items-center gap-2">
<Button type="button" size="sm" variant="outline" onClick={onRefreshSnapshot}>
Open latest conflicts
</Button>
<Button type="button" size="sm" variant="ghost" onClick={onReturnToSourceControl}>
Source Control
<RefreshCw className="size-3.5" />
Refresh
</Button>
</div>
</div>
@@ -3,7 +3,13 @@ import { LoaderCircle, ExternalLink, RefreshCw, Check, X, Pencil } from 'lucide-
import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
import PRActions from './PRActions'
import { PullRequestIcon, prStateColor, MergeConflictWarning, ChecksList } from './checks-helpers'
import {
PullRequestIcon,
prStateColor,
ConflictingFilesSection,
MergeConflictNotice,
ChecksList
} from './checks-helpers'
import type { PRInfo, PRCheckDetail } from '../../../../shared/types'
export default function ChecksPanel(): React.JSX.Element {
@@ -12,6 +18,7 @@ export default function ChecksPanel(): React.JSX.Element {
const repos = useAppStore((s) => s.repos)
const prCache = useAppStore((s) => s.prCache)
const fetchPRForBranch = useAppStore((s) => s.fetchPRForBranch)
const gitConflictOperationByWorktree = useAppStore((s) => s.gitConflictOperationByWorktree)
const fetchPRChecks = useAppStore((s) => s.fetchPRChecks)
@@ -26,6 +33,7 @@ export default function ChecksPanel(): React.JSX.Element {
const pollRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pollIntervalRef = useRef(30_000) // start at 30s, backs off to 120s
const prevChecksRef = useRef<string>('')
const conflictSummaryRefreshKeyRef = useRef<string | null>(null)
// Find active worktree and repo
const { worktree, repo } = useMemo(() => {
@@ -46,6 +54,9 @@ export default function ChecksPanel(): React.JSX.Element {
const prCacheKey = repo && branch ? `${repo.path}::${branch}` : ''
const pr: PRInfo | null = prCacheKey ? (prCache[prCacheKey]?.data ?? null) : null
const prNumber = pr?.number ?? null
const conflictOperation = activeWorktreeId
? (gitConflictOperationByWorktree[activeWorktreeId] ?? 'unknown')
: 'unknown'
// Fetch PR data when the active worktree/branch changes
useEffect(() => {
@@ -54,6 +65,25 @@ export default function ChecksPanel(): React.JSX.Element {
}
}, [repo, branch, fetchPRForBranch])
useEffect(() => {
if (!repo || !branch || !pr || pr.mergeable !== 'CONFLICTING') {
conflictSummaryRefreshKeyRef.current = null
return
}
const refreshKey = `${repo.path}::${branch}::${pr.number}`
if (conflictSummaryRefreshKeyRef.current === refreshKey) {
return
}
// Why: the checks panel is the one place where stale conflict metadata is
// visibly wrong. Force-refresh conflicting PRs once when the panel sees
// them so we don't keep rendering cached branch summaries or empty file
// lists from an older payload.
conflictSummaryRefreshKeyRef.current = refreshKey
void fetchPRForBranch(repo.path, branch, { force: true })
}, [repo, branch, pr, fetchPRForBranch])
// Fetch checks via cached store method
const fetchChecks = useCallback(
async ({
@@ -211,27 +241,47 @@ export default function ChecksPanel(): React.JSX.Element {
}
if (!pr) {
// Why: during a rebase/merge/cherry-pick the worktree is on a detached
// HEAD, so there is no branch to look up a PR for. Showing "No pull
// request found" is misleading — the PR still exists on the original
// branch. Show an operation-aware message instead.
const operationInProgress = conflictOperation !== 'unknown'
const operationLabel =
conflictOperation === 'rebase'
? 'Rebase'
: conflictOperation === 'merge'
? 'Merge'
: conflictOperation === 'cherry-pick'
? 'Cherry-pick'
: null
return (
<div className="px-4 py-6">
<div className="text-sm font-medium text-foreground">No pull request found</div>
<div className="mt-1 text-xs text-muted-foreground">
Push your branch and open a PR to see checks here
<div className="text-sm font-medium text-foreground">
{operationInProgress ? `${operationLabel} in progress` : 'No pull request found'}
</div>
<button
className="mt-3 px-3 py-1 text-xs font-medium rounded-md border border-border bg-accent/50 text-foreground hover:bg-accent transition-colors disabled:opacity-50"
disabled={emptyRefreshing}
onClick={() => {
if (!activeWorktreeId) {
return
}
setEmptyRefreshing(true)
void handleRefresh().finally(() => {
setEmptyRefreshing(false)
})
}}
>
{emptyRefreshing ? 'Refreshing…' : 'Refresh'}
</button>
<div className="mt-1 text-xs text-muted-foreground">
{operationInProgress
? 'PR checks will be available after the operation completes'
: 'Push your branch and open a PR to see checks here'}
</div>
{!operationInProgress && (
<button
className="mt-3 px-3 py-1 text-xs font-medium rounded-md border border-border bg-accent/50 text-foreground hover:bg-accent transition-colors disabled:opacity-50"
disabled={emptyRefreshing}
onClick={() => {
if (!activeWorktreeId) {
return
}
setEmptyRefreshing(true)
void handleRefresh().finally(() => {
setEmptyRefreshing(false)
})
}}
>
{emptyRefreshing ? 'Refreshing…' : 'Refresh'}
</button>
)}
</div>
)
}
@@ -319,17 +369,20 @@ export default function ChecksPanel(): React.JSX.Element {
</div>
)}
{/* Merge conflict warning — surfaced from GitHub's mergeable state so the
user knows they must resolve conflicts before the PR can merge. */}
<MergeConflictWarning mergeable={pr.mergeable} />
{/* Merge / Delete Worktree actions */}
{worktree && repo && (
<PRActions pr={pr} repo={repo} worktree={worktree} onRefreshPR={handleRefreshPR} />
)}
</div>
<ChecksList checks={checks} checksLoading={checksLoading} />
<ConflictingFilesSection pr={pr} />
<MergeConflictNotice pr={pr} />
{/* Why: when the PR has merge conflicts and no checks have been fetched,
showing "No checks configured" is misleading — checks may exist but
simply cannot run until conflicts are resolved. Hide the empty state. */}
{!(pr.mergeable === 'CONFLICTING' && checks.length === 0 && !checksLoading) && (
<ChecksList checks={checks} checksLoading={checksLoading} />
)}
</div>
)
}
@@ -3,6 +3,7 @@ import { LoaderCircle, GitMerge, ChevronDown, Trash2 } from 'lucide-react'
import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'
import type { PRInfo, Repo, Worktree } from '../../../../shared/types'
const MERGE_METHODS = ['squash', 'merge', 'rebase'] as const
@@ -72,58 +73,84 @@ export default function PRActions({
openModal('delete-worktree', { worktreeId: worktree.id })
}, [worktree.id, openModal])
// Why: merging a PR with unresolved conflicts would fail on GitHub anyway;
// disabling the button prevents a confusing error and signals the user must
// resolve conflicts first.
const hasConflicts = pr.mergeable === 'CONFLICTING'
if (pr.state === 'open') {
return (
<div className="space-y-1.5">
<div className="relative flex items-stretch" ref={mergeMenuRef}>
<Button
type="button"
size="xs"
className={cn(
'flex-1 rounded-r-none px-3 text-[11px]',
'bg-green-600 text-white hover:bg-green-700',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
onClick={() => void handleMerge('squash')}
disabled={merging}
>
{merging ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : (
<GitMerge className="size-3.5" />
)}
{merging ? 'Merging\u2026' : 'Squash and merge'}
</Button>
<Button
type="button"
size="xs"
className={cn(
'rounded-l-none border-l border-green-700/50 px-1.5',
'bg-green-600 text-white hover:bg-green-700',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
onClick={() => setMergeMenuOpen((v) => !v)}
disabled={merging}
>
<ChevronDown className="size-3.5" />
</Button>
{mergeMenuOpen && (
<div className="absolute top-full left-0 right-0 mt-1 z-50 rounded-md border border-border bg-popover shadow-md overflow-hidden">
{MERGE_METHODS.map((method) => (
<Button
key={method}
type="button"
variant="ghost"
size="xs"
className="h-auto w-full justify-start rounded-none px-3 py-1 text-left text-[11px]"
onClick={() => void handleMerge(method)}
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
{/* Why: wrapping in a <span> so the tooltip trigger receives pointer
events even when the buttons inside are disabled. */}
<span className={cn(hasConflicts && 'cursor-not-allowed')}>
<div
className={cn(
'relative flex items-stretch',
hasConflicts && 'pointer-events-none'
)}
ref={mergeMenuRef}
>
{MERGE_LABELS[method]}
</Button>
))}
</div>
)}
</div>
<Button
type="button"
size="xs"
className={cn(
'flex-1 rounded-r-none px-3 text-[11px]',
'bg-green-600 text-white hover:bg-green-700',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
onClick={() => void handleMerge('squash')}
disabled={merging || hasConflicts}
>
{merging ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : (
<GitMerge className="size-3.5" />
)}
{merging ? 'Merging\u2026' : 'Squash and merge'}
</Button>
<Button
type="button"
size="xs"
className={cn(
'rounded-l-none border-l border-green-700/50 px-1.5',
'bg-green-600 text-white hover:bg-green-700',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
onClick={() => setMergeMenuOpen((v) => !v)}
disabled={merging || hasConflicts}
>
<ChevronDown className="size-3.5" />
</Button>
{mergeMenuOpen && (
<div className="absolute top-full left-0 right-0 mt-1 z-50 rounded-md border border-border bg-popover shadow-md overflow-hidden">
{MERGE_METHODS.map((method) => (
<Button
key={method}
type="button"
variant="ghost"
size="xs"
className="h-auto w-full justify-start rounded-none px-3 py-1 text-left text-[11px]"
onClick={() => void handleMerge(method)}
>
{MERGE_LABELS[method]}
</Button>
))}
</div>
)}
</div>
</span>
</TooltipTrigger>
{hasConflicts && (
<TooltipContent side="bottom" sideOffset={4}>
Merge conflicts must be resolved before merging
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
{mergeError && <div className="text-[10px] text-rose-500 break-words">{mergeError}</div>}
</div>
)
@@ -15,6 +15,7 @@ import {
ArrowRightLeft,
FolderOpen,
GitMerge,
GitPullRequestArrow,
TriangleAlert,
CircleCheck
} from 'lucide-react'
@@ -63,7 +64,11 @@ const STATUS_ICONS: Record<
copied: FilePlus
}
const SECTION_ORDER = ['staged', 'unstaged', 'untracked'] as const
// Why: unstaged ("Changes") is listed first so that conflict files — which
// are assigned area:'unstaged' by the parser — appear above "Staged Changes".
// This keeps unresolved conflicts visible at the top of the list where the
// user won't miss them.
const SECTION_ORDER = ['unstaged', 'staged', 'untracked'] as const
const SECTION_LABELS: Record<(typeof SECTION_ORDER)[number], string> = {
staged: 'Staged Changes',
unstaged: 'Changes',
@@ -82,20 +87,6 @@ const CONFLICT_KIND_LABELS: Record<GitConflictKind, string> = {
both_deleted: 'Both deleted'
}
// Why: hint text is derived at render time in the renderer, not returned by the
// main process on GitUncommittedEntry. This keeps UI copy out of the IPC layer
// and the main-process parser. See also ConflictComponents.tsx for the editor-
// side copy of this map.
const CONFLICT_HINT_MAP: Record<GitConflictKind, string> = {
both_modified: 'Open and edit the final contents',
both_added: 'Choose which version to keep, or combine them',
deleted_by_us: 'Decide whether to restore the file',
deleted_by_them: 'Decide whether to keep the file or accept deletion',
added_by_us: 'Review whether to keep the added file',
added_by_them: 'Review the added file before keeping it',
both_deleted: 'Resolve in Git or restore one side before editing'
}
export default function SourceControl(): React.JSX.Element {
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const rightSidebarTab = useAppStore((s) => s.rightSidebarTab)
@@ -473,6 +464,15 @@ export default function SourceControl(): React.JSX.Element {
/>
</div>
)}
{/* Why: show operation banner when rebase/merge/cherry-pick is in progress
but there are no unresolved conflicts (e.g. between rebase steps, or
after resolving all conflicts before running --continue). The
ConflictSummaryCard handles the "has conflicts" case above. */}
{unresolvedConflictReviewEntries.length === 0 && conflictOperation !== 'unknown' && (
<div className="px-3 pb-2">
<OperationBanner conflictOperation={conflictOperation} />
</div>
)}
{scope === 'all' && showGenericEmptyState ? (
<EmptyState
@@ -764,7 +764,7 @@ function SectionHeader({
actions?: React.ReactNode
}): React.JSX.Element {
return (
<div className="group/section flex items-center pl-1 pr-3 py-1">
<div className="group/section flex items-center pl-1 pr-3 pt-3 pb-1">
<button
type="button"
className="flex flex-1 items-center gap-1 rounded-md px-0.5 py-0.5 text-left text-xs font-semibold uppercase tracking-wider text-foreground/70 hover:bg-accent hover:text-accent-foreground"
@@ -805,9 +805,9 @@ function ConflictSummaryCard({
: 'Conflicts'
return (
<div className="rounded-md border border-destructive/25 bg-destructive/5 px-3 py-2">
<div className="rounded-md border border-amber-500/25 bg-amber-500/5 px-3 py-2">
<div className="flex items-start gap-2">
<TriangleAlert className="mt-0.5 size-4 shrink-0 text-destructive" />
<TriangleAlert className="mt-0.5 size-4 shrink-0 text-amber-600 dark:text-amber-400" />
<div className="min-w-0 flex-1">
<div
className="text-xs font-medium text-foreground"
@@ -817,11 +817,13 @@ function ConflictSummaryCard({
Resolved files move back to normal changes after they leave the live conflict state.
</div>
</div>
</div>
<div className="mt-2">
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs"
className="h-7 text-xs w-full"
onClick={onReview}
>
<GitMerge className="size-3.5" />
@@ -832,6 +834,37 @@ function ConflictSummaryCard({
)
}
// Why: this banner is separate from ConflictSummaryCard because a rebase (or
// merge/cherry-pick) can be in progress without any conflicts — e.g. between
// rebase steps, or after resolving all conflicts but before --continue. The
// user needs to see the operation state so they know the worktree is mid-rebase
// and that they should run `git rebase --continue` or `--abort`.
function OperationBanner({
conflictOperation
}: {
conflictOperation: GitConflictOperation
}): React.JSX.Element {
const label =
conflictOperation === 'merge'
? 'Merge in progress'
: conflictOperation === 'rebase'
? 'Rebase in progress'
: conflictOperation === 'cherry-pick'
? 'Cherry-pick in progress'
: 'Operation in progress'
const Icon = conflictOperation === 'rebase' ? GitPullRequestArrow : GitMerge
return (
<div className="rounded-md border border-amber-500/25 bg-amber-500/5 px-3 py-2">
<div className="flex items-center gap-2">
<Icon className="size-4 shrink-0 text-amber-600 dark:text-amber-400" />
<span className="text-xs font-medium text-foreground">{label}</span>
</div>
</div>
)
}
function UncommittedEntryRow({
entry,
worktreePath,
@@ -856,7 +889,9 @@ function UncommittedEntryRow({
const isUnresolvedConflict = entry.conflictStatus === 'unresolved'
const isResolvedLocally = entry.conflictStatus === 'resolved_locally'
const conflictLabel = entry.conflictKind ? CONFLICT_KIND_LABELS[entry.conflictKind] : null
const conflictHint = entry.conflictKind ? CONFLICT_HINT_MAP[entry.conflictKind] : null
// Why: the hint text ("Open and edit…", "Decide whether to…") was removed
// from the sidebar because it's not actionable here — the user can only
// click the row, and the conflict-kind label alone is sufficient context.
// Why: Stage is suppressed for unresolved conflicts because `git add` would
// immediately erase the `u` record — the only live conflict signal in the
// sidebar — before the user has actually reviewed the file. The user should
@@ -901,10 +936,8 @@ function UncommittedEntryRow({
<span className="truncate text-[11px] text-muted-foreground">{dirPath}</span>
)}
</div>
{conflictLabel && conflictHint && (
<div className="truncate text-[11px] text-muted-foreground">
{conflictLabel} · {conflictHint}
</div>
{conflictLabel && (
<div className="truncate text-[11px] text-muted-foreground">{conflictLabel}</div>
)}
</div>
{entry.conflictStatus ? (
@@ -5,11 +5,11 @@ import {
CircleDashed,
CircleMinus,
GitPullRequest,
AlertTriangle
Files
} from 'lucide-react'
import { ExternalLink } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { PRInfo, PRMergeableState, PRCheckDetail } from '../../../../shared/types'
import type { PRInfo, PRCheckDetail } from '../../../../shared/types'
export const PullRequestIcon = GitPullRequest
@@ -33,26 +33,51 @@ export const CHECK_COLOR: Record<string, string> = {
timed_out: 'text-rose-500'
}
/** Shown when GitHub reports the PR branch has merge conflicts. */
export function MergeConflictWarning({
mergeable
}: {
mergeable: PRMergeableState
}): React.JSX.Element | null {
if (mergeable !== 'CONFLICTING') {
export function ConflictingFilesSection({ pr }: { pr: PRInfo }): React.JSX.Element | null {
const files = pr.conflictSummary?.files ?? []
if (pr.mergeable !== 'CONFLICTING' || files.length === 0) {
return null
}
return (
<div className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0 text-amber-500" />
<div>
<div className="text-[11px] font-medium text-foreground">
This branch has conflicts that must be resolved before merging
</div>
<div className="mt-0.5 text-[10px] text-muted-foreground">
Resolve conflicts on GitHub or locally, then push
</div>
<div className="border-t border-border px-3 py-3">
<div className="text-[11px] font-medium text-foreground">
This branch has conflicts that must be resolved
</div>
<div className="mt-1 text-[11px] text-muted-foreground">
It&apos;s {pr.conflictSummary!.commitsBehind} commit
{pr.conflictSummary!.commitsBehind === 1 ? '' : 's'} behind (base commit:{' '}
<span className="font-mono text-[10px]">{pr.conflictSummary!.baseCommit}</span>)
</div>
<div className="mt-2 flex items-center gap-2">
<Files className="size-3.5 shrink-0 text-muted-foreground" />
<div className="text-[11px] text-muted-foreground">Conflicting files</div>
</div>
<div className="mt-2 space-y-2">
{files.map((filePath) => (
<div key={filePath} className="rounded-md border border-border bg-accent/20 px-2.5 py-2">
<div className="break-all font-mono text-[11px] leading-4 text-foreground">
{filePath}
</div>
</div>
))}
</div>
</div>
)
}
/** Fallback shown when GitHub reports merge conflicts but no file list is available yet. */
export function MergeConflictNotice({ pr }: { pr: PRInfo }): React.JSX.Element | null {
if (pr.mergeable !== 'CONFLICTING' || (pr.conflictSummary?.files.length ?? 0) > 0) {
return null
}
return (
<div className="border-t border-border px-3 py-3">
<div className="text-[11px] font-medium text-foreground">
This branch has conflicts that must be resolved
</div>
<div className="mt-1 text-[11px] text-muted-foreground">Refreshing conflict details</div>
</div>
)
}
@@ -4,7 +4,7 @@ import { useAppStore } from '@/store'
import { Badge } from '@/components/ui/badge'
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { Bell, LoaderCircle, CircleDot, CircleCheck, CircleX } from 'lucide-react'
import { Bell, GitMerge, LoaderCircle, CircleDot, CircleCheck, CircleX } from 'lucide-react'
import StatusIndicator from './StatusIndicator'
import WorktreeContextMenu from './WorktreeContextMenu'
import { cn } from '@/lib/utils'
@@ -16,6 +16,7 @@ import type {
IssueInfo,
PRState,
CheckStatus,
GitConflictOperation,
TerminalTab
} from '../../../../shared/types'
import type { Status } from './StatusIndicator'
@@ -47,6 +48,12 @@ function checksLabel(status: CheckStatus): string {
}
}
const CONFLICT_OPERATION_LABELS: Record<Exclude<GitConflictOperation, 'unknown'>, string> = {
merge: 'Merging',
rebase: 'Rebasing',
'cherry-pick': 'Cherry-picking'
}
// ── Stable empty array for tabs fallback ─────────────────────────
const EMPTY_TABS: TerminalTab[] = []
@@ -121,6 +128,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
)
const deleteState = useAppStore((s) => s.deleteStateByWorktreeId[worktree.id])
const conflictOperation = useAppStore((s) => s.gitConflictOperationByWorktree[worktree.id])
// ── GRANULAR selectors: only subscribe to THIS worktree's data ──
const tabs = useAppStore((s) => s.tabsByWorktree[worktree.id] ?? EMPTY_TABS)
@@ -323,6 +331,20 @@ const WorktreeCard = React.memo(function WorktreeCard({
{branch}
</span>
)}
{/* Why: the conflict operation (merge/rebase/cherry-pick) is the
only signal that the worktree is in an incomplete operation state.
Showing it on the card lets the user spot worktrees that need
attention without switching to them first. */}
{conflictOperation && conflictOperation !== 'unknown' && (
<Badge
variant="outline"
className="h-[16px] px-1.5 text-[10px] font-medium rounded shrink-0 gap-1 text-amber-600 border-amber-500/30 bg-amber-500/5 dark:text-amber-400 dark:border-amber-400/30 dark:bg-amber-400/5 leading-none"
>
<GitMerge className="size-2.5" />
{CONFLICT_OPERATION_LABELS[conflictOperation]}
</Badge>
)}
</div>
{/* Meta section: Issue / PR Links / Comment */}
+8
View File
@@ -97,6 +97,13 @@ export type CheckStatus = 'pending' | 'success' | 'failure' | 'neutral'
export type PRMergeableState = 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'
export type PRConflictSummary = {
baseRef: string
baseCommit: string
commitsBehind: number
files: string[]
}
export type PRInfo = {
number: number
title: string
@@ -105,6 +112,7 @@ export type PRInfo = {
checksStatus: CheckStatus
updatedAt: string
mergeable: PRMergeableState
conflictSummary?: PRConflictSummary
}
export type PRCheckDetail = {