Merge remote-tracking branch 'origin/main'

This commit is contained in:
Bohdan Triapitsyn
2026-08-18 21:38:20 +03:00
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;
@@ -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.
+116 -62
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;
// 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.
}
}
}
+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
// ---------------------------------------------------------------------------