Track and sync base ref throughout Create PR intent flow (#5907)

* Track and sync base ref throughout create PR intent flow

* Capture and track the review base ref (`baseRef`) directly inside the Create PR intent run token to keep async steps anchored to the base selected when the run started.
* Resolve intent flow and eligibility bases safely using the compare-base picker to override stale default refs.
* Keep the PR dialog's base field in sync with the active compare base while untouched by the user.

* Ignore stale generated PR fields if base ref syncs while in flight

Track user-initiated edits to the base field separately using a ref.
When the base ref is synced automatically from Source Control, mark
the base field dirty to increment its revision. This ensures stale
generated fields from an in-flight request cannot overwrite or revert
the newly updated base.
This commit is contained in:
Jinjing 2026-06-20 03:39:45 -07:00 committed by GitHub
parent 97dc6d63e3
commit 6dd82b974d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 394 additions and 29 deletions

View File

@ -236,6 +236,7 @@ import {
createPrIntentGitStatusMatchesToken,
createPrIntentRunTokenMatches,
getCreatePrIntentStagePaths,
resolveCreatePrIntentReviewBase,
resolveCreatePrIntentRemoteStep,
type CreatePrIntentRunToken
} from './source-control-create-pr-intent-flow'
@ -979,7 +980,8 @@ function SourceControlInner(): React.JSX.Element {
repoId: null as string | null,
worktreeId: null as string | null,
worktreePath: null as string | null,
branch: null as string | null
branch: null as string | null,
baseRef: null as string | null
})
const [createPrIntentInFlightByWorktree, setCreatePrIntentInFlightByWorktree] = useState<
Record<string, boolean>
@ -1089,14 +1091,6 @@ function SourceControlInner(): React.JSX.Element {
const gitIdentityDisplay = activeWorktree ? getWorktreeGitIdentityDisplay(activeWorktree) : null
const detachedHeadDisplay = gitIdentityDisplay?.kind === 'detached' ? gitIdentityDisplay : null
const branchName = gitIdentityDisplay?.kind === 'branch' ? gitIdentityDisplay.branchName : ''
useEffect(() => {
createPrIntentCurrentTargetRef.current = {
repoId: activeRepo?.id ?? null,
worktreeId: activeWorktreeId ?? null,
worktreePath,
branch: branchName
}
}, [activeRepo?.id, activeWorktreeId, branchName, worktreePath])
const activePullRequestGenerationKey = getPullRequestGenerationRecordKey({
worktreeId: activeWorktreeId,
worktreePath,
@ -1343,6 +1337,15 @@ function SourceControlInner(): React.JSX.Element {
pinnedBaseRef,
effectiveBaseRef
})
useEffect(() => {
createPrIntentCurrentTargetRef.current = {
repoId: activeRepo?.id ?? null,
worktreeId: activeWorktreeId ?? null,
worktreePath,
branch: branchName,
baseRef: effectiveBaseRef ?? null
}
}, [activeRepo?.id, activeWorktreeId, branchName, effectiveBaseRef, worktreePath])
const linkedGitHubPR = activeWorktree?.linkedPR ?? null
const fallbackGitHubPRNumber = linkedGitHubPR == null ? (activePrFromQueue?.number ?? null) : null
@ -2698,6 +2701,7 @@ function SourceControlInner(): React.JSX.Element {
worktreePath: worktreePath ?? '',
branch: branchName,
eligibility: hostedReviewCreation,
currentBaseRef: effectiveBaseRef,
repo: activeRepo ?? null,
settings: activeRepoSettings,
submitting: isCreatingPr,
@ -3070,9 +3074,11 @@ function SourceControlInner(): React.JSX.Element {
return false
}
const base = stripBaseRef(
eligibility.defaultBaseRef ?? effectiveBaseRef ?? prBase ?? ''
).trim()
const base = resolveCreatePrIntentReviewBase({
currentBaseRef: token.baseRef,
eligibilityDefaultBaseRef: eligibility.defaultBaseRef,
composerBaseRef: prBase
}).trim()
if (!base || stripBaseRef(base).toLowerCase() === stripBaseRef(token.branch).toLowerCase()) {
setCreatePrIntentNoticeForWorktree(token.worktreeId, {
tone: 'destructive',
@ -3256,7 +3262,6 @@ function SourceControlInner(): React.JSX.Element {
[
activeRepo,
createHostedReview,
effectiveBaseRef,
createPrIntentActiveTargetConflicts,
createPrIntentRunStillOwnsWorktree,
getCreatePrIntentOperationTarget,
@ -3274,11 +3279,12 @@ function SourceControlInner(): React.JSX.Element {
const refreshBranchCompareForCreatePrIntent = useCallback(
async (token: CreatePrIntentRunToken): Promise<number | undefined> => {
if (!effectiveBaseRef) {
const baseRef = token.baseRef?.trim()
if (!baseRef) {
return undefined
}
const requestKey = `${token.worktreeId}:${effectiveBaseRef}:${Date.now()}:create-pr-intent`
beginGitBranchCompareRequest(token.worktreeId, requestKey, effectiveBaseRef)
const requestKey = `${token.worktreeId}:${baseRef}:${Date.now()}:create-pr-intent`
beginGitBranchCompareRequest(token.worktreeId, requestKey, baseRef)
const result = await getRuntimeGitBranchCompare(
{
// Why: the intent flow may continue after a worktree switch; use the
@ -3288,12 +3294,12 @@ function SourceControlInner(): React.JSX.Element {
worktreePath: token.worktreePath,
connectionId: getConnectionId(token.worktreeId) ?? undefined
},
effectiveBaseRef
baseRef
)
setGitBranchCompareResult(token.worktreeId, requestKey, result)
return result.summary.status === 'ready' ? (result.summary.commitsAhead ?? 0) : undefined
},
[activeRepoSettings, beginGitBranchCompareRequest, effectiveBaseRef, setGitBranchCompareResult]
[activeRepoSettings, beginGitBranchCompareRequest, setGitBranchCompareResult]
)
const readHostedReviewCreationEligibilityForIntent = useCallback(
@ -3314,7 +3320,7 @@ function SourceControlInner(): React.JSX.Element {
repoId: activeRepo.id,
worktreePath: token.worktreePath,
branch: token.branch,
base: effectiveBaseRef ?? null,
base: token.baseRef ?? null,
hasUncommittedChanges,
hasUpstream: upstreamStatus?.hasUpstream,
ahead: upstreamStatus?.ahead,
@ -3336,7 +3342,6 @@ function SourceControlInner(): React.JSX.Element {
},
[
activeRepo,
effectiveBaseRef,
fallbackGitHubPRNumber,
getHostedReviewCreationEligibility,
linkedAzureDevOpsPR,
@ -3398,7 +3403,10 @@ function SourceControlInner(): React.JSX.Element {
repoId: activeRepo.id,
worktreeId: activeWorktreeId,
worktreePath,
branch: branchName
branch: branchName,
// Why: Create PR intent crosses async commit/push steps; the review
// target must stay tied to the base selected when the run started.
baseRef: effectiveBaseRef ?? null
})
const operationTarget = getCreatePrIntentOperationTarget(token)
const runIsCurrent = (): boolean =>
@ -3622,7 +3630,7 @@ function SourceControlInner(): React.JSX.Element {
const remoteOk = await runRemoteAction(remoteStep, {
target: operationTarget,
remoteStatus: latestUpstreamStatus,
baseRef: effectiveBaseRef
baseRef: token.baseRef
})
if (abortIfStale()) {
return

View File

@ -6,25 +6,33 @@ import {
createPrIntentGitStatusMatchesToken,
createPrIntentRunTokenMatches,
getCreatePrIntentStagePaths,
resolveCreatePrIntentReviewBase,
resolveCreatePrIntentRemoteStep
} from './source-control-create-pr-intent-flow'
import type { GitStatusEntry } from '../../../../shared/types'
describe('source-control Create PR intent flow helpers', () => {
it('matches async completions only to the original repo, worktree, path, and branch', () => {
it('matches async completions only to the original repo, worktree, path, branch, and base', () => {
const now = vi.spyOn(Date, 'now').mockReturnValue(123)
try {
const token = createCreatePrIntentRunToken({
repoId: 'repo-1',
worktreeId: 'wt-1',
worktreePath: '/repo',
branch: 'feature'
branch: 'feature',
baseRef: 'origin/main'
})
expect(token.startedAt).toBe(123)
expect(createPrIntentRunTokenMatches(token, token)).toBe(true)
expect(
createPrIntentRunTokenMatches(token, { ...token, baseRef: 'refs/remotes/origin/main' })
).toBe(true)
expect(createPrIntentRunTokenMatches(token, { ...token, branch: 'other' })).toBe(false)
expect(createPrIntentRunTokenMatches(token, { ...token, worktreeId: 'wt-2' })).toBe(false)
expect(createPrIntentRunTokenMatches(token, { ...token, baseRef: 'upstream/main' })).toBe(
false
)
} finally {
now.mockRestore()
}
@ -76,6 +84,47 @@ describe('source-control Create PR intent flow helpers', () => {
).toBe(true)
})
it('treats same-worktree base changes as intent conflicts', () => {
const worktreePath = join(sep, 'repo', 'wt-1')
const token = createCreatePrIntentRunToken({
repoId: 'repo-1',
worktreeId: 'wt-1',
worktreePath,
branch: 'feature/pr',
baseRef: 'refs/remotes/origin/main'
})
expect(
createPrIntentCurrentTargetConflictsWithToken(token, {
repoId: 'repo-1',
worktreeId: 'wt-1',
worktreePath,
branch: 'feature/pr',
baseRef: 'remotes/origin/main'
})
).toBe(false)
expect(
createPrIntentCurrentTargetConflictsWithToken(token, {
repoId: 'repo-1',
worktreeId: 'wt-1',
worktreePath,
branch: 'feature/pr',
baseRef: 'upstream/main'
})
).toBe(true)
expect(
createPrIntentCurrentTargetConflictsWithToken(token, {
repoId: 'repo-1',
worktreeId: 'wt-1',
worktreePath,
branch: 'feature/pr',
baseRef: 'origin/release'
})
).toBe(true)
})
it('stages only safe unstaged and untracked paths', () => {
const unresolved = {
path: 'conflicted.ts',
@ -93,6 +142,24 @@ describe('source-control Create PR intent flow helpers', () => {
).toEqual(['safe.ts', 'new.ts'])
})
it('prefers the current compare base over stale eligibility defaults', () => {
expect(
resolveCreatePrIntentReviewBase({
currentBaseRef: 'refs/remotes/origin/release',
eligibilityDefaultBaseRef: 'refs/remotes/origin/main',
composerBaseRef: 'main'
})
).toBe('release')
expect(
resolveCreatePrIntentReviewBase({
currentBaseRef: null,
eligibilityDefaultBaseRef: 'refs/remotes/upstream/develop',
composerBaseRef: 'main'
})
).toBe('develop')
})
it('resolves safe remote steps for publish, push, and patch-equivalent force-push', () => {
expect(
resolveCreatePrIntentRemoteStep({

View File

@ -1,6 +1,9 @@
import { shouldForcePushWithLeaseForUpstream } from '../../../../shared/git-upstream-status'
import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review'
import { normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs'
import {
normalizeHostedReviewBaseRef,
normalizeHostedReviewHeadRef
} from '../../../../shared/hosted-review-refs'
import type { GitStatusEntry, GitUpstreamStatus } from '../../../../shared/types'
import { getStageAllPaths } from './discard-all-sequence'
@ -11,6 +14,7 @@ export type CreatePrIntentRunToken = {
worktreeId: string
worktreePath: string
branch: string
baseRef?: string | null
startedAt: number
}
@ -19,12 +23,32 @@ export type CreatePrIntentCurrentTarget = {
worktreeId?: string | null
worktreePath?: string | null
branch?: string | null
baseRef?: string | null
}
export function createCreatePrIntentRunToken(input: Omit<CreatePrIntentRunToken, 'startedAt'>) {
return { ...input, startedAt: Date.now() }
}
function normalizeCreatePrIntentBaseIdentityRef(ref: string | null | undefined): string {
const trimmed = ref?.trim()
if (!trimmed) {
return ''
}
// Why: compare bases are local git refs; origin/main and upstream/main must
// stay distinct even though hosted review APIs receive only branch names.
if (trimmed.startsWith('refs/remotes/')) {
return trimmed.slice('refs/remotes/'.length)
}
if (trimmed.startsWith('remotes/')) {
return trimmed.slice('remotes/'.length)
}
if (trimmed.startsWith('refs/heads/')) {
return trimmed.slice('refs/heads/'.length)
}
return trimmed
}
export function createPrIntentRunTokenMatches(
token: CreatePrIntentRunToken,
current: CreatePrIntentCurrentTarget
@ -33,7 +57,9 @@ export function createPrIntentRunTokenMatches(
token.repoId === current.repoId &&
token.worktreeId === current.worktreeId &&
token.worktreePath === current.worktreePath &&
token.branch === current.branch
token.branch === current.branch &&
normalizeCreatePrIntentBaseIdentityRef(token.baseRef) ===
normalizeCreatePrIntentBaseIdentityRef(current.baseRef)
)
}
@ -67,6 +93,22 @@ export function getCreatePrIntentStagePaths(grouped: {
]
}
export function resolveCreatePrIntentReviewBase({
currentBaseRef,
eligibilityDefaultBaseRef,
composerBaseRef
}: {
currentBaseRef?: string | null
eligibilityDefaultBaseRef?: string | null
composerBaseRef?: string | null
}): string {
// Why: the compare-base picker is the user's latest target; eligibility can
// lag behind while Create PR intent is preparing the branch.
return normalizeHostedReviewBaseRef(
currentBaseRef?.trim() || eligibilityDefaultBaseRef?.trim() || composerBaseRef?.trim() || ''
)
}
export function resolveCreatePrIntentRemoteStep({
upstreamStatus,
hostedReviewCreation,

View File

@ -1,5 +1,14 @@
// @vitest-environment happy-dom
import React from 'react'
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { describe, expect, it } from 'vitest'
import { normalizeCreateReviewBaseSearchResults } from './useCreatePullRequestDialogFields'
import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review'
import {
normalizeCreateReviewBaseSearchResults,
useCreatePullRequestDialogFields
} from './useCreatePullRequestDialogFields'
describe('normalizeCreateReviewBaseSearchResults', () => {
it('uses detailed local branch names for base refs from arbitrary remotes', () => {
@ -32,3 +41,193 @@ describe('normalizeCreateReviewBaseSearchResults', () => {
).toEqual(['main', 'release/1.0'])
})
})
function createEligibility(
overrides: Partial<HostedReviewCreationEligibility> = {}
): HostedReviewCreationEligibility {
return {
provider: 'github',
review: null,
canCreate: true,
blockedReason: null,
nextAction: null,
defaultBaseRef: 'refs/remotes/origin/main',
title: 'Review title',
body: 'Review body',
...overrides
}
}
type DialogFields = ReturnType<typeof useCreatePullRequestDialogFields>
type DialogFieldsRenderInput = {
eligibility: HostedReviewCreationEligibility
currentBaseRef?: string | null
}
function renderDialogFields(input: DialogFieldsRenderInput): {
current: () => DialogFields
rerender: (nextInput: DialogFieldsRenderInput) => Promise<void>
unmount: () => void
} {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
const container = document.createElement('div')
document.body.appendChild(container)
const root: Root = createRoot(container)
let latest: DialogFields | null = null
let currentInput = input
function Harness(): null {
latest = useCreatePullRequestDialogFields({
open: true,
repoId: 'repo-1',
worktreeId: 'wt-1',
worktreePath: '/repo/wt',
branch: 'feature/base-change',
eligibility: currentInput.eligibility,
currentBaseRef: currentInput.currentBaseRef,
settings: null,
submitting: false
})
return null
}
function current(): DialogFields {
if (!latest) {
throw new Error('dialog fields were not rendered')
}
return latest
}
async function render(): Promise<void> {
await act(async () => {
root.render(React.createElement(Harness))
await Promise.resolve()
})
}
return {
current,
rerender: async (nextInput) => {
currentInput = nextInput
await render()
},
unmount: () => {
act(() => root.unmount())
container.remove()
}
}
}
describe('useCreatePullRequestDialogFields', () => {
it('updates an untouched base field when the creation default changes for the same branch', async () => {
const harness = renderDialogFields({ eligibility: createEligibility() })
try {
await harness.rerender({ eligibility: createEligibility() })
expect(harness.current().base).toBe('main')
await harness.rerender({
eligibility: createEligibility({
defaultBaseRef: 'refs/remotes/origin/release'
})
})
expect(harness.current().base).toBe('release')
expect(harness.current().title).toBe('Review title')
expect(harness.current().body).toBe('Review body')
await harness.rerender({
eligibility: createEligibility({
defaultBaseRef: 'refs/remotes/origin/develop'
})
})
expect(harness.current().base).toBe('develop')
} finally {
harness.unmount()
}
})
it('prefers the selected current base ref over stale eligibility defaults', async () => {
const harness = renderDialogFields({
eligibility: createEligibility({ defaultBaseRef: 'refs/remotes/origin/main' }),
currentBaseRef: 'refs/remotes/origin/release'
})
try {
await harness.rerender({
eligibility: createEligibility({ defaultBaseRef: 'refs/remotes/origin/main' }),
currentBaseRef: 'refs/remotes/origin/release'
})
expect(harness.current().base).toBe('release')
} finally {
harness.unmount()
}
})
it('keeps a user-edited base when the default base ref changes', async () => {
const harness = renderDialogFields({
eligibility: createEligibility(),
currentBaseRef: 'refs/remotes/origin/main'
})
try {
await harness.rerender({
eligibility: createEligibility(),
currentBaseRef: 'refs/remotes/origin/main'
})
act(() => {
harness.current().setBase('custom-target')
})
await harness.rerender({
eligibility: createEligibility({
defaultBaseRef: 'refs/remotes/origin/release'
}),
currentBaseRef: 'refs/remotes/origin/release'
})
expect(harness.current().base).toBe('custom-target')
} finally {
harness.unmount()
}
})
it('keeps a synced base when stale generated fields arrive', async () => {
const harness = renderDialogFields({
eligibility: createEligibility(),
currentBaseRef: 'refs/remotes/origin/main'
})
try {
await harness.rerender({
eligibility: createEligibility(),
currentBaseRef: 'refs/remotes/origin/main'
})
const seedRevisions = { ...harness.current().fieldRevisions }
await harness.rerender({
eligibility: createEligibility(),
currentBaseRef: 'refs/remotes/origin/release'
})
expect(harness.current().base).toBe('release')
act(() => {
harness.current().applyGeneratedFields(
{
base: 'main',
title: 'Generated title',
body: 'Generated body',
draft: true
},
seedRevisions
)
})
expect(harness.current().base).toBe('release')
expect(harness.current().title).toBe('Generated title')
expect(harness.current().body).toBe('Generated body')
expect(harness.current().draft).toBe(true)
} finally {
harness.unmount()
}
})
})

View File

@ -41,6 +41,7 @@ type UseCreatePullRequestDialogFieldsOptions = {
worktreePath: string
branch: string
eligibility: HostedReviewCreationEligibility | null
currentBaseRef?: string | null
repo?: Pick<Repo, 'sourceControlAi'> | null
settings: AppState['settings']
submitting: boolean
@ -78,6 +79,16 @@ export function stripBaseRef(ref: string): string {
return normalizeHostedReviewBaseRef(ref)
}
function resolveCreateReviewDefaultBaseRef({
currentBaseRef,
eligibilityDefaultBaseRef
}: {
currentBaseRef?: string | null
eligibilityDefaultBaseRef?: string | null
}): string {
return stripBaseRef(currentBaseRef?.trim() || eligibilityDefaultBaseRef?.trim() || '')
}
export function normalizeCreateReviewBaseSearchResults(
results: readonly BaseRefSearchResult[]
): string[] {
@ -103,6 +114,7 @@ export function useCreatePullRequestDialogFields({
worktreePath,
branch,
eligibility,
currentBaseRef,
repo,
settings,
submitting,
@ -124,6 +136,8 @@ export function useCreatePullRequestDialogFields({
}
const initializedFromEligibilityRef = useRef<string | null>(null)
const [initializedEligibilityKey, setInitializedEligibilityKey] = useState<string | null>(null)
const syncedDefaultBaseRef = useRef<string | null>(null)
const baseEditedByUserRef = useRef(false)
const autoGeneratedForKeyRef = useRef<string | null>(null)
const generateInFlightRef = useRef(false)
const generationRequestIdRef = useRef(0)
@ -143,6 +157,10 @@ export function useCreatePullRequestDialogFields({
const hasExternalGeneration = Boolean(generation)
const currentEligibilityKey =
open && eligibility ? `${repoId}:${worktreeId ?? worktreePath}:${branch}` : null
const resolvedDefaultBaseRef = resolveCreateReviewDefaultBaseRef({
currentBaseRef,
eligibilityDefaultBaseRef: eligibility?.defaultBaseRef
})
const markFieldDirty = useCallback((field: PullRequestFieldName): void => {
fieldRevisionsRef.current = {
@ -153,6 +171,7 @@ export function useCreatePullRequestDialogFields({
const setUserBase = useCallback(
(value: string): void => {
baseEditedByUserRef.current = true
markFieldDirty('base')
setBase(value)
},
@ -225,6 +244,8 @@ export function useCreatePullRequestDialogFields({
generateInFlightRef.current = false
generationSeedRef.current = null
initializedFromEligibilityRef.current = null
syncedDefaultBaseRef.current = null
baseEditedByUserRef.current = false
setInitializedEligibilityKey(null)
autoGeneratedForKeyRef.current = null
setGenerating(false)
@ -263,8 +284,9 @@ export function useCreatePullRequestDialogFields({
setInitializedEligibilityKey(initializationKey)
autoGeneratedForKeyRef.current = null
fieldRevisionsRef.current = createInitialPullRequestFieldRevisions()
const initialBase = eligibility.defaultBaseRef ?? ''
setBase(stripBaseRef(initialBase))
baseEditedByUserRef.current = false
syncedDefaultBaseRef.current = resolvedDefaultBaseRef || null
setBase(resolvedDefaultBaseRef)
setTitle(eligibility.title ?? '')
setBody(eligibility.body ?? '')
setDraft(resolvedPrDefaults.draft)
@ -279,11 +301,38 @@ export function useCreatePullRequestDialogFields({
hasExternalGeneration,
open,
repoId,
resolvedDefaultBaseRef,
resolvedPrDefaults.draft,
worktreeId,
worktreePath
])
useEffect(() => {
if (
!open ||
!eligibility ||
!initializedFromEligibilityRef.current ||
!resolvedDefaultBaseRef
) {
return
}
if (syncedDefaultBaseRef.current === resolvedDefaultBaseRef) {
return
}
syncedDefaultBaseRef.current = resolvedDefaultBaseRef
if (baseEditedByUserRef.current) {
return
}
// Why: the Source Control compare-base picker can change the intended
// review target while generation is in flight; bump the revision so stale
// generated details cannot retarget an untouched base back to the old ref.
markFieldDirty('base')
setBase(resolvedDefaultBaseRef)
setBaseQuery('')
setBaseResults([])
setBaseSearchError(null)
}, [eligibility, markFieldDirty, open, resolvedDefaultBaseRef])
const effectiveGenerating = generation?.generating ?? generating
const effectiveGenerateError = generation?.generateError ?? generateError