From 9064e8e73f2c3de5813942871dacc218fafb272b Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 29 Jan 2026 01:11:45 +0200 Subject: [PATCH] feat: add local branch checks to PR picker and integrate refresh Enable optimistic local-branch checks for PR heads to disable non-existent branches Show IntegrateCommitsSection only when upstream tracking is origin-based or applicable --- .../session/GitHubPullRequestPickerDialog.tsx | 207 +++++++++++++++--- packages/ui/src/components/views/GitView.tsx | 12 +- .../views/git/IntegrateCommitsSection.tsx | 4 +- 3 files changed, 192 insertions(+), 31 deletions(-) diff --git a/packages/ui/src/components/session/GitHubPullRequestPickerDialog.tsx b/packages/ui/src/components/session/GitHubPullRequestPickerDialog.tsx index 8488339b..23ec2eea 100644 --- a/packages/ui/src/components/session/GitHubPullRequestPickerDialog.tsx +++ b/packages/ui/src/components/session/GitHubPullRequestPickerDialog.tsx @@ -29,6 +29,7 @@ import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { opencodeClient } from '@/lib/opencode/client'; import { createWorktreeSessionForNewBranchExact } from '@/lib/worktreeSessionCreator'; import { gitFetch } from '@/lib/gitApi'; +import { execCommand, execCommands } from '@/lib/execCommands'; import type { GitHubPullRequestContextResult, GitHubPullRequestSummary, GitHubPullRequestsListResult } from '@/lib/api/types'; const parsePullRequestNumber = (value: string): number | null => { @@ -54,6 +55,15 @@ const buildPullRequestContextText = (payload: GitHubPullRequestContextResult) => return `GitHub pull request context (JSON)\n${JSON.stringify(payload, null, 2)}`; }; +const sanitizeGitRemoteName = (value: string): string => { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 64); +}; + export function GitHubPullRequestPickerDialog({ open, onOpenChange, @@ -80,6 +90,7 @@ export function GitHubPullRequestPickerDialog({ const [startingNumber, setStartingNumber] = React.useState(null); const [isLoading, setIsLoading] = React.useState(false); const [isLoadingMore, setIsLoadingMore] = React.useState(false); + const [existingBranchHeads, setExistingBranchHeads] = React.useState>(new Map()); const [error, setError] = React.useState(null); const refresh = React.useCallback(async () => { @@ -154,11 +165,44 @@ export function GitHubPullRequestPickerDialog({ setStartingNumber(null); setIsLoading(false); setError(null); + setExistingBranchHeads(new Map()); return; } void refresh(); }, [open, refresh]); + const checkLocalBranchExists = React.useCallback(async (heads: string[]) => { + if (!projectDirectory) return; + const unique = Array.from(new Set(heads.map((h) => (h || '').trim()).filter(Boolean))); + if (unique.length === 0) return; + + // Only check unknown heads (optimistic enable; disable after result arrives). + const unknown = unique.filter((h) => !existingBranchHeads.has(h)); + if (unknown.length === 0) return; + + // optimistic UI: no spinner; disable once results arrive + { + // Avoid shell wrappers; rely on exit code only. + const commands = unknown.map((h) => `git show-ref --verify --quiet ${JSON.stringify(`refs/heads/${h}`)}`); + const res = await execCommands(commands, projectDirectory); + setExistingBranchHeads((prev) => { + const next = new Map(prev); + for (let i = 0; i < unknown.length; i += 1) { + const head = unknown[i]; + next.set(head, Boolean(res.results[i]?.success)); + } + return next; + }); + } + }, [projectDirectory, existingBranchHeads]); + + React.useEffect(() => { + if (!open) return; + if (!projectDirectory) return; + if (!createInWorktree) return; + void checkLocalBranchExists(prs.map((pr) => pr.head)); + }, [open, projectDirectory, createInWorktree, prs, checkLocalBranchExists]); + React.useEffect(() => { if (!open) return; if (githubAuthChecked && githubAuthStatus?.connected === false) { @@ -187,6 +231,15 @@ export function GitHubPullRequestPickerDialog({ }); }, [prs, query]); + const isPrDisabledForWorktree = React.useCallback((pr: GitHubPullRequestSummary): boolean => { + if (!createInWorktree) return false; + const head = pr.head?.trim(); + if (!head) return true; + const exists = existingBranchHeads.get(head); + // Optimistic: treat unknown as enabled. + return exists === true; + }, [createInWorktree, existingBranchHeads]); + const directNumber = React.useMemo(() => parsePullRequestNumber(query), [query]); const resolveDefaultAgentName = React.useCallback((): string | undefined => { @@ -263,13 +316,88 @@ export function GitHubPullRequestPickerDialog({ throw new Error('Failed to fetch PR head'); } + const headCommitish = pr.headSha?.trim() || (await execCommand('git rev-parse FETCH_HEAD', projectDirectory)).stdout?.trim() || ''; + if (!headCommitish) { + throw new Error('PR head commit not resolvable'); + } + const preferredBranch = pr.head; - const session = await createWorktreeSessionForNewBranchExact(projectDirectory, preferredBranch, 'FETCH_HEAD'); + + // Prevent clobbering/removing an existing local branch when using PR worktree mode. + if (existingBranchHeads.get(preferredBranch) === true) { + throw new Error(`Local branch already exists: ${preferredBranch}`); + } + + const session = await createWorktreeSessionForNewBranchExact(projectDirectory, preferredBranch, headCommitish); if (!session?.id) { throw new Error('Failed to create PR worktree session'); } + + const meta = useSessionStore.getState().worktreeMetadata.get(session.id); + const worktreeDir = meta?.path; + if (!worktreeDir) { + throw new Error('Worktree directory not found'); + } + + // Switch the new worktree to the PR branch and delete the SDK-created opencode/* branch immediately. + // This makes the worktree directly operate on the PR branch. + const originalBranch = (meta?.branch || session.branch || '').replace(/^refs\/heads\//, '').trim(); + const commands: string[] = [ + // Create local branch from the fetched PR head commit. + `git -C ${JSON.stringify(worktreeDir)} switch -c ${JSON.stringify(preferredBranch)} ${JSON.stringify(headCommitish)}`, + ]; + if (originalBranch && originalBranch.startsWith('opencode/')) { + commands.push(`git -C ${JSON.stringify(projectDirectory)} branch -D ${JSON.stringify(originalBranch)}`); + } + + const result = await execCommands(commands, projectDirectory); + if (!result.success) { + const failed = result.results.find((r) => !r.success); + throw new Error(failed?.stderr || failed?.stdout || 'Failed to switch worktree to PR branch'); + } + + // Best-effort: set upstream for PR branch (without pushing). + try { + const remoteName = isFork + ? sanitizeGitRemoteName(`pr-${headRepo?.owner || 'fork'}-${headRepo?.repo || ''}`) + : 'origin'; + const remoteUrl = isFork ? (headRepo?.cloneUrl || headRepo?.url || '') : ''; + const fetchRefspec = `+refs/heads/${preferredBranch}:refs/remotes/${remoteName}/${preferredBranch}`; + + const upstreamCommands: string[] = []; + if (isFork && remoteUrl) { + upstreamCommands.push( + `git -C ${JSON.stringify(projectDirectory)} remote add ${JSON.stringify(remoteName)} ${JSON.stringify(remoteUrl)} 2>/dev/null || git -C ${JSON.stringify(projectDirectory)} remote set-url ${JSON.stringify(remoteName)} ${JSON.stringify(remoteUrl)}` + ); + } + upstreamCommands.push( + `git -C ${JSON.stringify(projectDirectory)} fetch ${JSON.stringify(remoteName)} ${JSON.stringify(fetchRefspec)}` + ); + upstreamCommands.push( + `git -C ${JSON.stringify(worktreeDir)} branch --set-upstream-to=${JSON.stringify(`${remoteName}/${preferredBranch}`)} ${JSON.stringify(preferredBranch)}` + ); + + const upstreamResult = await execCommands(upstreamCommands, projectDirectory); + if (!upstreamResult.success) { + const failed = upstreamResult.results.find((r) => !r.success); + toast.message('PR upstream not set', { description: failed?.stderr || failed?.stdout || 'Configure remote manually if needed.' }); + } + } catch { + toast.message('PR upstream not set', { description: 'Configure remote manually if needed.' }); + } + + // Update stored metadata for better UX + reintegration target. + useSessionStore.getState().setWorktreeMetadata(session.id, { + ...(meta || { path: worktreeDir, projectDirectory, branch: preferredBranch, label: preferredBranch }), + path: worktreeDir, + projectDirectory, + branch: preferredBranch, + label: preferredBranch, + createdFromBranch: pr.base, + }); + return { id: session.id }; - }, [projectDirectory]); + }, [projectDirectory, existingBranchHeads]); const startSession = React.useCallback(async (number: number) => { if (!projectDirectory) { @@ -536,35 +664,55 @@ Nice-to-have:
{query ? 'No PRs found' : 'No open PRs found'}
) : null} - {filtered.map((pr) => ( -
void startSession(pr.number)} - > - #{pr.number} -

{pr.title}

-
- {startingNumber === pr.number ? ( - - ) : ( - e.stopPropagation()} - aria-label="Open in GitHub" - > - - + {filtered.map((pr) => { + const disabledByWorktree = isPrDisabledForWorktree(pr); + + return ( +
{ + if (disabledByWorktree) return; + void startSession(pr.number); + }} + > + #{pr.number} +
+

{pr.title}

+ {createInWorktree && disabledByWorktree ? ( +

+ PR worktree disabled: local branch exists ({pr.head}) +

+ ) : null} +
+
+ {startingNumber === pr.number ? ( + + ) : ( + e.stopPropagation()} + aria-label="Open in GitHub" + > + + + )} +
-
- ))} + ); + })} {hasMore && connected && projectDirectory && github ? (
@@ -625,6 +773,7 @@ Nice-to-have: Create session in PR worktree
+
{ ); const [hasUserAdjustedSelection, setHasUserAdjustedSelection] = React.useState(false); const [revertingPaths, setRevertingPaths] = React.useState>(new Set()); + const [integrateRefreshKey, setIntegrateRefreshKey] = React.useState(0); const [isGeneratingMessage, setIsGeneratingMessage] = React.useState(false); const [generatedHighlights, setGeneratedHighlights] = React.useState( initialSnapshot?.generatedHighlights ?? [] @@ -267,6 +268,13 @@ export const GitView: React.FC = () => { const repoRootForIntegrate = worktreeMetadata?.projectDirectory || null; const sourceBranchForIntegrate = status?.current || null; + const shouldShowIntegrateCommits = React.useMemo(() => { + // For PR worktrees from forks we set upstream to a non-origin remote (e.g. pr--). + // Re-integrate commits is intended for local scratch branches -> base branch, not fork PR branches. + const tracking = status?.tracking; + if (!tracking) return true; + return tracking.startsWith('origin/'); + }, [status?.tracking]); const defaultTargetBranch = React.useMemo(() => { const fromMeta = worktreeMetadata?.createdFromBranch; if (typeof fromMeta === 'string' && fromMeta.trim().length > 0) { @@ -602,6 +610,7 @@ export const GitView: React.FC = () => { } await refreshLog(); + setIntegrateRefreshKey((v) => v + 1); } catch (err) { const message = err instanceof Error ? err.message : 'Failed to create commit'; toast.error(message); @@ -1048,13 +1057,14 @@ export const GitView: React.FC = () => { )}
- {worktreeMetadata && repoRootForIntegrate && sourceBranchForIntegrate ? ( + {worktreeMetadata && repoRootForIntegrate && sourceBranchForIntegrate && shouldShowIntegrateCommits ? ( { if (!currentDirectory) return; fetchStatus(currentDirectory, git); diff --git a/packages/ui/src/components/views/git/IntegrateCommitsSection.tsx b/packages/ui/src/components/views/git/IntegrateCommitsSection.tsx index 9e1a762a..5774d812 100644 --- a/packages/ui/src/components/views/git/IntegrateCommitsSection.tsx +++ b/packages/ui/src/components/views/git/IntegrateCommitsSection.tsx @@ -51,6 +51,7 @@ export const IntegrateCommitsSection: React.FC<{ worktreeMetadata: WorktreeMetadata; localBranches: string[]; defaultTargetBranch: string; + refreshKey?: number; onRefresh?: () => void; }> = ({ repoRoot, @@ -58,6 +59,7 @@ export const IntegrateCommitsSection: React.FC<{ worktreeMetadata, localBranches, defaultTargetBranch, + refreshKey, onRefresh, }) => { const currentSessionId = useSessionStore((s) => s.currentSessionId); @@ -156,7 +158,7 @@ export const IntegrateCommitsSection: React.FC<{ return () => { cancelled = true; }; - }, [isEligible, repoRoot, sourceBranch, targetBranch]); + }, [isEligible, repoRoot, sourceBranch, targetBranch, refreshKey]); const persistTarget = React.useCallback( (branch: string) => {