fix(git): handle worktrees from forked PRs safely (#2693)

* fix(git): create worktrees from forked PRs via refs/pull/<n>/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/<n>/head, which GitHub serves on the base repository.

- NewWorktreeDialog: when pr.headRepo is absent, send a prRef config
  (refs/pull/<n>/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/<n>/head into
  refs/remotes/<remote>/pr-<n>-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/<n>/head
instead of accepting a cached remotes/<fork>/<branch> tracking ref.
Match the PR base repository by URL (not a hardcoded origin remote),
store fetched PR heads under refs/openchamber/pull/<n>/head, and share
one existing-mode resolver between validate and create.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* 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/<owner>/<repo>/pull/<n>/head, prefer
HTTPS for direct base-repo fallback, and surface composite fork+fallback
errors when both paths fail.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* 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 <makeittech@users.noreply.github.com>

* refactor(git): always checkout linked PRs from refs/pull/<n>/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 <makeittech@users.noreply.github.com>

* 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 <makeittech@users.noreply.github.com>

* 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/<n>/head, create --no-track, done.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* fix(git): meet #2422 Must/Should without refs/pull fallback

Linked PRs send fork identity only; the server provisions pr-<owner>,
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 <makeittech@users.noreply.github.com>

* 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-<number> instead of git worktree add -b on the
colliding name.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* 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 <makeittech@users.noreply.github.com>


Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
Serhii Dziupin
2026-08-18 16:56:55 +03:00
committed by GitHub
co-authored by Serhii Dziupin
parent bf0dfc4e6b
commit 9832c0a4a8
5 changed files with 345 additions and 85 deletions
@@ -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);
@@ -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<string, unknown>;
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<string, unknown>;
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);
});
});
@@ -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-<owner>` via `ensureRemoteName`/`ensureRemoteUrl`
(HTTPS clone URL preferred over SSH) and checks out
`remotes/pr-<owner>/<head>`. 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.
+121 -67
View File
@@ -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.
}
}
}
+129
View File
@@ -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
// ---------------------------------------------------------------------------