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:
gsxdsm
2026-02-09 19:28:41 +02:00
committed by GitHub
parent 1efe8a1cba
commit 6776ac31c2
9 changed files with 468 additions and 54 deletions
+69 -17
View File
@@ -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 &amp; 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>
+5 -1
View File
@@ -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>;
+125 -7
View File
@@ -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' });
}
});
+2 -2
View File
@@ -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 };
}
+7 -2
View File
@@ -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);