feat(git): add multi-remote push with remote selection and fork-aware PR creation (#365)
* fix: Usage drop down should be scrollable * fix: When there are multiple remotes, provide the user with an option of which branch to push to * feat(gitview): add remote-push selection and auto checkout on create * feat: enable selecting remote when creating PR * fix: show PR icon in create button when not creating * fix(pullrequest): drop remote picker and use explicit remote * feat: add remote selection for PR status and creation * fix: improve PR creation with selected remote and fork head * chore(ui): simplify PR remote link UI * fix(web): handle cross-repo PRs and branch validation * feat: add remote selection for commit and push * feat(git): delegate PR head source resolution to server * chore(web): remove noisy PR creation logs
This commit is contained in:
@@ -409,6 +409,8 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
||||
const [conflictDialogOpen, setConflictDialogOpen] = React.useState(false);
|
||||
const [conflictFiles, setConflictFiles] = React.useState<string[]>([]);
|
||||
const [conflictOperation, setConflictOperation] = React.useState<'merge' | 'rebase'>('merge');
|
||||
const [pushRemoteDialogOpen, setPushRemoteDialogOpen] = React.useState(false);
|
||||
const [pendingPushAction, setPendingPushAction] = React.useState<'commitAndPush' | null>(null);
|
||||
|
||||
// Conflict state persistence key
|
||||
const conflictStorageKey = React.useMemo(() => {
|
||||
@@ -756,7 +758,7 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommit = async (options: { pushAfter?: boolean } = {}) => {
|
||||
const handleCommit = async (options: { pushAfter?: boolean; remote?: GitRemote } = {}) => {
|
||||
if (!currentDirectory) return;
|
||||
if (!commitMessage.trim()) {
|
||||
toast.error('Please enter a commit message');
|
||||
@@ -769,6 +771,17 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// If pushing with multiple remotes and no remote specified, this shouldn't happen anymore
|
||||
// since CommitSection now uses a dropdown. But keep as fallback for safety.
|
||||
if (options.pushAfter && remotes.length > 1 && !options.remote) {
|
||||
setPendingPushAction('commitAndPush');
|
||||
setPushRemoteDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// If there's only one remote, use it automatically when no remote is specified
|
||||
const targetRemote = options.remote ?? (remotes.length === 1 ? remotes[0] : undefined);
|
||||
|
||||
const action: CommitAction = options.pushAfter ? 'commitAndPush' : 'commit';
|
||||
setCommitAction(action);
|
||||
|
||||
@@ -785,8 +798,9 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
||||
await refreshStatusAndBranches();
|
||||
|
||||
if (options.pushAfter) {
|
||||
await git.gitPush(currentDirectory);
|
||||
toast.success('Pushed to remote');
|
||||
const remoteName = targetRemote?.name;
|
||||
await git.gitPush(currentDirectory, remoteName ? { remote: remoteName } : undefined);
|
||||
toast.success(remoteName ? `Pushed to ${remoteName}` : 'Pushed to remote');
|
||||
triggerFireworks();
|
||||
await refreshStatusAndBranches(false);
|
||||
} else {
|
||||
@@ -803,6 +817,19 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePushRemoteSelect = (remote: GitRemote) => {
|
||||
setPushRemoteDialogOpen(false);
|
||||
if (pendingPushAction === 'commitAndPush') {
|
||||
handleCommit({ pushAfter: true, remote });
|
||||
}
|
||||
setPendingPushAction(null);
|
||||
};
|
||||
|
||||
const handlePushRemoteDialogClose = () => {
|
||||
setPushRemoteDialogOpen(false);
|
||||
setPendingPushAction(null);
|
||||
};
|
||||
|
||||
const handleGenerateCommitMessage = React.useCallback(async () => {
|
||||
if (!currentDirectory) return;
|
||||
if (selectedPaths.size === 0) {
|
||||
@@ -844,19 +871,22 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
||||
}
|
||||
}, [currentDirectory, selectedPaths, git, settingsGitmojiEnabled, gitmojiEmojis, scrollActionPanelToBottom]);
|
||||
|
||||
const handleCreateBranch = async (branchName: string) => {
|
||||
const handleCreateBranch = async (branchName: string, remote?: GitRemote) => {
|
||||
if (!currentDirectory || !status) return;
|
||||
const checkoutBase = status.current ?? null;
|
||||
const remoteName = remote?.name ?? 'origin';
|
||||
|
||||
try {
|
||||
await git.createBranch(currentDirectory, branchName, checkoutBase ?? 'HEAD');
|
||||
toast.success(`Created branch ${branchName}`);
|
||||
|
||||
// Checkout the new branch and stay on it
|
||||
await git.checkoutBranch(currentDirectory, branchName);
|
||||
|
||||
let pushSucceeded = false;
|
||||
try {
|
||||
await git.checkoutBranch(currentDirectory, branchName);
|
||||
await git.gitPush(currentDirectory, {
|
||||
remote: 'origin',
|
||||
remote: remoteName,
|
||||
branch: branchName,
|
||||
options: ['--set-upstream'],
|
||||
});
|
||||
@@ -865,7 +895,7 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
||||
const message =
|
||||
pushError instanceof Error
|
||||
? pushError.message
|
||||
: 'Unable to push new branch to origin.';
|
||||
: `Unable to push new branch to ${remoteName}.`;
|
||||
toast.warning('Branch created locally', {
|
||||
description: (
|
||||
<span className="text-foreground/80 dark:text-foreground/70">
|
||||
@@ -873,21 +903,13 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
||||
</span>
|
||||
),
|
||||
});
|
||||
} finally {
|
||||
if (checkoutBase) {
|
||||
try {
|
||||
await git.checkoutBranch(currentDirectory, checkoutBase);
|
||||
} catch (restoreError) {
|
||||
console.warn('Failed to restore original branch after creation:', restoreError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await refreshStatusAndBranches();
|
||||
await refreshLog();
|
||||
|
||||
if (pushSucceeded) {
|
||||
toast.success(`Upstream set for ${branchName}`);
|
||||
toast.success(`Upstream set for ${branchName} on ${remoteName}`);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to create branch';
|
||||
@@ -1673,11 +1695,12 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
||||
onGenerateMessage={handleGenerateCommitMessage}
|
||||
isGeneratingMessage={isGeneratingMessage}
|
||||
onCommit={() => handleCommit({ pushAfter: false })}
|
||||
onCommitAndPush={() => handleCommit({ pushAfter: true })}
|
||||
onCommitAndPush={(remote) => handleCommit({ pushAfter: true, remote })}
|
||||
commitAction={commitAction}
|
||||
isBusy={isBusy}
|
||||
gitmojiEnabled={settingsGitmojiEnabled}
|
||||
onOpenGitmojiPicker={() => setIsGitmojiPickerOpen(true)}
|
||||
remotes={remotes}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
@@ -1751,6 +1774,7 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
||||
directory={pullRequestProps.directory}
|
||||
branch={pullRequestProps.branch}
|
||||
baseBranch={baseBranch}
|
||||
remotes={remotes}
|
||||
onGeneratedDescription={scrollActionPanelToBottom}
|
||||
/>
|
||||
) : (
|
||||
@@ -1859,6 +1883,34 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
||||
project={branchPickerProject}
|
||||
/>
|
||||
|
||||
<Dialog open={pushRemoteDialogOpen} onOpenChange={handlePushRemoteDialogClose}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Select remote</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose which remote to push to
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-2">
|
||||
{remotes.map((remote) => (
|
||||
<button
|
||||
key={remote.name}
|
||||
type="button"
|
||||
onClick={() => handlePushRemoteSelect(remote)}
|
||||
className="flex flex-col items-start gap-0.5 px-3 py-2 rounded-lg text-left border border-border/60 hover:bg-accent hover:border-border transition-colors"
|
||||
>
|
||||
<span className="typography-ui-label text-foreground font-medium">
|
||||
{remote.name}
|
||||
</span>
|
||||
<span className="typography-meta text-muted-foreground truncate max-w-full">
|
||||
{remote.pushUrl}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
RiAddLine,
|
||||
RiCloseLine,
|
||||
RiLoader4Line,
|
||||
RiArrowLeftLine,
|
||||
} from '@remixicon/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
CommandSeparator,
|
||||
} from '@/components/ui/command';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import type { GitRemote } from '@/lib/api/types';
|
||||
|
||||
interface BranchInfo {
|
||||
ahead?: number;
|
||||
@@ -34,7 +36,8 @@ interface BranchSelectorProps {
|
||||
remoteBranches: string[];
|
||||
branchInfo: Record<string, BranchInfo> | undefined;
|
||||
onCheckout: (branch: string) => void;
|
||||
onCreate: (name: string) => Promise<void>;
|
||||
onCreate: (name: string, remote?: GitRemote) => Promise<void>;
|
||||
remotes?: GitRemote[];
|
||||
disabled?: boolean;
|
||||
tooltipDelayMs?: number;
|
||||
}
|
||||
@@ -59,16 +62,20 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
|
||||
branchInfo,
|
||||
onCheckout,
|
||||
onCreate,
|
||||
remotes = [],
|
||||
disabled = false,
|
||||
tooltipDelayMs = 1000,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [search, setSearch] = React.useState('');
|
||||
const [showCreate, setShowCreate] = React.useState(false);
|
||||
const [showRemoteSelect, setShowRemoteSelect] = React.useState(false);
|
||||
const [newBranchName, setNewBranchName] = React.useState('');
|
||||
const [isCreating, setIsCreating] = React.useState(false);
|
||||
const createInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const hasMultipleRemotes = remotes.length > 1;
|
||||
|
||||
const sanitizedNewBranch = React.useMemo(
|
||||
() => sanitizeBranchNameInput(newBranchName),
|
||||
[newBranchName]
|
||||
@@ -103,9 +110,17 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!sanitizedNewBranch || isCreating) return;
|
||||
|
||||
// If multiple remotes, show remote selection first
|
||||
if (hasMultipleRemotes) {
|
||||
setShowRemoteSelect(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Single or no remote - proceed directly
|
||||
setIsCreating(true);
|
||||
try {
|
||||
await onCreate(sanitizedNewBranch);
|
||||
await onCreate(sanitizedNewBranch, remotes[0]);
|
||||
setNewBranchName('');
|
||||
setShowCreate(false);
|
||||
setIsOpen(false);
|
||||
@@ -114,15 +129,35 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectRemote = async (remote: GitRemote) => {
|
||||
if (!sanitizedNewBranch || isCreating) return;
|
||||
setIsCreating(true);
|
||||
try {
|
||||
await onCreate(sanitizedNewBranch, remote);
|
||||
setNewBranchName('');
|
||||
setShowCreate(false);
|
||||
setShowRemoteSelect(false);
|
||||
setIsOpen(false);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackFromRemoteSelect = () => {
|
||||
setShowRemoteSelect(false);
|
||||
};
|
||||
|
||||
const handleCancelCreate = () => {
|
||||
setNewBranchName('');
|
||||
setShowCreate(false);
|
||||
setShowRemoteSelect(false);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setSearch('');
|
||||
setShowCreate(false);
|
||||
setShowRemoteSelect(false);
|
||||
setNewBranchName('');
|
||||
}
|
||||
}, [isOpen]);
|
||||
@@ -165,7 +200,45 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
|
||||
<CommandEmpty>No branches found.</CommandEmpty>
|
||||
|
||||
<CommandGroup>
|
||||
{!showCreate ? (
|
||||
{showRemoteSelect ? (
|
||||
// Remote selection step
|
||||
<div className="px-2 py-1.5">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBackFromRemoteSelect}
|
||||
disabled={isCreating}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
<RiArrowLeftLine className="size-4" />
|
||||
</button>
|
||||
<span className="typography-meta text-muted-foreground">
|
||||
Push <span className="text-foreground font-medium">{sanitizedNewBranch}</span> to:
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{remotes.map((remote) => (
|
||||
<button
|
||||
key={remote.name}
|
||||
type="button"
|
||||
onClick={() => handleSelectRemote(remote)}
|
||||
disabled={isCreating}
|
||||
className="flex flex-col items-start gap-0.5 px-2 py-1.5 rounded-md text-left hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
<span className="typography-ui-label text-foreground">
|
||||
{isCreating ? (
|
||||
<RiLoader4Line className="inline size-3 mr-1.5 animate-spin" />
|
||||
) : null}
|
||||
{remote.name}
|
||||
</span>
|
||||
<span className="typography-micro text-muted-foreground truncate max-w-full">
|
||||
{remote.pushUrl}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : !showCreate ? (
|
||||
<CommandItem onSelect={handleShowCreate}>
|
||||
<RiAddLine className="size-4" />
|
||||
<span>Create new branch...</span>
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
RiAiGenerate2,
|
||||
RiLoader4Line,
|
||||
RiEmotionHappyLine,
|
||||
RiArrowDownSLine,
|
||||
} from '@remixicon/react';
|
||||
import {
|
||||
Collapsible,
|
||||
@@ -15,6 +16,13 @@ import { CommitInput } from './CommitInput';
|
||||
import { AIHighlightsBox } from './AIHighlightsBox';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import type { GitRemote } from '@/lib/api/types';
|
||||
|
||||
type CommitAction = 'commit' | 'commitAndPush' | null;
|
||||
|
||||
@@ -28,11 +36,12 @@ interface CommitSectionProps {
|
||||
onGenerateMessage: () => void;
|
||||
isGeneratingMessage: boolean;
|
||||
onCommit: () => void;
|
||||
onCommitAndPush: () => void;
|
||||
onCommitAndPush: (remote?: GitRemote) => void;
|
||||
commitAction: CommitAction;
|
||||
isBusy: boolean;
|
||||
gitmojiEnabled: boolean;
|
||||
onOpenGitmojiPicker: () => void;
|
||||
remotes?: GitRemote[];
|
||||
variant?: 'framed' | 'plain';
|
||||
}
|
||||
|
||||
@@ -51,11 +60,13 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
|
||||
isBusy,
|
||||
gitmojiEnabled,
|
||||
onOpenGitmojiPicker,
|
||||
remotes = [],
|
||||
variant = 'framed',
|
||||
}) => {
|
||||
const hasSelectedFiles = selectedCount > 0;
|
||||
const canCommit = commitMessage.trim() && hasSelectedFiles && commitAction === null;
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const hasMultipleRemotes = remotes.length > 1;
|
||||
|
||||
const containerClassName =
|
||||
variant === 'framed'
|
||||
@@ -165,12 +176,55 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
|
||||
</ButtonLarge>
|
||||
|
||||
{isMobile ? (
|
||||
hasMultipleRemotes ? (
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={!canCommit || isGeneratingMessage}
|
||||
className="h-7 gap-0.5 px-1.5"
|
||||
aria-label="Commit & Push"
|
||||
>
|
||||
{commitAction === 'commitAndPush' ? (
|
||||
<RiLoader4Line className="size-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<RiArrowUpLine className="size-4" />
|
||||
<RiArrowDownSLine className="size-3 opacity-60" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>Commit & Push</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" className="min-w-[200px]">
|
||||
{remotes.map((remote) => (
|
||||
<DropdownMenuItem key={remote.name} onSelect={() => onCommitAndPush(remote)}>
|
||||
<div className="flex flex-col">
|
||||
<span className="typography-ui-label text-foreground">
|
||||
{remote.name}
|
||||
</span>
|
||||
<span className="typography-meta text-muted-foreground truncate">
|
||||
{remote.pushUrl}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={onCommitAndPush}
|
||||
onClick={() => onCommitAndPush()}
|
||||
disabled={!canCommit || isGeneratingMessage}
|
||||
className="h-7 w-7 p-0"
|
||||
aria-label="Commit & Push"
|
||||
@@ -186,10 +240,49 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
|
||||
<p>Commit & Push</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
) : hasMultipleRemotes ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ButtonLarge
|
||||
variant="default"
|
||||
disabled={!canCommit || isGeneratingMessage}
|
||||
className="commit-actions__btn"
|
||||
aria-label="Commit & Push"
|
||||
>
|
||||
{commitAction === 'commitAndPush' ? (
|
||||
<>
|
||||
<RiLoader4Line className="size-4 animate-spin" />
|
||||
<span className="commit-actions__label commit-actions__label--long">Pushing...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiArrowUpLine className="size-4" />
|
||||
<span className="commit-actions__label commit-actions__label--long">Commit & Push</span>
|
||||
<RiArrowDownSLine className="size-3.5 opacity-60 -mr-0.5" />
|
||||
</>
|
||||
)}
|
||||
</ButtonLarge>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[200px]">
|
||||
{remotes.map((remote) => (
|
||||
<DropdownMenuItem key={remote.name} onSelect={() => onCommitAndPush(remote)}>
|
||||
<div className="flex flex-col">
|
||||
<span className="typography-ui-label text-foreground">
|
||||
{remote.name}
|
||||
</span>
|
||||
<span className="typography-meta text-muted-foreground truncate">
|
||||
{remote.pushUrl}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<ButtonLarge
|
||||
variant="default"
|
||||
onClick={onCommitAndPush}
|
||||
onClick={() => onCommitAndPush()}
|
||||
disabled={!canCommit || isGeneratingMessage}
|
||||
className="commit-actions__btn"
|
||||
aria-label="Commit & Push"
|
||||
|
||||
@@ -40,7 +40,7 @@ interface GitHeaderProps {
|
||||
onPull: (remote: GitRemote) => void;
|
||||
onPush: (remote: GitRemote) => void;
|
||||
onCheckoutBranch: (branch: string) => void;
|
||||
onCreateBranch: (name: string) => Promise<void>;
|
||||
onCreateBranch: (name: string, remote?: GitRemote) => Promise<void>;
|
||||
onRenameBranch?: (oldName: string, newName: string) => Promise<void>;
|
||||
activeIdentityProfile: GitIdentityProfile | null;
|
||||
availableIdentities: GitIdentityProfile[];
|
||||
@@ -301,6 +301,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
||||
branchInfo={branchInfo}
|
||||
onCheckout={onCheckoutBranch}
|
||||
onCreate={onCreateBranch}
|
||||
remotes={remotes}
|
||||
tooltipDelayMs={useTwoRowHeader ? 300 : 1000}
|
||||
/>
|
||||
)}
|
||||
@@ -334,6 +335,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
||||
branchInfo={branchInfo}
|
||||
onCheckout={onCheckoutBranch}
|
||||
onCreate={onCreateBranch}
|
||||
remotes={remotes}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -28,6 +28,12 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
@@ -53,6 +59,7 @@ import type {
|
||||
GitHubCheckRun,
|
||||
GitHubPullRequestContextResult,
|
||||
GitHubPullRequestStatus,
|
||||
GitRemote,
|
||||
} from '@/lib/api/types';
|
||||
|
||||
type MergeMethod = 'merge' | 'squash' | 'rebase';
|
||||
@@ -167,9 +174,10 @@ export const PullRequestSection: React.FC<{
|
||||
directory: string;
|
||||
branch: string;
|
||||
baseBranch: string;
|
||||
remotes?: GitRemote[];
|
||||
variant?: 'framed' | 'plain';
|
||||
onGeneratedDescription?: () => void;
|
||||
}> = ({ directory, branch, baseBranch, variant = 'framed', onGeneratedDescription }) => {
|
||||
}> = ({ directory, branch, baseBranch, remotes = [], variant = 'framed', onGeneratedDescription }) => {
|
||||
const { github } = useRuntimeAPIs();
|
||||
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
|
||||
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
|
||||
@@ -217,6 +225,16 @@ export const PullRequestSection: React.FC<{
|
||||
|
||||
const [isContextOpen, setIsContextOpen] = React.useState(false);
|
||||
const [isContextSheetOpen, setIsContextSheetOpen] = React.useState(false);
|
||||
const [selectedRemote, setSelectedRemote] = React.useState<GitRemote | null>(() => remotes[0] ?? null);
|
||||
|
||||
const hasMultipleRemotes = remotes.length > 1;
|
||||
|
||||
// Update selected remote when remotes change
|
||||
React.useEffect(() => {
|
||||
if (remotes.length > 0 && !selectedRemote) {
|
||||
setSelectedRemote(remotes[0]);
|
||||
}
|
||||
}, [remotes, selectedRemote]);
|
||||
|
||||
const [checksDialogOpen, setChecksDialogOpen] = React.useState(false);
|
||||
const [checkDetails, setCheckDetails] = React.useState<GitHubPullRequestContextResult | null>(null);
|
||||
@@ -781,7 +799,7 @@ export const PullRequestSection: React.FC<{
|
||||
}
|
||||
setError(null);
|
||||
try {
|
||||
const next = await github.prStatus(directory, branch);
|
||||
const next = await github.prStatus(directory, branch, selectedRemote?.name);
|
||||
setStatus((prev) => {
|
||||
const nextPr = next.pr;
|
||||
const prevPr = prev?.pr;
|
||||
@@ -828,7 +846,16 @@ export const PullRequestSection: React.FC<{
|
||||
}
|
||||
isRefreshInFlightRef.current = false;
|
||||
}
|
||||
}, [branch, canShow, directory, github, githubAuthChecked, githubAuthStatus]);
|
||||
}, [branch, canShow, directory, github, githubAuthChecked, githubAuthStatus, selectedRemote?.name]);
|
||||
|
||||
// Refetch PR status when selected remote changes
|
||||
const handleRemoteChange = React.useCallback((remote: GitRemote) => {
|
||||
setSelectedRemote(remote);
|
||||
// Clear current status and refetch
|
||||
setStatus(null);
|
||||
setError(null);
|
||||
lastRefreshAtRef.current = 0; // Force refresh
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const snapshot = pullRequestDraftSnapshots.get(snapshotKey) ?? null;
|
||||
@@ -842,6 +869,13 @@ export const PullRequestSection: React.FC<{
|
||||
void refresh({ force: true, markInitialResolved: true });
|
||||
}, [branch, refresh, snapshotKey]);
|
||||
|
||||
// Refetch when selected remote changes
|
||||
React.useEffect(() => {
|
||||
if (selectedRemote) {
|
||||
void refresh({ force: true, markInitialResolved: true });
|
||||
}
|
||||
}, [selectedRemote, refresh]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const onFocus = () => {
|
||||
void refresh({ force: true, silent: true });
|
||||
@@ -951,6 +985,8 @@ export const PullRequestSection: React.FC<{
|
||||
|
||||
setIsCreating(true);
|
||||
try {
|
||||
// Let the server determine the head source from tracking info
|
||||
// The server will check the branch's tracking remote and use that
|
||||
const pr = await github.prCreate({
|
||||
directory,
|
||||
title: trimmedTitle,
|
||||
@@ -958,6 +994,7 @@ export const PullRequestSection: React.FC<{
|
||||
base: baseBranch,
|
||||
...(body.trim() ? { body } : {}),
|
||||
draft,
|
||||
...(selectedRemote ? { remote: selectedRemote.name } : {}),
|
||||
});
|
||||
toast.success('PR created');
|
||||
setStatus((prev) => (prev ? { ...prev, pr } : prev));
|
||||
@@ -968,7 +1005,7 @@ export const PullRequestSection: React.FC<{
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [baseBranch, body, branch, directory, draft, github, refresh, title]);
|
||||
}, [baseBranch, body, branch, directory, draft, github, refresh, selectedRemote, title]);
|
||||
|
||||
const mergePr = React.useCallback(async (pr: GitHubPullRequest) => {
|
||||
if (!github?.prMerge) {
|
||||
@@ -1120,6 +1157,36 @@ export const PullRequestSection: React.FC<{
|
||||
{checks.total > 0 ? `${checks.success}/${checks.total} checks` : `${checks.state} checks`}
|
||||
</span>
|
||||
) : null}
|
||||
{hasMultipleRemotes ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-2 gap-1">
|
||||
<span className="typography-micro">{selectedRemote?.name}</span>
|
||||
<RiArrowDownSLine className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[200px]">
|
||||
{remotes.map((remote) => (
|
||||
<DropdownMenuItem
|
||||
key={remote.name}
|
||||
onSelect={() => handleRemoteChange(remote)}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="typography-ui-label text-foreground">
|
||||
{remote.name}
|
||||
{remote.name === selectedRemote?.name && (
|
||||
<span className="ml-2 text-primary">✓</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="typography-meta text-muted-foreground truncate">
|
||||
{remote.pushUrl}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{pr ? (
|
||||
@@ -1554,7 +1621,7 @@ export const PullRequestSection: React.FC<{
|
||||
disabled={isCreating || !isConnected}
|
||||
>
|
||||
<span className="inline-flex size-4 items-center justify-center">
|
||||
{isCreating ? <RiLoader4Line className="size-4 animate-spin" /> : <span className="size-4" aria-hidden="true" />}
|
||||
{isCreating ? <RiLoader4Line className="size-4 animate-spin" /> : <RiGitPullRequestLine className="size-4" />}
|
||||
</span>
|
||||
<span>Create PR</span>
|
||||
</Button>
|
||||
|
||||
@@ -701,6 +701,10 @@ export type GitHubPullRequestCreateInput = {
|
||||
base: string;
|
||||
body?: string;
|
||||
draft?: boolean;
|
||||
/** Remote to create the PR against (target repo, e.g., 'upstream' for forks) */
|
||||
remote?: string;
|
||||
/** Remote where the head branch lives (source repo, e.g., 'origin' for forks) */
|
||||
headRemote?: string;
|
||||
};
|
||||
|
||||
export type GitHubPullRequestUpdateInput = {
|
||||
@@ -816,7 +820,7 @@ export interface GitHubAPI {
|
||||
authActivate(accountId: string): Promise<GitHubAuthStatus>;
|
||||
me?(): Promise<GitHubUserSummary>;
|
||||
|
||||
prStatus(directory: string, branch: string): Promise<GitHubPullRequestStatus>;
|
||||
prStatus(directory: string, branch: string, remote?: string): Promise<GitHubPullRequestStatus>;
|
||||
prCreate(payload: GitHubPullRequestCreateInput): Promise<GitHubPullRequest>;
|
||||
prUpdate(payload: GitHubPullRequestUpdateInput): Promise<GitHubPullRequest>;
|
||||
prMerge(payload: GitHubPullRequestMergeInput): Promise<GitHubPullRequestMergeResult>;
|
||||
|
||||
@@ -6673,6 +6673,7 @@ async function main(options = {}) {
|
||||
try {
|
||||
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
|
||||
const branch = typeof req.query?.branch === 'string' ? req.query.branch.trim() : '';
|
||||
const remote = typeof req.query?.remote === 'string' ? req.query.remote.trim() : 'origin';
|
||||
if (!directory || !branch) {
|
||||
return res.status(400).json({ error: 'directory and branch are required' });
|
||||
}
|
||||
@@ -6684,17 +6685,43 @@ async function main(options = {}) {
|
||||
}
|
||||
|
||||
const { resolveGitHubRepoFromDirectory } = await import('./lib/github-repo.js');
|
||||
const { repo } = await resolveGitHubRepoFromDirectory(directory);
|
||||
const { repo } = await resolveGitHubRepoFromDirectory(directory, remote);
|
||||
if (!repo) {
|
||||
return res.json({ connected: true, repo: null, branch, pr: null, checks: null, canMerge: false });
|
||||
}
|
||||
|
||||
const listByHead = async (state) => {
|
||||
// Determine the head owner for PR search
|
||||
// Priority: 1) tracking branch remote, 2) origin (if different from target), 3) target repo owner
|
||||
let headOwnerForSearch = null;
|
||||
|
||||
// First, check the branch's tracking info to see which remote it's on
|
||||
const { getStatus } = await import('./lib/git-service.js');
|
||||
const status = await getStatus(directory).catch(() => null);
|
||||
if (status?.tracking) {
|
||||
const trackingRemote = status.tracking.split('/')[0];
|
||||
if (trackingRemote && trackingRemote !== remote) {
|
||||
// Branch is tracked on a different remote - get that remote's owner
|
||||
const { repo: trackingRepo } = await resolveGitHubRepoFromDirectory(directory, trackingRemote);
|
||||
if (trackingRepo && trackingRepo.owner !== repo.owner) {
|
||||
headOwnerForSearch = trackingRepo.owner;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if targeting non-origin, check if origin has a different owner (fork scenario)
|
||||
if (!headOwnerForSearch && remote !== 'origin') {
|
||||
const { repo: originRepo } = await resolveGitHubRepoFromDirectory(directory, 'origin');
|
||||
if (originRepo && originRepo.owner !== repo.owner) {
|
||||
headOwnerForSearch = originRepo.owner;
|
||||
}
|
||||
}
|
||||
|
||||
const listByHead = async (state, headOwner = repo.owner) => {
|
||||
const resp = await octokit.rest.pulls.list({
|
||||
owner: repo.owner,
|
||||
repo: repo.repo,
|
||||
state,
|
||||
head: `${repo.owner}:${branch}`,
|
||||
head: `${headOwner}:${branch}`,
|
||||
per_page: 10,
|
||||
});
|
||||
return Array.isArray(resp?.data) ? resp.data[0] : null;
|
||||
@@ -6716,9 +6743,20 @@ async function main(options = {}) {
|
||||
// PR status by branch:
|
||||
// - Prefer open PRs.
|
||||
// - If none, also surface closed/merged PRs.
|
||||
// - Fork PR support: head owner != base owner -> head filter yields empty; fall back to matching head.ref.
|
||||
let first = await listByHead('open');
|
||||
// - For cross-repo PRs: first try with head owner, then fall back to target owner, then ref match.
|
||||
let first = null;
|
||||
|
||||
// For cross-repo workflows, try head owner first
|
||||
if (headOwnerForSearch) {
|
||||
first = await listByHead('open', headOwnerForSearch);
|
||||
if (!first) first = await listByHead('closed', headOwnerForSearch);
|
||||
}
|
||||
|
||||
// Try with target repo owner (same-repo PRs)
|
||||
if (!first) first = await listByHead('open');
|
||||
if (!first) first = await listByHead('closed');
|
||||
|
||||
// Fall back to matching head.ref directly (handles edge cases)
|
||||
if (!first) first = await listByHeadRef('open');
|
||||
if (!first) first = await listByHeadRef('closed');
|
||||
if (!first) {
|
||||
@@ -6858,6 +6896,10 @@ async function main(options = {}) {
|
||||
const base = typeof req.body?.base === 'string' ? req.body.base.trim() : '';
|
||||
const body = typeof req.body?.body === 'string' ? req.body.body : undefined;
|
||||
const draft = typeof req.body?.draft === 'boolean' ? req.body.draft : undefined;
|
||||
// remote = target repo (where PR is created, e.g., 'upstream' for forks)
|
||||
const remote = typeof req.body?.remote === 'string' ? req.body.remote.trim() : 'origin';
|
||||
// headRemote = source repo (where head branch lives, e.g., 'origin' for forks)
|
||||
const headRemote = typeof req.body?.headRemote === 'string' ? req.body.headRemote.trim() : '';
|
||||
if (!directory || !title || !head || !base) {
|
||||
return res.status(400).json({ error: 'directory, title, head, base are required' });
|
||||
}
|
||||
@@ -6869,16 +6911,78 @@ async function main(options = {}) {
|
||||
}
|
||||
|
||||
const { resolveGitHubRepoFromDirectory } = await import('./lib/github-repo.js');
|
||||
const { repo } = await resolveGitHubRepoFromDirectory(directory);
|
||||
const { repo } = await resolveGitHubRepoFromDirectory(directory, remote);
|
||||
if (!repo) {
|
||||
return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' });
|
||||
}
|
||||
|
||||
// Determine the source remote for the head branch
|
||||
// Priority: 1) explicit headRemote, 2) tracking branch remote, 3) 'origin' if targeting non-origin
|
||||
let sourceRemote = headRemote;
|
||||
|
||||
// If no explicit headRemote, check the branch's tracking info
|
||||
if (!sourceRemote) {
|
||||
const { getStatus } = await import('./lib/git-service.js');
|
||||
const status = await getStatus(directory).catch(() => null);
|
||||
if (status?.tracking) {
|
||||
// tracking is like "gsxdsm/fix/multi-remote-branch-creation" or "origin/main"
|
||||
const trackingRemote = status.tracking.split('/')[0];
|
||||
if (trackingRemote) {
|
||||
sourceRemote = trackingRemote;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if targeting non-origin and no tracking info, try 'origin'
|
||||
if (!sourceRemote && remote !== 'origin') {
|
||||
sourceRemote = 'origin';
|
||||
}
|
||||
|
||||
// For fork workflows: we need to determine the correct head reference
|
||||
let headRef = head;
|
||||
|
||||
if (sourceRemote && sourceRemote !== remote) {
|
||||
// The branch is on a different remote than the target - this is a cross-repo PR
|
||||
const { repo: headRepo } = await resolveGitHubRepoFromDirectory(directory, sourceRemote);
|
||||
if (headRepo) {
|
||||
// Always use owner:branch format for cross-repo PRs
|
||||
// GitHub API requires this when head is from a different repo/fork
|
||||
if (headRepo.owner !== repo.owner || headRepo.repo !== repo.repo) {
|
||||
headRef = `${headRepo.owner}:${head}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For cross-repo PRs, verify the branch exists on the head repo first
|
||||
if (headRef.includes(':')) {
|
||||
const [headOwner] = headRef.split(':');
|
||||
const headRepoName = sourceRemote
|
||||
? (await resolveGitHubRepoFromDirectory(directory, sourceRemote)).repo?.repo
|
||||
: repo.repo;
|
||||
|
||||
if (headRepoName) {
|
||||
try {
|
||||
await octokit.rest.repos.getBranch({
|
||||
owner: headOwner,
|
||||
repo: headRepoName,
|
||||
branch: head,
|
||||
});
|
||||
} catch (branchError) {
|
||||
if (branchError?.status === 404) {
|
||||
return res.status(400).json({
|
||||
error: `Branch "${head}" not found on ${headOwner}/${headRepoName}. Please push your branch first: git push ${sourceRemote || 'origin'} ${head}`,
|
||||
});
|
||||
}
|
||||
// For other errors, continue - let the PR create attempt handle it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const created = await octokit.rest.pulls.create({
|
||||
owner: repo.owner,
|
||||
repo: repo.repo,
|
||||
title,
|
||||
head,
|
||||
head: headRef,
|
||||
base,
|
||||
...(typeof body === 'string' ? { body } : {}),
|
||||
...(typeof draft === 'boolean' ? { draft } : {}),
|
||||
@@ -6904,6 +7008,20 @@ async function main(options = {}) {
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to create GitHub PR:', error);
|
||||
|
||||
// Check for head validation error (common with fork PRs)
|
||||
const errorMessage = error.message || '';
|
||||
const isHeadValidationError =
|
||||
errorMessage.includes('Validation Failed') &&
|
||||
errorMessage.includes('"field":"head"') &&
|
||||
errorMessage.includes('"code":"invalid"');
|
||||
|
||||
if (isHeadValidationError) {
|
||||
return res.status(400).json({
|
||||
error: 'Unable to create PR: You must have write access to the source repository. Make sure you have pushed your branch to a repository you own (your fork), and that the branch exists on the remote.'
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(500).json({ error: error.message || 'Failed to create GitHub PR' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -43,8 +43,8 @@ export const parseGitHubRemoteUrl = (raw) => {
|
||||
}
|
||||
};
|
||||
|
||||
export async function resolveGitHubRepoFromDirectory(directory) {
|
||||
const remoteUrl = await getRemoteUrl(directory).catch(() => null);
|
||||
export async function resolveGitHubRepoFromDirectory(directory, remoteName = 'origin') {
|
||||
const remoteUrl = await getRemoteUrl(directory, remoteName).catch(() => null);
|
||||
if (!remoteUrl) {
|
||||
return { repo: null, remoteUrl: null };
|
||||
}
|
||||
|
||||
@@ -90,9 +90,14 @@ export const createWebGitHubAPI = (): GitHubAPI => ({
|
||||
return payload;
|
||||
},
|
||||
|
||||
async prStatus(directory: string, branch: string): Promise<GitHubPullRequestStatus> {
|
||||
async prStatus(directory: string, branch: string, remote?: string): Promise<GitHubPullRequestStatus> {
|
||||
const params = new URLSearchParams({
|
||||
directory,
|
||||
branch,
|
||||
...(remote ? { remote } : {}),
|
||||
});
|
||||
const response = await fetch(
|
||||
`/api/github/pr/status?directory=${encodeURIComponent(directory)}&branch=${encodeURIComponent(branch)}`,
|
||||
`/api/github/pr/status?${params.toString()}`,
|
||||
{ method: 'GET', headers: { Accept: 'application/json' } }
|
||||
);
|
||||
const payload = await jsonOrNull<GitHubPullRequestStatus & { error?: string }>(response);
|
||||
|
||||
Reference in New Issue
Block a user