From 9832c0a4a8cf7c2e5b06e0799b4cae62a5b611c5 Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Tue, 18 Aug 2026 16:56:55 +0300 Subject: [PATCH] fix(git): handle worktrees from forked PRs safely (#2693) * fix(git): create worktrees from forked PRs via refs/pull//head fallback A worktree created from a linked GitHub PR whose head branch lives in a fork failed when the fork's head repository was missing (deleted fork) or unfetchable (auth, network): the dialog threw 'PR head repository URL is unavailable' before any git command ran, and the server had no fallback to refs/pull//head, which GitHub serves on the base repository. - NewWorktreeDialog: when pr.headRepo is absent, send a prRef config (refs/pull//head from origin) instead of throwing; the fork config now also carries prRef so the server can fall back when the fork fetch fails. - git service: fetchPullRequestHeadRef fetches refs/pull//head into refs/remotes//pr--head (same refspec shape as fetchRemoteBranchRef) and both validateWorktreeCreate and attachGitWorktreeToCandidate fall back to it when the fork path fails; fallback worktrees get --no-track and no upstream config because a PR ref is not pushable. When both paths fail the original fork error surfaces. - Focused tests cover the prRef-only path and the fork-unreachable fallback. Fixes #2422 * fix(git): harden PR worktree fallback against stale fork refs (#12) After a fork fetch fails, resolve immediately from refs/pull//head instead of accepting a cached remotes// tracking ref. Match the PR base repository by URL (not a hardcoded origin remote), store fetched PR heads under refs/openchamber/pull//head, and share one existing-mode resolver between validate and create. Co-authored-by: Cursor Agent Co-authored-by: Serhii Dziupin * fix(git): make PR head SHA authoritative and namespace private refs Reuse local/remote branches for linked PRs only when their tip matches pr.headSha; otherwise fall through to fork fetch / refs/pull. Store PR heads under refs/openchamber/github///pull//head, prefer HTTPS for direct base-repo fallback, and surface composite fork+fallback errors when both paths fail. Co-authored-by: Serhii Dziupin * test(ui): assert validate/create forward deleted-fork PR payload fields Guards the dialog wiring regression where validate omitted prRef while create included it, by asserting worktreeManager forwards prRef, prBaseRepoUrl, and related fields for deleted-fork configs. Co-authored-by: Serhii Dziupin * refactor(git): always checkout linked PRs from refs/pull//head Move PR worktree resolution to the server. The UI now sends only pullRequest identity (number + baseRepoUrl + optional head fields); the server always fetches the authoritative PR head and best-effort configures fork upstream afterward. Co-authored-by: Serhii Dziupin * refactor(ui): drop PrWorktreeConfig; send PR identity only Delete the prWorktreeConfig module. NewWorktreeDialog maps linked PRs straight to pullRequest identity, skips upstream defaults for that path, and leaves checkout + optional fork tracking to the server. Co-authored-by: Serhii Dziupin * refactor(git): linked PRs are {number, baseRepoUrl} only Drop fork upstream / tracking and head/base owner-repo fields from the linked-PR worktree path. Fetch refs/pull//head, create --no-track, done. Co-authored-by: Serhii Dziupin * fix(git): meet #2422 Must/Should without refs/pull fallback Linked PRs send fork identity only; the server provisions pr-, fetches the head branch, and fails clearly when the fork is missing or unreachable. Local reuse requires a matching headSha. Prefer HTTPS for headRepoUrl. Do not write upstream tracking when the upstream ref was never fetched. Co-authored-by: Serhii Dziupin * fix(git): drop invalid upstream fallback and PR branch collisions Remove setBranchTrackingFallback: if upstream fetch fails, leave tracking unset. When a linked PR's head branch already exists locally with a different tip, create pr- instead of git worktree add -b on the colliding name. Co-authored-by: Serhii Dziupin * fix(git): strip PR worktree create back to fork-remote provision (#15) Keep the original ensureRemoteName/Url path for linked fork PRs, prefer HTTPS clone URLs, fail clearly when the fork is unreachable, and leave upstream tracking unset when the upstream ref was never fetched. Co-authored-by: Serhii Dziupin Co-authored-by: Serhii Dziupin --- .../components/session/NewWorktreeDialog.tsx | 38 ++-- .../src/lib/worktrees/worktreeManager.test.ts | 67 ++++++- packages/web/server/lib/git/DOCUMENTATION.md | 8 + packages/web/server/lib/git/service.js | 188 +++++++++++------- packages/web/server/lib/git/service.test.js | 129 ++++++++++++ 5 files changed, 345 insertions(+), 85 deletions(-) diff --git a/packages/ui/src/components/session/NewWorktreeDialog.tsx b/packages/ui/src/components/session/NewWorktreeDialog.tsx index d9130d92..5fc9f2c3 100644 --- a/packages/ui/src/components/session/NewWorktreeDialog.tsx +++ b/packages/ui/src/components/session/NewWorktreeDialog.tsx @@ -98,20 +98,6 @@ const normalizeBranchName = (value: string): string => { .replace(/^\/+|\/+$/g, ''); }; -const slugifyWorktreeName = (value: string): string => { - return value - .trim() - .replace(/^refs\/heads\//, '') - .replace(/^heads\//, '') - .replace(/\s+/g, '-') - .replace(/^\/+|\/+$/g, '') - .split('/').join('-') - .replace(/[^A-Za-z0-9._-]+/g, '-') - .replace(/-+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 80); -}; - const sanitizeRemoteName = (value: string): string => { const normalized = String(value || '') .trim() @@ -165,10 +151,14 @@ const resolvePrWorktreeConfig = (pr: GitHubPullRequestSummary, localBranches: st const ownerFromLabel = String(pr.headLabel || '').split(':')[0]?.trim(); const remoteSeed = pr.headRepo?.owner || ownerFromLabel || 'pr-head'; const remoteName = `pr-${sanitizeRemoteName(remoteSeed)}`; - const remoteUrl = pr.headRepo?.sshUrl || pr.headRepo?.cloneUrl || ''; + // Prefer HTTPS so anonymous public fetches do not require SSH agent setup. + const remoteUrl = pr.headRepo?.cloneUrl || pr.headRepo?.sshUrl || ''; if (!remoteUrl) { - throw new Error('PR head repository URL is unavailable'); + throw new Error( + 'PR head repository URL is unavailable. The fork may have been deleted; ' + + 'push the branch to a reachable repository and try again.' + ); } return { @@ -182,6 +172,20 @@ const resolvePrWorktreeConfig = (pr: GitHubPullRequestSummary, localBranches: st }; }; +const slugifyWorktreeName = (value: string): string => { + return value + .trim() + .replace(/^refs\/heads\//, '') + .replace(/^heads\//, '') + .replace(/\s+/g, '-') + .replace(/^\/+|\/+$/g, '') + .split('/').join('-') + .replace(/[^A-Za-z0-9._-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80); +}; + interface NewWorktreeDialogProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -898,7 +902,7 @@ export function NewWorktreeDialog({ ...(sourceBranch && mode === 'new-branch' ? { startRef: sourceBranch } : {}), }; })(); - + const resolvedArgs = await withWorktreeUpstreamDefaults(projectDirectory, args); const metadata = await createWorktree(projectRef, resolvedArgs); diff --git a/packages/ui/src/lib/worktrees/worktreeManager.test.ts b/packages/ui/src/lib/worktrees/worktreeManager.test.ts index 71c6ccb4..772db94b 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.test.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.test.ts @@ -11,6 +11,8 @@ type WorktreeListEntry = { const listCalls: string[] = []; const listResolvers: Array<(value: WorktreeListEntry[]) => void> = []; +const createPayloads: unknown[] = []; +const validatePayloads: unknown[] = []; const createdWorktree = { head: 'abc123', name: 'feature', @@ -80,7 +82,14 @@ mock.module('@/lib/gitApi', () => ({ listResolvers.push(resolve); }); }, - create: mock(() => Promise.resolve(createdWorktreeResult)), + create: mock((_directory: string, payload: unknown) => { + createPayloads.push(payload); + return Promise.resolve(createdWorktreeResult); + }), + validate: mock((_directory: string, payload: unknown) => { + validatePayloads.push(payload); + return Promise.resolve({ ok: true, errors: [] }); + }), remove: mock(() => Promise.resolve({ success: true })), }, }, @@ -91,6 +100,7 @@ const { getLatestWorktreeMetadata, listProjectWorktrees, partitionWorktreesByRegisteredProject, + validateWorktreeCreate, worktreeMapsEqual, } = await import('./worktreeManager'); @@ -108,6 +118,8 @@ describe('worktreeManager list invalidation', () => { beforeEach(() => { listCalls.length = 0; listResolvers.length = 0; + createPayloads.length = 0; + validatePayloads.length = 0; bootstrapWatcherCalls.length = 0; bootstrapWatcherOptions.length = 0; createdWorktreeResult = createdWorktree; @@ -372,3 +384,56 @@ describe('partitionWorktreesByRegisteredProject', () => { expect(result.get('/repo')?.map((entry) => entry.path)).toEqual(['/worktrees/loose']); }); }); + +describe('worktreeManager fork remote payload wiring', () => { + beforeEach(() => { + listCalls.length = 0; + listResolvers.length = 0; + createPayloads.length = 0; + validatePayloads.length = 0; + bootstrapWatcherCalls.length = 0; + bootstrapWatcherOptions.length = 0; + createdWorktreeResult = createdWorktree; + sessionState.availableWorktreesByProject = new Map(); + sessionState.availableWorktrees = []; + sessionState.worktreeMetadata = new Map(); + attachmentState.attachments = new Map(); + }); + + test('validate and create forward ensureRemoteName/Url for a fork head', async () => { + const project = { id: 'project-1', path: '/repo' }; + const args = { + mode: 'existing' as const, + branchName: 'feature/login', + worktreeName: 'pr-42', + existingBranch: 'remotes/pr-alice/feature/login', + setUpstream: true as const, + upstreamRemote: 'pr-alice', + upstreamBranch: 'feature/login', + ensureRemoteName: 'pr-alice', + ensureRemoteUrl: 'https://github.com/alice/openchamber.git', + }; + + const validation = await validateWorktreeCreate(project, args); + expect(validation.ok).toBe(true); + expect(validatePayloads).toHaveLength(1); + const validated = validatePayloads[0] as Record; + expect(validated.mode).toBe('existing'); + expect(validated.existingBranch).toBe('remotes/pr-alice/feature/login'); + expect(validated.ensureRemoteName).toBe('pr-alice'); + expect(validated.ensureRemoteUrl).toBe('https://github.com/alice/openchamber.git'); + expect('pullRequest' in validated).toBe(false); + + await createWorktree(project, { + ...args, + returnAfterDirectoryCreated: true, + }); + expect(createPayloads).toHaveLength(1); + const created = createPayloads[0] as Record; + expect(created.existingBranch).toBe('remotes/pr-alice/feature/login'); + expect(created.ensureRemoteName).toBe('pr-alice'); + expect(created.ensureRemoteUrl).toBe('https://github.com/alice/openchamber.git'); + expect(created.setUpstream).toBe(true); + expect('pullRequest' in created).toBe(false); + }); +}); diff --git a/packages/web/server/lib/git/DOCUMENTATION.md b/packages/web/server/lib/git/DOCUMENTATION.md index 10743664..3488bc5f 100644 --- a/packages/web/server/lib/git/DOCUMENTATION.md +++ b/packages/web/server/lib/git/DOCUMENTATION.md @@ -52,6 +52,14 @@ The following functions are exported and used by the web server: - `removeWorktree(directory, input)`: Remove a worktree (optionally delete local branch). - `isLinkedWorktree(directory)`: Check if directory is a linked worktree (not primary). +### Worktree creation from a GitHub pull request +The UI provisions `pr-` via `ensureRemoteName`/`ensureRemoteUrl` +(HTTPS clone URL preferred over SSH) and checks out +`remotes/pr-/`. A missing head URL or unreachable fork fails with +a clear error before a worktree is kept. If upstream fetch fails during +bootstrap, tracking is left unset rather than writing `branch.*.remote` / +`branch.*.merge` for a ref that was never fetched. + ### Commit and Remote Operations - `commit(directory, message, options)`: Create a commit from the current index. `options.stageFiles` may be provided with `options.files` by older callers to stage only selected unstaged rows before committing, but the shared Git panel now stages/unstages explicitly before commit. - `pull(directory, options)`: Pull changes from remote. diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index 04baa715..e82d3774 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -1891,6 +1891,100 @@ const fetchRemoteBranchRef = async (primaryWorktree, remoteName, branchName) => ); }; +/** + * Shared existing-mode resolver for validate + create. + * Provisioned remotes (`ensureRemoteName`/`ensureRemoteUrl`) are used for fork + * PR heads; other existing branches keep the local / already-fetched remote path. + * + * @param {'validate'|'create'} intent + */ +const resolveExistingWorktreeSource = async (primaryWorktree, input = {}, intent = 'create') => { + const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim()); + const ensureRemoteName = String(input?.ensureRemoteName || '').trim(); + const ensureRemoteUrl = String(input?.ensureRemoteUrl || '').trim(); + const requestedExistingBranch = String(input?.existingBranch || '').trim(); + const wantUpstream = Boolean(input?.setUpstream); + const explicitUpstreamRemote = String(input?.upstreamRemote || '').trim(); + const explicitUpstreamBranch = String(input?.upstreamBranch || '').trim(); + const parsedExistingRemote = await resolveRemoteBranchRef(primaryWorktree, requestedExistingBranch); + + if ( + parsedExistingRemote + && ensureRemoteName + && ensureRemoteUrl + && parsedExistingRemote.remote === ensureRemoteName + ) { + if (intent === 'validate') { + const lsRemote = await runGitCommand( + primaryWorktree, + ['ls-remote', '--heads', ensureRemoteUrl, `refs/heads/${parsedExistingRemote.branch}`] + ); + if (!lsRemote.success) { + throw new Error( + `Unable to reach remote ${ensureRemoteName} (${ensureRemoteUrl}). ` + + 'Check network access and credentials for that repository.' + ); + } + if (!String(lsRemote.stdout || '').trim()) { + throw new Error(`Remote branch not found: ${parsedExistingRemote.remoteRef}`); + } + } else { + await ensureRemoteWithUrl(primaryWorktree, ensureRemoteName, ensureRemoteUrl); + try { + await fetchRemoteBranchRef( + primaryWorktree, + parsedExistingRemote.remote, + parsedExistingRemote.branch + ); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error( + `Unable to fetch ${parsedExistingRemote.remote}/${parsedExistingRemote.branch} ` + + `from ${ensureRemoteUrl}. ${detail}` + ); + } + } + + const localBranch = cleanBranchName(preferredBranchName || parsedExistingRemote.branch); + return { + localBranch, + checkoutRef: parsedExistingRemote.remoteRef, + createLocalBranch: true, + setUpstream: wantUpstream, + upstream: { + remote: explicitUpstreamRemote || parsedExistingRemote.remote, + branch: explicitUpstreamBranch || parsedExistingRemote.branch, + }, + }; + } + + if (!requestedExistingBranch) { + throw new Error('existingBranch is required in existing mode'); + } + + const resolved = await resolveBranchForExistingMode( + primaryWorktree, + requestedExistingBranch, + preferredBranchName + ); + const upstream = resolved.remoteRef + ? { + remote: explicitUpstreamRemote || resolved.remoteRef.remote, + branch: explicitUpstreamBranch || resolved.remoteRef.branch, + } + : (explicitUpstreamRemote && explicitUpstreamBranch + ? { remote: explicitUpstreamRemote, branch: explicitUpstreamBranch } + : null); + + return { + localBranch: resolved.localBranch, + checkoutRef: resolved.checkoutRef, + createLocalBranch: resolved.createLocalBranch, + setUpstream: wantUpstream && Boolean(upstream), + upstream, + }; +}; + const checkRemoteBranchExists = async (primaryWorktree, remoteName, branchName, remoteUrl = '') => { const remote = String(remoteName || '').trim(); const branch = String(branchName || '').trim(); @@ -1914,19 +2008,6 @@ const checkRemoteBranchExists = async (primaryWorktree, remoteName, branchName, }; }; -const setBranchTrackingFallback = async (worktreeDirectory, localBranch, upstream) => { - await runGitCommandOrThrow( - worktreeDirectory, - ['config', `branch.${localBranch}.remote`, upstream.remote], - `Failed to set branch.${localBranch}.remote` - ); - await runGitCommandOrThrow( - worktreeDirectory, - ['config', `branch.${localBranch}.merge`, `refs/heads/${upstream.branch}`], - `Failed to set branch.${localBranch}.merge` - ); -}; - const applyUpstreamConfiguration = async (args) => { const { primaryWorktree, @@ -1952,23 +2033,19 @@ const applyUpstreamConfiguration = async (args) => { return; } - let fetched = true; try { await fetchRemoteBranchRef(primaryWorktree, upstream.remote, upstream.branch); } catch { - fetched = false; - } - - if (fetched) { - await runGitCommandOrThrow( - worktreeDirectory, - ['branch', `--set-upstream-to=${upstream.full}`, localBranch], - `Failed to set upstream to ${upstream.full}` - ); + // Fetch failed: leave tracking unset. Do not write branch.*.remote/merge + // pointing at a ref that was never fetched. return; } - await setBranchTrackingFallback(worktreeDirectory, localBranch, upstream); + await runGitCommandOrThrow( + worktreeDirectory, + ['branch', `--set-upstream-to=${upstream.full}`, localBranch], + `Failed to set upstream to ${upstream.full}` + ); }; export async function isGitRepository(directory) { @@ -3759,33 +3836,13 @@ export async function validateWorktreeCreate(directory, input = {}) { if (mode === 'existing') { try { - const requestedExistingBranch = String(input?.existingBranch || '').trim(); - const parsedExistingRemote = await resolveRemoteBranchRef(context.primaryWorktree, requestedExistingBranch); - if (parsedExistingRemote && ensureRemoteName && ensureRemoteUrl && ensureRemoteName === parsedExistingRemote.remote) { - const lsRemote = await runGitCommand( - context.primaryWorktree, - ['ls-remote', '--heads', ensureRemoteUrl, `refs/heads/${parsedExistingRemote.branch}`] - ); - if (!lsRemote.success) { - throw new Error(`Unable to query remote ${ensureRemoteName}`); - } - if (!String(lsRemote.stdout || '').trim()) { - throw new Error(`Remote branch not found: ${parsedExistingRemote.remoteRef}`); - } - localBranch = cleanBranchName(preferredBranchName || parsedExistingRemote.branch); + const resolved = await resolveExistingWorktreeSource(context.primaryWorktree, input, 'validate'); + localBranch = resolved.localBranch || ''; + if (resolved.upstream) { inferredUpstream = { - remote: parsedExistingRemote.remote, - branch: parsedExistingRemote.branch, + remote: resolved.upstream.remote, + branch: resolved.upstream.branch, }; - } else { - const resolved = await resolveBranchForExistingMode(context.primaryWorktree, requestedExistingBranch, preferredBranchName); - localBranch = resolved.localBranch || ''; - if (resolved.remoteRef) { - inferredUpstream = { - remote: resolved.remoteRef.remote, - branch: resolved.remoteRef.branch, - }; - } } } catch (error) { errors.push({ @@ -3954,25 +4011,19 @@ export async function previewWorktreeCreate(directory, input = {}) { async function attachGitWorktreeToCandidate(context, candidate, input = {}) { const mode = input?.mode === 'existing' ? 'existing' : 'new'; - const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim()); const startRef = normalizeStartRef(input?.startRef); - const ensureRemoteName = String(input?.ensureRemoteName || '').trim(); - const ensureRemoteUrl = String(input?.ensureRemoteUrl || '').trim(); + let ensureRemoteName = String(input?.ensureRemoteName || '').trim(); + let ensureRemoteUrl = String(input?.ensureRemoteUrl || '').trim(); let localBranch = ''; let inferredUpstream = null; + let shouldSetUpstream = Boolean(input?.setUpstream); const worktreeAddArgs = ['worktree', 'add', '--no-checkout']; if (mode === 'existing') { - const requestedExistingBranch = String(input?.existingBranch || '').trim(); - const parsedExistingRemote = await resolveRemoteBranchRef(context.primaryWorktree, requestedExistingBranch); - if (parsedExistingRemote && ensureRemoteName && ensureRemoteUrl && parsedExistingRemote.remote === ensureRemoteName) { - await ensureRemoteWithUrl(context.primaryWorktree, ensureRemoteName, ensureRemoteUrl); - await fetchRemoteBranchRef(context.primaryWorktree, parsedExistingRemote.remote, parsedExistingRemote.branch); - } - - const resolved = await resolveBranchForExistingMode(context.primaryWorktree, requestedExistingBranch, preferredBranchName); + const resolved = await resolveExistingWorktreeSource(context.primaryWorktree, input, 'create'); localBranch = resolved.localBranch; + shouldSetUpstream = resolved.setUpstream; const inUse = await findBranchInUse(context.primaryWorktree, localBranch); if (inUse) { @@ -3984,10 +4035,10 @@ async function attachGitWorktreeToCandidate(context, candidate, input = {}) { } worktreeAddArgs.push(candidate.directory, resolved.checkoutRef); - if (resolved.remoteRef) { + if (resolved.upstream) { inferredUpstream = { - remote: resolved.remoteRef.remote, - branch: resolved.remoteRef.branch, + remote: resolved.upstream.remote, + branch: resolved.upstream.branch, }; } } else { @@ -4033,9 +4084,12 @@ async function attachGitWorktreeToCandidate(context, candidate, input = {}) { await runGitCommandOrThrow(context.primaryWorktree, worktreeAddArgs, 'Failed to create git worktree'); - const shouldSetUpstream = Boolean(input?.setUpstream); - const upstreamRemote = String(input?.upstreamRemote || inferredUpstream?.remote || '').trim(); - const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim(); + const upstreamRemote = shouldSetUpstream + ? String(inferredUpstream?.remote || input?.upstreamRemote || '').trim() + : ''; + const upstreamBranch = shouldSetUpstream + ? String(inferredUpstream?.branch || input?.upstreamBranch || '').trim() + : ''; const bootstrapStatus = setWorktreeBootstrapState( candidate.directory, @@ -4697,7 +4751,7 @@ export async function renameBranch(directory, oldName, newName) { `Failed to set upstream to ${upstream.full}` ); } catch { - await setBranchTrackingFallback(repoRoot, normalizedNewName, upstream); + // Leave tracking unset rather than writing config for a missing ref. } } } diff --git a/packages/web/server/lib/git/service.test.js b/packages/web/server/lib/git/service.test.js index 3127fe92..88baecc3 100644 --- a/packages/web/server/lib/git/service.test.js +++ b/packages/web/server/lib/git/service.test.js @@ -27,6 +27,7 @@ import { applyHunk, getDiff, getFileDiff, + validateWorktreeCreate, } from './service.js'; // --------------------------------------------------------------------------- @@ -806,6 +807,134 @@ describe('createWorktree', () => { }); }); +// --------------------------------------------------------------------------- +// createWorktree from a forked GitHub PR head (issue #2422) +// --------------------------------------------------------------------------- + +describe('createWorktree from a forked GitHub PR', () => { + const withDataHome = async (test) => { + const previousXdgDataHome = process.env.XDG_DATA_HOME; + const dataHome = createTempDir(); + process.env.XDG_DATA_HOME = dataHome; + try { + await test(dataHome); + } finally { + if (previousXdgDataHome === undefined) { + delete process.env.XDG_DATA_HOME; + } else { + process.env.XDG_DATA_HOME = previousXdgDataHome; + } + } + }; + + const publishForkHead = (repository, forkBare, branchName) => { + fs.writeFileSync(path.join(repository, 'FORK.md'), `# ${branchName}\n`); + runGit(repository, ['add', 'FORK.md']); + runGit(repository, ['commit', '-m', `fork ${branchName}`]); + const sha = runGit(repository, ['rev-parse', 'HEAD']).trim(); + runGit(repository, ['push', forkBare, `HEAD:refs/heads/${branchName}`]); + return sha; + }; + + const getBranchTrackingRemote = (directory, branch) => { + try { + return runGit(directory, ['config', '--get', `branch.${branch}.remote`]).trim(); + } catch { + return ''; + } + }; + + const forkWorktreeInput = ({ fork, worktreeName }) => ({ + mode: 'existing', + branchName: 'feature/login', + worktreeName, + existingBranch: 'remotes/pr-alice/feature/login', + setUpstream: true, + upstreamRemote: 'pr-alice', + upstreamBranch: 'feature/login', + ensureRemoteName: 'pr-alice', + ensureRemoteUrl: fork, + }); + + it('creates a worktree from a reachable fork head remote', async () => { + if (!canRunGit()) return; + + await withDataHome(async () => { + const { repository } = createRepositoryWithRemote(); + const fork = createTempDir(); + runGit(fork, ['init', '--bare']); + const sha = publishForkHead(repository, fork, 'feature/login'); + + const created = await createWorktree(repository, forkWorktreeInput({ + fork, + worktreeName: 'pr-42', + })); + + expect(created.branch).toBe('feature/login'); + expect(runGit(created.path, ['rev-parse', 'HEAD']).trim()).toBe(sha); + await expect.poll(() => fs.existsSync(path.join(created.path, 'FORK.md')), { timeout: 5_000 }).toBe(true); + expect(runGit(repository, ['remote', 'get-url', 'pr-alice']).trim()).toBe(fork); + await expect.poll( + () => getBranchTrackingRemote(created.path, 'feature/login') === 'pr-alice', + { timeout: 5_000 } + ).toBe(true); + }); + }, 30_000); + + it('rejects an unreachable fork with an actionable error and no worktree', async () => { + if (!canRunGit()) return; + + await withDataHome(async () => { + const { repository } = createRepositoryWithRemote(); + const missingFork = path.join(createTempDir(), 'missing-fork.git'); + const before = runGit(repository, ['worktree', 'list', '--porcelain']); + + await expect(createWorktree(repository, forkWorktreeInput({ + fork: missingFork, + worktreeName: 'pr-42-unreachable', + }))).rejects.toThrow(/Unable to (reach|fetch)/i); + + expect(runGit(repository, ['worktree', 'list', '--porcelain'])).toBe(before); + + const validation = await validateWorktreeCreate(repository, forkWorktreeInput({ + fork: missingFork, + worktreeName: 'pr-42-unreachable', + })); + expect(validation.ok).toBe(false); + expect(validation.errors.some((error) => /Unable to (reach|fetch)/i.test(error.message))).toBe(true); + }); + }, 30_000); + + it('does not write upstream tracking when the upstream ref cannot be fetched', async () => { + if (!canRunGit()) return; + + await withDataHome(async () => { + const { repository } = createRepositoryWithRemote(); + runGit(repository, ['branch', 'feature/tracking']); + const emptyRemote = createTempDir(); + runGit(emptyRemote, ['init', '--bare']); + runGit(repository, ['remote', 'add', 'broken-upstream', emptyRemote]); + + const created = await createWorktree(repository, { + mode: 'existing', + branchName: 'feature/tracking-wt', + worktreeName: 'feature-tracking-wt', + existingBranch: 'feature/tracking', + setUpstream: true, + upstreamRemote: 'broken-upstream', + upstreamBranch: 'does-not-exist', + }); + + await expect.poll( + () => getWorktreeBootstrapStatus(created.path).then((status) => status.status === 'ready' || status.status === 'failed'), + { timeout: 5_000 } + ).toBe(true); + + expect(getBranchTrackingRemote(created.path, 'feature/tracking-wt')).toBe(''); + }); + }, 30_000); +}); + // --------------------------------------------------------------------------- // removeWorktree // ---------------------------------------------------------------------------