Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -98,20 +98,6 @@ const normalizeBranchName = (value: string): string => {
|
|||||||
.replace(/^\/+|\/+$/g, '');
|
.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 sanitizeRemoteName = (value: string): string => {
|
||||||
const normalized = String(value || '')
|
const normalized = String(value || '')
|
||||||
.trim()
|
.trim()
|
||||||
@@ -165,10 +151,14 @@ const resolvePrWorktreeConfig = (pr: GitHubPullRequestSummary, localBranches: st
|
|||||||
const ownerFromLabel = String(pr.headLabel || '').split(':')[0]?.trim();
|
const ownerFromLabel = String(pr.headLabel || '').split(':')[0]?.trim();
|
||||||
const remoteSeed = pr.headRepo?.owner || ownerFromLabel || 'pr-head';
|
const remoteSeed = pr.headRepo?.owner || ownerFromLabel || 'pr-head';
|
||||||
const remoteName = `pr-${sanitizeRemoteName(remoteSeed)}`;
|
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) {
|
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 {
|
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 {
|
interface NewWorktreeDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ type WorktreeListEntry = {
|
|||||||
|
|
||||||
const listCalls: string[] = [];
|
const listCalls: string[] = [];
|
||||||
const listResolvers: Array<(value: WorktreeListEntry[]) => void> = [];
|
const listResolvers: Array<(value: WorktreeListEntry[]) => void> = [];
|
||||||
|
const createPayloads: unknown[] = [];
|
||||||
|
const validatePayloads: unknown[] = [];
|
||||||
const createdWorktree = {
|
const createdWorktree = {
|
||||||
head: 'abc123',
|
head: 'abc123',
|
||||||
name: 'feature',
|
name: 'feature',
|
||||||
@@ -80,7 +82,14 @@ mock.module('@/lib/gitApi', () => ({
|
|||||||
listResolvers.push(resolve);
|
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 })),
|
remove: mock(() => Promise.resolve({ success: true })),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -91,6 +100,7 @@ const {
|
|||||||
getLatestWorktreeMetadata,
|
getLatestWorktreeMetadata,
|
||||||
listProjectWorktrees,
|
listProjectWorktrees,
|
||||||
partitionWorktreesByRegisteredProject,
|
partitionWorktreesByRegisteredProject,
|
||||||
|
validateWorktreeCreate,
|
||||||
worktreeMapsEqual,
|
worktreeMapsEqual,
|
||||||
} = await import('./worktreeManager');
|
} = await import('./worktreeManager');
|
||||||
|
|
||||||
@@ -108,6 +118,8 @@ describe('worktreeManager list invalidation', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
listCalls.length = 0;
|
listCalls.length = 0;
|
||||||
listResolvers.length = 0;
|
listResolvers.length = 0;
|
||||||
|
createPayloads.length = 0;
|
||||||
|
validatePayloads.length = 0;
|
||||||
bootstrapWatcherCalls.length = 0;
|
bootstrapWatcherCalls.length = 0;
|
||||||
bootstrapWatcherOptions.length = 0;
|
bootstrapWatcherOptions.length = 0;
|
||||||
createdWorktreeResult = createdWorktree;
|
createdWorktreeResult = createdWorktree;
|
||||||
@@ -372,3 +384,56 @@ describe('partitionWorktreesByRegisteredProject', () => {
|
|||||||
expect(result.get('/repo')?.map((entry) => entry.path)).toEqual(['/worktrees/loose']);
|
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).
|
- `removeWorktree(directory, input)`: Remove a worktree (optionally delete local branch).
|
||||||
- `isLinkedWorktree(directory)`: Check if directory is a linked worktree (not primary).
|
- `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 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.
|
- `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.
|
- `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 checkRemoteBranchExists = async (primaryWorktree, remoteName, branchName, remoteUrl = '') => {
|
||||||
const remote = String(remoteName || '').trim();
|
const remote = String(remoteName || '').trim();
|
||||||
const branch = String(branchName || '').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 applyUpstreamConfiguration = async (args) => {
|
||||||
const {
|
const {
|
||||||
primaryWorktree,
|
primaryWorktree,
|
||||||
@@ -1952,23 +2033,19 @@ const applyUpstreamConfiguration = async (args) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let fetched = true;
|
|
||||||
try {
|
try {
|
||||||
await fetchRemoteBranchRef(primaryWorktree, upstream.remote, upstream.branch);
|
await fetchRemoteBranchRef(primaryWorktree, upstream.remote, upstream.branch);
|
||||||
} catch {
|
} catch {
|
||||||
fetched = false;
|
// Fetch failed: leave tracking unset. Do not write branch.*.remote/merge
|
||||||
}
|
// pointing at a ref that was never fetched.
|
||||||
|
|
||||||
if (fetched) {
|
|
||||||
await runGitCommandOrThrow(
|
|
||||||
worktreeDirectory,
|
|
||||||
['branch', `--set-upstream-to=${upstream.full}`, localBranch],
|
|
||||||
`Failed to set upstream to ${upstream.full}`
|
|
||||||
);
|
|
||||||
return;
|
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) {
|
export async function isGitRepository(directory) {
|
||||||
@@ -3759,33 +3836,13 @@ export async function validateWorktreeCreate(directory, input = {}) {
|
|||||||
|
|
||||||
if (mode === 'existing') {
|
if (mode === 'existing') {
|
||||||
try {
|
try {
|
||||||
const requestedExistingBranch = String(input?.existingBranch || '').trim();
|
const resolved = await resolveExistingWorktreeSource(context.primaryWorktree, input, 'validate');
|
||||||
const parsedExistingRemote = await resolveRemoteBranchRef(context.primaryWorktree, requestedExistingBranch);
|
localBranch = resolved.localBranch || '';
|
||||||
if (parsedExistingRemote && ensureRemoteName && ensureRemoteUrl && ensureRemoteName === parsedExistingRemote.remote) {
|
if (resolved.upstream) {
|
||||||
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 = {
|
inferredUpstream = {
|
||||||
remote: parsedExistingRemote.remote,
|
remote: resolved.upstream.remote,
|
||||||
branch: parsedExistingRemote.branch,
|
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) {
|
} catch (error) {
|
||||||
errors.push({
|
errors.push({
|
||||||
@@ -3954,25 +4011,19 @@ export async function previewWorktreeCreate(directory, input = {}) {
|
|||||||
|
|
||||||
async function attachGitWorktreeToCandidate(context, candidate, input = {}) {
|
async function attachGitWorktreeToCandidate(context, candidate, input = {}) {
|
||||||
const mode = input?.mode === 'existing' ? 'existing' : 'new';
|
const mode = input?.mode === 'existing' ? 'existing' : 'new';
|
||||||
const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim());
|
|
||||||
const startRef = normalizeStartRef(input?.startRef);
|
const startRef = normalizeStartRef(input?.startRef);
|
||||||
const ensureRemoteName = String(input?.ensureRemoteName || '').trim();
|
let ensureRemoteName = String(input?.ensureRemoteName || '').trim();
|
||||||
const ensureRemoteUrl = String(input?.ensureRemoteUrl || '').trim();
|
let ensureRemoteUrl = String(input?.ensureRemoteUrl || '').trim();
|
||||||
|
|
||||||
let localBranch = '';
|
let localBranch = '';
|
||||||
let inferredUpstream = null;
|
let inferredUpstream = null;
|
||||||
|
let shouldSetUpstream = Boolean(input?.setUpstream);
|
||||||
const worktreeAddArgs = ['worktree', 'add', '--no-checkout'];
|
const worktreeAddArgs = ['worktree', 'add', '--no-checkout'];
|
||||||
|
|
||||||
if (mode === 'existing') {
|
if (mode === 'existing') {
|
||||||
const requestedExistingBranch = String(input?.existingBranch || '').trim();
|
const resolved = await resolveExistingWorktreeSource(context.primaryWorktree, input, 'create');
|
||||||
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);
|
|
||||||
localBranch = resolved.localBranch;
|
localBranch = resolved.localBranch;
|
||||||
|
shouldSetUpstream = resolved.setUpstream;
|
||||||
|
|
||||||
const inUse = await findBranchInUse(context.primaryWorktree, localBranch);
|
const inUse = await findBranchInUse(context.primaryWorktree, localBranch);
|
||||||
if (inUse) {
|
if (inUse) {
|
||||||
@@ -3984,10 +4035,10 @@ async function attachGitWorktreeToCandidate(context, candidate, input = {}) {
|
|||||||
}
|
}
|
||||||
worktreeAddArgs.push(candidate.directory, resolved.checkoutRef);
|
worktreeAddArgs.push(candidate.directory, resolved.checkoutRef);
|
||||||
|
|
||||||
if (resolved.remoteRef) {
|
if (resolved.upstream) {
|
||||||
inferredUpstream = {
|
inferredUpstream = {
|
||||||
remote: resolved.remoteRef.remote,
|
remote: resolved.upstream.remote,
|
||||||
branch: resolved.remoteRef.branch,
|
branch: resolved.upstream.branch,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -4033,9 +4084,12 @@ async function attachGitWorktreeToCandidate(context, candidate, input = {}) {
|
|||||||
|
|
||||||
await runGitCommandOrThrow(context.primaryWorktree, worktreeAddArgs, 'Failed to create git worktree');
|
await runGitCommandOrThrow(context.primaryWorktree, worktreeAddArgs, 'Failed to create git worktree');
|
||||||
|
|
||||||
const shouldSetUpstream = Boolean(input?.setUpstream);
|
const upstreamRemote = shouldSetUpstream
|
||||||
const upstreamRemote = String(input?.upstreamRemote || inferredUpstream?.remote || '').trim();
|
? String(inferredUpstream?.remote || input?.upstreamRemote || '').trim()
|
||||||
const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim();
|
: '';
|
||||||
|
const upstreamBranch = shouldSetUpstream
|
||||||
|
? String(inferredUpstream?.branch || input?.upstreamBranch || '').trim()
|
||||||
|
: '';
|
||||||
|
|
||||||
const bootstrapStatus = setWorktreeBootstrapState(
|
const bootstrapStatus = setWorktreeBootstrapState(
|
||||||
candidate.directory,
|
candidate.directory,
|
||||||
@@ -4697,7 +4751,7 @@ export async function renameBranch(directory, oldName, newName) {
|
|||||||
`Failed to set upstream to ${upstream.full}`
|
`Failed to set upstream to ${upstream.full}`
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
await setBranchTrackingFallback(repoRoot, normalizedNewName, upstream);
|
// Leave tracking unset rather than writing config for a missing ref.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
applyHunk,
|
applyHunk,
|
||||||
getDiff,
|
getDiff,
|
||||||
getFileDiff,
|
getFileDiff,
|
||||||
|
validateWorktreeCreate,
|
||||||
} from './service.js';
|
} 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
|
// removeWorktree
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user