Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
// Fetch failed: leave tracking unset. Do not write branch.*.remote/merge
|
||||
// pointing at a ref that was never fetched.
|
||||
return;
|
||||
}
|
||||
|
||||
if (fetched) {
|
||||
await runGitCommandOrThrow(
|
||||
worktreeDirectory,
|
||||
['branch', `--set-upstream-to=${upstream.full}`, localBranch],
|
||||
`Failed to set upstream to ${upstream.full}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await setBranchTrackingFallback(worktreeDirectory, localBranch, upstream);
|
||||
};
|
||||
|
||||
export async function isGitRepository(directory) {
|
||||
@@ -3759,34 +3836,14 @@ 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);
|
||||
inferredUpstream = {
|
||||
remote: parsedExistingRemote.remote,
|
||||
branch: parsedExistingRemote.branch,
|
||||
};
|
||||
} else {
|
||||
const resolved = await resolveBranchForExistingMode(context.primaryWorktree, requestedExistingBranch, preferredBranchName);
|
||||
const resolved = await resolveExistingWorktreeSource(context.primaryWorktree, input, 'validate');
|
||||
localBranch = resolved.localBranch || '';
|
||||
if (resolved.remoteRef) {
|
||||
if (resolved.upstream) {
|
||||
inferredUpstream = {
|
||||
remote: resolved.remoteRef.remote,
|
||||
branch: resolved.remoteRef.branch,
|
||||
remote: resolved.upstream.remote,
|
||||
branch: resolved.upstream.branch,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
code: 'branch_not_found',
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user