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
This commit is contained in:
@@ -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<number | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = React.useState(false);
|
||||
const [existingBranchHeads, setExistingBranchHeads] = React.useState<Map<string, boolean>>(new Map());
|
||||
const [error, setError] = React.useState<string | null>(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:
|
||||
<div className="text-center text-muted-foreground py-8">{query ? 'No PRs found' : 'No open PRs found'}</div>
|
||||
) : null}
|
||||
|
||||
{filtered.map((pr) => (
|
||||
<div
|
||||
key={pr.number}
|
||||
className={cn(
|
||||
'group flex items-center gap-2 py-1.5 hover:bg-muted/30 rounded transition-colors cursor-pointer',
|
||||
startingNumber === pr.number && 'bg-muted/30'
|
||||
)}
|
||||
onClick={() => void startSession(pr.number)}
|
||||
>
|
||||
<span className="typography-meta text-muted-foreground w-12 text-right flex-shrink-0">#{pr.number}</span>
|
||||
<p className="flex-1 min-w-0 typography-small text-foreground truncate ml-0.5">{pr.title}</p>
|
||||
<div className="flex-shrink-0 h-5 flex items-center mr-2">
|
||||
{startingNumber === pr.number ? (
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<a
|
||||
href={pr.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hidden group-hover:flex h-5 w-5 items-center justify-center text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label="Open in GitHub"
|
||||
>
|
||||
<RiExternalLinkLine className="h-4 w-4" />
|
||||
</a>
|
||||
{filtered.map((pr) => {
|
||||
const disabledByWorktree = isPrDisabledForWorktree(pr);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={pr.number}
|
||||
className={cn(
|
||||
'group flex items-start gap-2 py-1.5 rounded transition-colors',
|
||||
startingNumber === pr.number && 'bg-muted/30',
|
||||
disabledByWorktree
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'hover:bg-muted/30 cursor-pointer'
|
||||
)}
|
||||
onClick={() => {
|
||||
if (disabledByWorktree) return;
|
||||
void startSession(pr.number);
|
||||
}}
|
||||
>
|
||||
<span className="typography-meta text-muted-foreground w-12 text-right flex-shrink-0 pt-0.5">#{pr.number}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="typography-small text-foreground truncate ml-0.5">{pr.title}</p>
|
||||
{createInWorktree && disabledByWorktree ? (
|
||||
<p className="typography-micro text-muted-foreground mt-0.5 ml-0.5">
|
||||
PR worktree disabled: local branch exists ({pr.head})
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex-shrink-0 h-5 flex items-center mr-2 pt-0.5">
|
||||
{startingNumber === pr.number ? (
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<a
|
||||
href={pr.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
'hidden group-hover:flex h-5 w-5 items-center justify-center text-muted-foreground hover:text-foreground transition-colors',
|
||||
disabledByWorktree && 'pointer-events-none'
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label="Open in GitHub"
|
||||
>
|
||||
<RiExternalLinkLine className="h-4 w-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{hasMore && connected && projectDirectory && github ? (
|
||||
<div className="py-2 flex justify-center">
|
||||
@@ -625,6 +773,7 @@ Nice-to-have:
|
||||
<span className="typography-meta text-muted-foreground">Create session in PR worktree</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
role="button"
|
||||
|
||||
@@ -260,6 +260,7 @@ export const GitView: React.FC = () => {
|
||||
);
|
||||
const [hasUserAdjustedSelection, setHasUserAdjustedSelection] = React.useState(false);
|
||||
const [revertingPaths, setRevertingPaths] = React.useState<Set<string>>(new Set());
|
||||
const [integrateRefreshKey, setIntegrateRefreshKey] = React.useState(0);
|
||||
const [isGeneratingMessage, setIsGeneratingMessage] = React.useState(false);
|
||||
const [generatedHighlights, setGeneratedHighlights] = React.useState<string[]>(
|
||||
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-<owner>-<repo>).
|
||||
// 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 = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{worktreeMetadata && repoRootForIntegrate && sourceBranchForIntegrate ? (
|
||||
{worktreeMetadata && repoRootForIntegrate && sourceBranchForIntegrate && shouldShowIntegrateCommits ? (
|
||||
<IntegrateCommitsSection
|
||||
repoRoot={repoRootForIntegrate}
|
||||
sourceBranch={sourceBranchForIntegrate}
|
||||
worktreeMetadata={worktreeMetadata}
|
||||
localBranches={localBranches}
|
||||
defaultTargetBranch={defaultTargetBranch}
|
||||
refreshKey={integrateRefreshKey}
|
||||
onRefresh={() => {
|
||||
if (!currentDirectory) return;
|
||||
fetchStatus(currentDirectory, git);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user