diff --git a/src/main/source-control/hosted-review-creation.test.ts b/src/main/source-control/hosted-review-creation.test.ts index baf3e919da..ee0b97e8e1 100644 --- a/src/main/source-control/hosted-review-creation.test.ts +++ b/src/main/source-control/hosted-review-creation.test.ts @@ -121,6 +121,9 @@ describe('createHostedReview', () => { if (args[0] === 'log') { return { stdout: '- Feature title\n', stderr: '' } } + if (args[0] === 'rev-list' && args.includes('--count')) { + return { stdout: '1\n', stderr: '' } + } return { stdout: '', stderr: '' } }) createGitHubPullRequestMock.mockResolvedValue({ @@ -153,6 +156,41 @@ describe('createHostedReview', () => { expect(createGitHubPullRequestMock).not.toHaveBeenCalled() }) + it('revalidates committed branch work before creating a GitHub pull request', async () => { + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'rev-parse') { + return { stdout: 'feature\n', stderr: '' } + } + if (args[0] === 'status') { + return { stdout: '', stderr: '' } + } + if (args[0] === 'log' && args.includes('--pretty=%s')) { + return { stdout: 'Feature title\n', stderr: '' } + } + if (args[0] === 'log') { + return { stdout: '\n', stderr: '' } + } + if (args[0] === 'rev-list' && args.includes('--count')) { + return { stdout: '0\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + + await expect( + createHostedReview('/repo', { + provider: 'github', + base: 'main', + head: 'feature', + title: 'Feature' + }) + ).resolves.toEqual({ + ok: false, + code: 'validation', + error: 'Create PR failed: commit changes before creating a pull request.' + }) + expect(createGitHubPullRequestMock).not.toHaveBeenCalled() + }) + it('rejects creation when the selected head is no longer checked out', async () => { gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { if (args[0] === 'rev-parse') { @@ -204,9 +242,12 @@ describe('createHostedReview', () => { if (args[0] === 'rev-parse' && args[2] === 'HEAD@{u}') { return { stdout: 'origin/feature\n', stderr: '' } } - if (args[0] === 'rev-list') { + if (args[0] === 'rev-list' && args.includes('--left-right')) { return { stdout: '0 0\n', stderr: '' } } + if (args[0] === 'rev-list' && args.includes('--count')) { + return { stdout: '1\n', stderr: '' } + } if (args[0] === 'log' && args.includes('--pretty=%s')) { return { stdout: 'Feature title\n', stderr: '' } } @@ -296,7 +337,18 @@ describe('getHostedReviewCreationEligibility', () => { mockGitHubProvider() getHostedReviewForBranchMock.mockResolvedValue(null) ghExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) - gitExecFileAsyncMock.mockResolvedValue({ stdout: 'Feature title\n', stderr: '' }) + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'log' && args.includes('--pretty=%s')) { + return { stdout: 'Feature title\n', stderr: '' } + } + if (args[0] === 'log') { + return { stdout: '- Feature title\n', stderr: '' } + } + if (args[0] === 'rev-list') { + return { stdout: '1\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) }) it('treats short remote base refs as the default branch name', async () => { @@ -356,8 +408,46 @@ describe('getHostedReviewCreationEligibility', () => { defaultBaseRef: 'origin/main', head: 'feature/create-pr', title: 'Feature title', - body: 'Feature title' + body: '- Feature title', + hasCommittedChanges: true + }) + }) + + it('counts committed review work against the remote-tracking base when only a short base is provided', async () => { + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'log' && args.includes('--pretty=%s')) { + return { stdout: 'Feature title\n', stderr: '' } + } + if (args[0] === 'log') { + return { stdout: '', stderr: '' } + } + if (args[0] === 'rev-list' && args.includes('origin/main..HEAD')) { + return { stdout: '1\n', stderr: '' } + } + if (args[0] === 'rev-list') { + throw new Error(`Unexpected rev-list candidate: ${args.join(' ')}`) + } + return { stdout: '', stderr: '' } }) + + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/create-pr', + base: 'main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + canCreate: true, + hasCommittedChanges: true + }) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['rev-list', '--count', 'origin/main..HEAD'], + { cwd: '/repo' } + ) }) it('resolves remote eligibility through SSH repo metadata', async () => { @@ -369,6 +459,9 @@ describe('getHostedReviewCreationEligibility', () => { if (args[0] === 'log') { return { stdout: '- Remote title\n', stderr: '' } } + if (args[0] === 'rev-list') { + return { stdout: '1\n', stderr: '' } + } return { stdout: '', stderr: '' } }) } @@ -389,7 +482,8 @@ describe('getHostedReviewCreationEligibility', () => { provider: 'github', canCreate: true, title: 'Remote title', - body: '- Remote title' + body: '- Remote title', + hasCommittedChanges: true }) expect(getProjectSlugMock).toHaveBeenCalledWith('/remote/repo', 'ssh-1') @@ -418,6 +512,39 @@ describe('getHostedReviewCreationEligibility', () => { }) }) + it('reports no committed review changes when the branch matches the base', async () => { + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'log' && args.includes('--pretty=%s')) { + return { stdout: 'Feature title\n', stderr: '' } + } + if (args[0] === 'log') { + return { stdout: '\n', stderr: '' } + } + if (args[0] === 'rev-list') { + return { stdout: '0\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/no-delta', + base: 'origin/main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + canCreate: false, + blockedReason: 'no_committed_changes', + nextAction: 'commit', + body: null, + hasCommittedChanges: false + }) + }) + it('blocks unsupported providers before GitHub authentication checks', async () => { getProjectSlugMock.mockResolvedValue({ host: 'gitlab.com', path: 'acme/orca' }) getRepoSlugMock.mockResolvedValue(null) diff --git a/src/main/source-control/hosted-review-creation.ts b/src/main/source-control/hosted-review-creation.ts index bb67ac768e..382fad745c 100644 --- a/src/main/source-control/hosted-review-creation.ts +++ b/src/main/source-control/hosted-review-creation.ts @@ -138,6 +138,44 @@ async function getCommitSummaryBody( } } +async function hasCommittedChangesForReview( + repoPath: string, + base: string | null, + connectionId?: string | null +): Promise { + if (!base) { + return false + } + + const normalizedBase = normalizeHostedReviewBaseRef(base) + const candidates = new Set() + if (!base.startsWith('refs/') && !base.includes('/')) { + // Why: submit preflight receives provider base names like "main", but a + // local checkout may only have the remote-tracking ref available. + candidates.add(`origin/${base}`) + candidates.add(`upstream/${base}`) + } + candidates.add(base) + candidates.add(normalizedBase) + + for (const candidate of candidates) { + try { + const { stdout } = await runGitForHostedReview( + repoPath, + ['rev-list', '--count', `${candidate}..HEAD`], + connectionId + ) + const count = Number.parseInt(stdout.trim(), 10) + if (Number.isFinite(count)) { + return count > 0 + } + } catch { + // Try the next plausible local spelling of the provider base branch. + } + } + return false +} + async function getDefaultBaseRef( repoPath: string, connectionId?: string | null @@ -243,6 +281,11 @@ const blockedCreateResultByReason = { code: 'validation', error: 'Create PR failed: choose a feature branch before creating a pull request.' }, + no_committed_changes: { + ok: false, + code: 'validation', + error: 'Create PR failed: commit changes before creating a pull request.' + }, no_upstream: { ok: false, code: 'validation', @@ -355,16 +398,20 @@ export async function getHostedReviewCreationEligibility( connectionId: args.connectionId ?? null }) - const title = - (await getLatestCommitSubject(args.repoPath, args.connectionId)) ?? branchToTitle(branch) - const body = await getCommitSummaryBody(args.repoPath, defaultBaseRef ?? null, args.connectionId) + const [latestCommitSubject, body, hasCommittedChanges] = await Promise.all([ + getLatestCommitSubject(args.repoPath, args.connectionId), + getCommitSummaryBody(args.repoPath, defaultBaseRef ?? null, args.connectionId), + hasCommittedChangesForReview(args.repoPath, defaultBaseRef ?? null, args.connectionId) + ]) + const title = latestCommitSubject ?? branchToTitle(branch) const baseResult = { provider, review: review ? { number: review.number, url: review.url } : null, defaultBaseRef, head: branch || null, title, - body + body, + hasCommittedChanges } if (!branch || branch === 'HEAD') { @@ -392,6 +439,16 @@ export async function getHostedReviewCreationEligibility( if (args.hasUncommittedChanges) { return { ...baseResult, canCreate: false, blockedReason: 'dirty', nextAction: 'commit' } } + // Why: every PR creation entry point consumes canCreate; keeping the + // no-delta rule here prevents renderer and main-process preflight drift. + if (baseBranch && !hasCommittedChanges) { + return { + ...baseResult, + canCreate: false, + blockedReason: 'no_committed_changes', + nextAction: 'commit' + } + } if (args.hasUpstream === false) { return { ...baseResult, canCreate: false, blockedReason: 'no_upstream', nextAction: 'publish' } } diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index ec4895d2c5..450beea522 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -1150,7 +1150,7 @@ export default function ChecksPanel(): React.JSX.Element { const isPausedPRRefresh = prRefreshState?.status === 'paused' const isErroredPRRefresh = prRefreshState?.status === 'error' - const canCreate = hostedReviewCreation?.canCreate + const canCreate = hostedReviewCreation?.canCreate === true const canPushCreate = hostedReviewCreation?.blockedReason === 'needs_push' return ( <> @@ -1191,9 +1191,11 @@ export default function ChecksPanel(): React.JSX.Element { ? 'Refreshing GitHub status for this branch' : isPausedPRRefresh ? 'GitHub refresh is paused by the current rate-limit budget' - : canPushCreate - ? 'Push your branch before creating a pull request.' - : 'Create a pull request to start checks and review.'} + : hostedReviewCreation?.blockedReason === 'no_committed_changes' + ? 'Commit changes before creating a pull request.' + : canPushCreate + ? 'Push your branch before creating a pull request.' + : 'Create a pull request to start checks and review.'} {!operationInProgress && (
diff --git a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts index d59cfa99c2..6d3378e8f8 100644 --- a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts @@ -373,4 +373,26 @@ describe('resolveDropdownItems', () => { expect(byKind.push_create_pr.label).toBe('Push before PR') expect(byKind.push_create_pr.disabled).toBe(false) }) + + it('keeps PR creation disabled when there is no committed branch work', () => { + const items = resolveDropdownItems( + inputs({ + upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }, + hostedReviewCreation: { + provider: 'github', + review: null, + canCreate: false, + blockedReason: 'no_committed_changes', + nextAction: 'commit', + hasCommittedChanges: false + } + }) + ) + const byKind = Object.fromEntries( + items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e]) + ) + expect(byKind.create_pr.disabled).toBe(true) + expect(byKind.create_pr.hint).toBe('Commit changes first') + expect(byKind.push_create_pr.disabled).toBe(true) + }) }) diff --git a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts index 7b0d908973..3ac46342b6 100644 --- a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts +++ b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts @@ -320,6 +320,8 @@ export function resolveDropdownItems(inputs: DropdownActionInputs): DropdownEntr return 'Check out a branch first' case 'default_branch': return 'Switch to a feature branch' + case 'no_committed_changes': + return 'Commit changes first' case 'no_upstream': return 'Publish Branch' case 'needs_push': diff --git a/src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts b/src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts index b33bf36958..c0f94ea3cf 100644 --- a/src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts @@ -408,4 +408,26 @@ describe('resolvePrimaryAction', () => { disabled: false }) }) + + it('keeps the primary disabled when review creation has no committed branch work', () => { + const result = resolvePrimaryAction( + inputs({ + upstreamStatus: upstreamInSync, + hostedReviewCreation: { + provider: 'github', + review: null, + canCreate: false, + blockedReason: 'no_committed_changes', + nextAction: 'commit', + hasCommittedChanges: false + } + }) + ) + expect(result).toEqual({ + kind: 'commit', + label: 'Commit', + title: 'Nothing to commit. Branch is up to date.', + disabled: true + }) + }) }) diff --git a/src/shared/hosted-review.ts b/src/shared/hosted-review.ts index d75131a56e..e7f5965baa 100644 --- a/src/shared/hosted-review.ts +++ b/src/shared/hosted-review.ts @@ -78,6 +78,7 @@ export type HostedReviewCreationBlockedReason = | 'dirty' | 'detached_head' | 'default_branch' + | 'no_committed_changes' | 'no_upstream' | 'needs_push' | 'needs_sync' @@ -106,6 +107,7 @@ export type HostedReviewCreationEligibility = { head?: string | null title?: string | null body?: string | null + hasCommittedChanges?: boolean } export type HostedReviewCreationEligibilityArgs = {