diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index 21676684..c660e23e 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -409,6 +409,8 @@ export const GitView: React.FC = ({ mode = 'full' }) => { const [conflictDialogOpen, setConflictDialogOpen] = React.useState(false); const [conflictFiles, setConflictFiles] = React.useState([]); 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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: ( @@ -873,21 +903,13 @@ export const GitView: React.FC = ({ mode = 'full' }) => { ), }); - } 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 = ({ 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 = ({ mode = 'full' }) => { directory={pullRequestProps.directory} branch={pullRequestProps.branch} baseBranch={baseBranch} + remotes={remotes} onGeneratedDescription={scrollActionPanelToBottom} /> ) : ( @@ -1859,6 +1883,34 @@ export const GitView: React.FC = ({ mode = 'full' }) => { project={branchPickerProject} /> + + + + Select remote + + Choose which remote to push to + + +
+ {remotes.map((remote) => ( + + ))} +
+
+
+ ); }; diff --git a/packages/ui/src/components/views/git/BranchSelector.tsx b/packages/ui/src/components/views/git/BranchSelector.tsx index dd586bee..52470221 100644 --- a/packages/ui/src/components/views/git/BranchSelector.tsx +++ b/packages/ui/src/components/views/git/BranchSelector.tsx @@ -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 | undefined; onCheckout: (branch: string) => void; - onCreate: (name: string) => Promise; + onCreate: (name: string, remote?: GitRemote) => Promise; + remotes?: GitRemote[]; disabled?: boolean; tooltipDelayMs?: number; } @@ -59,16 +62,20 @@ export const BranchSelector: React.FC = ({ 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(null); + const hasMultipleRemotes = remotes.length > 1; + const sanitizedNewBranch = React.useMemo( () => sanitizeBranchNameInput(newBranchName), [newBranchName] @@ -103,9 +110,17 @@ export const BranchSelector: React.FC = ({ 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 = ({ } }; + 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 = ({ No branches found. - {!showCreate ? ( + {showRemoteSelect ? ( + // Remote selection step +
+
+ + + Push {sanitizedNewBranch} to: + +
+
+ {remotes.map((remote) => ( + + ))} +
+
+ ) : !showCreate ? ( Create new branch... diff --git a/packages/ui/src/components/views/git/CommitSection.tsx b/packages/ui/src/components/views/git/CommitSection.tsx index f679ff85..b2885498 100644 --- a/packages/ui/src/components/views/git/CommitSection.tsx +++ b/packages/ui/src/components/views/git/CommitSection.tsx @@ -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 = ({ 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,31 +176,113 @@ export const CommitSection: React.FC = ({ {isMobile ? ( - - - + + + +

Commit & Push

+
+
+ + {remotes.map((remote) => ( + onCommitAndPush(remote)}> +
+ + {remote.name} + + + {remote.pushUrl} + +
+
+ ))} +
+ + ) : ( + + + + + +

Commit & Push

+
+
+ ) + ) : hasMultipleRemotes ? ( + + + {commitAction === 'commitAndPush' ? ( - + <> + + Pushing... + ) : ( - + <> + + Commit & Push + + )} - - - -

Commit & Push

-
- +
+
+ + {remotes.map((remote) => ( + onCommitAndPush(remote)}> +
+ + {remote.name} + + + {remote.pushUrl} + +
+
+ ))} +
+
) : ( onCommitAndPush()} disabled={!canCommit || isGeneratingMessage} className="commit-actions__btn" aria-label="Commit & Push" diff --git a/packages/ui/src/components/views/git/GitHeader.tsx b/packages/ui/src/components/views/git/GitHeader.tsx index 3fe42001..538d8b77 100644 --- a/packages/ui/src/components/views/git/GitHeader.tsx +++ b/packages/ui/src/components/views/git/GitHeader.tsx @@ -40,7 +40,7 @@ interface GitHeaderProps { onPull: (remote: GitRemote) => void; onPush: (remote: GitRemote) => void; onCheckoutBranch: (branch: string) => void; - onCreateBranch: (name: string) => Promise; + onCreateBranch: (name: string, remote?: GitRemote) => Promise; onRenameBranch?: (oldName: string, newName: string) => Promise; activeIdentityProfile: GitIdentityProfile | null; availableIdentities: GitIdentityProfile[]; @@ -301,6 +301,7 @@ export const GitHeader: React.FC = ({ branchInfo={branchInfo} onCheckout={onCheckoutBranch} onCreate={onCreateBranch} + remotes={remotes} tooltipDelayMs={useTwoRowHeader ? 300 : 1000} /> )} @@ -334,6 +335,7 @@ export const GitHeader: React.FC = ({ branchInfo={branchInfo} onCheckout={onCheckoutBranch} onCreate={onCreateBranch} + remotes={remotes} /> )} diff --git a/packages/ui/src/components/views/git/PullRequestSection.tsx b/packages/ui/src/components/views/git/PullRequestSection.tsx index 39052a51..8ca528b5 100644 --- a/packages/ui/src/components/views/git/PullRequestSection.tsx +++ b/packages/ui/src/components/views/git/PullRequestSection.tsx @@ -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(() => 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(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`} ) : null} + {hasMultipleRemotes ? ( + + + + + + {remotes.map((remote) => ( + handleRemoteChange(remote)} + > +
+ + {remote.name} + {remote.name === selectedRemote?.name && ( + + )} + + + {remote.pushUrl} + +
+
+ ))} +
+
+ ) : null} {pr ? ( @@ -1554,7 +1621,7 @@ export const PullRequestSection: React.FC<{ disabled={isCreating || !isConnected} > - {isCreating ? : Create PR diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index e3afeb90..c60cdd02 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -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; me?(): Promise; - prStatus(directory: string, branch: string): Promise; + prStatus(directory: string, branch: string, remote?: string): Promise; prCreate(payload: GitHubPullRequestCreateInput): Promise; prUpdate(payload: GitHubPullRequestUpdateInput): Promise; prMerge(payload: GitHubPullRequestMergeInput): Promise; diff --git a/packages/web/server/index.js b/packages/web/server/index.js index d8de2d65..889bf9cb 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -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' }); } }); diff --git a/packages/web/server/lib/github-repo.js b/packages/web/server/lib/github-repo.js index b9435398..4ca99ead 100644 --- a/packages/web/server/lib/github-repo.js +++ b/packages/web/server/lib/github-repo.js @@ -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 }; } diff --git a/packages/web/src/api/github.ts b/packages/web/src/api/github.ts index 0c846178..be66acd0 100644 --- a/packages/web/src/api/github.ts +++ b/packages/web/src/api/github.ts @@ -90,9 +90,14 @@ export const createWebGitHubAPI = (): GitHubAPI => ({ return payload; }, - async prStatus(directory: string, branch: string): Promise { + async prStatus(directory: string, branch: string, remote?: string): Promise { + 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(response);