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 { opencodeClient } from '@/lib/opencode/client';
|
||||||
import { createWorktreeSessionForNewBranchExact } from '@/lib/worktreeSessionCreator';
|
import { createWorktreeSessionForNewBranchExact } from '@/lib/worktreeSessionCreator';
|
||||||
import { gitFetch } from '@/lib/gitApi';
|
import { gitFetch } from '@/lib/gitApi';
|
||||||
|
import { execCommand, execCommands } from '@/lib/execCommands';
|
||||||
import type { GitHubPullRequestContextResult, GitHubPullRequestSummary, GitHubPullRequestsListResult } from '@/lib/api/types';
|
import type { GitHubPullRequestContextResult, GitHubPullRequestSummary, GitHubPullRequestsListResult } from '@/lib/api/types';
|
||||||
|
|
||||||
const parsePullRequestNumber = (value: string): number | null => {
|
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)}`;
|
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({
|
export function GitHubPullRequestPickerDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@@ -80,6 +90,7 @@ export function GitHubPullRequestPickerDialog({
|
|||||||
const [startingNumber, setStartingNumber] = React.useState<number | null>(null);
|
const [startingNumber, setStartingNumber] = React.useState<number | null>(null);
|
||||||
const [isLoading, setIsLoading] = React.useState(false);
|
const [isLoading, setIsLoading] = React.useState(false);
|
||||||
const [isLoadingMore, setIsLoadingMore] = 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 [error, setError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
const refresh = React.useCallback(async () => {
|
const refresh = React.useCallback(async () => {
|
||||||
@@ -154,11 +165,44 @@ export function GitHubPullRequestPickerDialog({
|
|||||||
setStartingNumber(null);
|
setStartingNumber(null);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setExistingBranchHeads(new Map());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void refresh();
|
void refresh();
|
||||||
}, [open, 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(() => {
|
React.useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
if (githubAuthChecked && githubAuthStatus?.connected === false) {
|
if (githubAuthChecked && githubAuthStatus?.connected === false) {
|
||||||
@@ -187,6 +231,15 @@ export function GitHubPullRequestPickerDialog({
|
|||||||
});
|
});
|
||||||
}, [prs, query]);
|
}, [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 directNumber = React.useMemo(() => parsePullRequestNumber(query), [query]);
|
||||||
|
|
||||||
const resolveDefaultAgentName = React.useCallback((): string | undefined => {
|
const resolveDefaultAgentName = React.useCallback((): string | undefined => {
|
||||||
@@ -263,13 +316,88 @@ export function GitHubPullRequestPickerDialog({
|
|||||||
throw new Error('Failed to fetch PR head');
|
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 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) {
|
if (!session?.id) {
|
||||||
throw new Error('Failed to create PR worktree session');
|
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 };
|
return { id: session.id };
|
||||||
}, [projectDirectory]);
|
}, [projectDirectory, existingBranchHeads]);
|
||||||
|
|
||||||
const startSession = React.useCallback(async (number: number) => {
|
const startSession = React.useCallback(async (number: number) => {
|
||||||
if (!projectDirectory) {
|
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>
|
<div className="text-center text-muted-foreground py-8">{query ? 'No PRs found' : 'No open PRs found'}</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{filtered.map((pr) => (
|
{filtered.map((pr) => {
|
||||||
<div
|
const disabledByWorktree = isPrDisabledForWorktree(pr);
|
||||||
key={pr.number}
|
|
||||||
className={cn(
|
return (
|
||||||
'group flex items-center gap-2 py-1.5 hover:bg-muted/30 rounded transition-colors cursor-pointer',
|
<div
|
||||||
startingNumber === pr.number && 'bg-muted/30'
|
key={pr.number}
|
||||||
)}
|
className={cn(
|
||||||
onClick={() => void startSession(pr.number)}
|
'group flex items-start gap-2 py-1.5 rounded transition-colors',
|
||||||
>
|
startingNumber === pr.number && 'bg-muted/30',
|
||||||
<span className="typography-meta text-muted-foreground w-12 text-right flex-shrink-0">#{pr.number}</span>
|
disabledByWorktree
|
||||||
<p className="flex-1 min-w-0 typography-small text-foreground truncate ml-0.5">{pr.title}</p>
|
? 'opacity-50 cursor-not-allowed'
|
||||||
<div className="flex-shrink-0 h-5 flex items-center mr-2">
|
: 'hover:bg-muted/30 cursor-pointer'
|
||||||
{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>
|
|
||||||
)}
|
)}
|
||||||
|
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>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
|
|
||||||
{hasMore && connected && projectDirectory && github ? (
|
{hasMore && connected && projectDirectory && github ? (
|
||||||
<div className="py-2 flex justify-center">
|
<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>
|
<span className="typography-meta text-muted-foreground">Create session in PR worktree</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2 cursor-pointer"
|
className="flex items-center gap-2 cursor-pointer"
|
||||||
role="button"
|
role="button"
|
||||||
|
|||||||
@@ -260,6 +260,7 @@ export const GitView: React.FC = () => {
|
|||||||
);
|
);
|
||||||
const [hasUserAdjustedSelection, setHasUserAdjustedSelection] = React.useState(false);
|
const [hasUserAdjustedSelection, setHasUserAdjustedSelection] = React.useState(false);
|
||||||
const [revertingPaths, setRevertingPaths] = React.useState<Set<string>>(new Set());
|
const [revertingPaths, setRevertingPaths] = React.useState<Set<string>>(new Set());
|
||||||
|
const [integrateRefreshKey, setIntegrateRefreshKey] = React.useState(0);
|
||||||
const [isGeneratingMessage, setIsGeneratingMessage] = React.useState(false);
|
const [isGeneratingMessage, setIsGeneratingMessage] = React.useState(false);
|
||||||
const [generatedHighlights, setGeneratedHighlights] = React.useState<string[]>(
|
const [generatedHighlights, setGeneratedHighlights] = React.useState<string[]>(
|
||||||
initialSnapshot?.generatedHighlights ?? []
|
initialSnapshot?.generatedHighlights ?? []
|
||||||
@@ -267,6 +268,13 @@ export const GitView: React.FC = () => {
|
|||||||
|
|
||||||
const repoRootForIntegrate = worktreeMetadata?.projectDirectory || null;
|
const repoRootForIntegrate = worktreeMetadata?.projectDirectory || null;
|
||||||
const sourceBranchForIntegrate = status?.current || 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 defaultTargetBranch = React.useMemo(() => {
|
||||||
const fromMeta = worktreeMetadata?.createdFromBranch;
|
const fromMeta = worktreeMetadata?.createdFromBranch;
|
||||||
if (typeof fromMeta === 'string' && fromMeta.trim().length > 0) {
|
if (typeof fromMeta === 'string' && fromMeta.trim().length > 0) {
|
||||||
@@ -602,6 +610,7 @@ export const GitView: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await refreshLog();
|
await refreshLog();
|
||||||
|
setIntegrateRefreshKey((v) => v + 1);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Failed to create commit';
|
const message = err instanceof Error ? err.message : 'Failed to create commit';
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
@@ -1048,13 +1057,14 @@ export const GitView: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{worktreeMetadata && repoRootForIntegrate && sourceBranchForIntegrate ? (
|
{worktreeMetadata && repoRootForIntegrate && sourceBranchForIntegrate && shouldShowIntegrateCommits ? (
|
||||||
<IntegrateCommitsSection
|
<IntegrateCommitsSection
|
||||||
repoRoot={repoRootForIntegrate}
|
repoRoot={repoRootForIntegrate}
|
||||||
sourceBranch={sourceBranchForIntegrate}
|
sourceBranch={sourceBranchForIntegrate}
|
||||||
worktreeMetadata={worktreeMetadata}
|
worktreeMetadata={worktreeMetadata}
|
||||||
localBranches={localBranches}
|
localBranches={localBranches}
|
||||||
defaultTargetBranch={defaultTargetBranch}
|
defaultTargetBranch={defaultTargetBranch}
|
||||||
|
refreshKey={integrateRefreshKey}
|
||||||
onRefresh={() => {
|
onRefresh={() => {
|
||||||
if (!currentDirectory) return;
|
if (!currentDirectory) return;
|
||||||
fetchStatus(currentDirectory, git);
|
fetchStatus(currentDirectory, git);
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export const IntegrateCommitsSection: React.FC<{
|
|||||||
worktreeMetadata: WorktreeMetadata;
|
worktreeMetadata: WorktreeMetadata;
|
||||||
localBranches: string[];
|
localBranches: string[];
|
||||||
defaultTargetBranch: string;
|
defaultTargetBranch: string;
|
||||||
|
refreshKey?: number;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
}> = ({
|
}> = ({
|
||||||
repoRoot,
|
repoRoot,
|
||||||
@@ -58,6 +59,7 @@ export const IntegrateCommitsSection: React.FC<{
|
|||||||
worktreeMetadata,
|
worktreeMetadata,
|
||||||
localBranches,
|
localBranches,
|
||||||
defaultTargetBranch,
|
defaultTargetBranch,
|
||||||
|
refreshKey,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}) => {
|
}) => {
|
||||||
const currentSessionId = useSessionStore((s) => s.currentSessionId);
|
const currentSessionId = useSessionStore((s) => s.currentSessionId);
|
||||||
@@ -156,7 +158,7 @@ export const IntegrateCommitsSection: React.FC<{
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [isEligible, repoRoot, sourceBranch, targetBranch]);
|
}, [isEligible, repoRoot, sourceBranch, targetBranch, refreshKey]);
|
||||||
|
|
||||||
const persistTarget = React.useCallback(
|
const persistTarget = React.useCallback(
|
||||||
(branch: string) => {
|
(branch: string) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user