Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b44c1ed
Initial plan
Copilot Feb 13, 2026
cf08027
initial plan for checkout PR in worktree feature
Copilot Feb 13, 2026
40d6d90
feat: add checkout pull request in worktree option
Copilot Feb 13, 2026
ecc650d
fix: use VS Code Tasks API for reliable cross-platform worktree creation
Copilot Feb 13, 2026
fab1204
fix: address code review feedback - improve error handling and branch…
Copilot Feb 13, 2026
80e46a2
argument clean up and keep branch name
alexr00 Feb 13, 2026
103da71
Update git API
alexr00 Feb 13, 2026
04c416a
refactor: use git extension API for worktree creation instead of shel…
Copilot Feb 13, 2026
ad74445
fix: run fetch and worktree selection in parallel, move info message …
Copilot Feb 13, 2026
3e1c44e
Fix createWorktree call
alexr00 Feb 13, 2026
766d8a0
fix: start progress before fetch operation, include fetch in progress…
Copilot Feb 13, 2026
fa08fb7
feat: add 'Checkout in Worktree' option to PR Description checkout bu…
Copilot Apr 7, 2026
8ea92b4
Merge branch 'main' into copilot/add-worktree-checkout-option
alexr00 Apr 17, 2026
db793c6
fix: handle existing local branch when creating worktree
Copilot Apr 17, 2026
5f65fda
feat: use modal dialog with new window and current window options for…
Copilot Apr 17, 2026
af07a44
feat: add pr.pickInWorktreeFromDescription command for PR overview ch…
Copilot Apr 17, 2026
ed22158
refactor: extract worktree checkout logic into standalone utility fun…
Copilot Apr 17, 2026
b79ac67
Fix strings
alexr00 Apr 24, 2026
4fc3bc1
fix strings
alexr00 Apr 24, 2026
897e23d
fix: stop if fetch fails and use commands.openFolder helper
Copilot Apr 24, 2026
290ab97
CCR cleanup
alexr00 Apr 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -1013,6 +1013,12 @@
"category": "%command.pull.request.category%",
"icon": "$(cloud)"
},
{
"command": "pr.pickInWorktree",
"title": "%command.pr.pickInWorktree.title%",
Comment thread
alexr00 marked this conversation as resolved.
"category": "%command.pull.request.category%",
"icon": "$(folder-library)"
},
{
"command": "pr.exit",
"title": "%command.pr.exit.title%",
Expand Down Expand Up @@ -2084,6 +2090,10 @@
"command": "pr.pickOnCodespaces",
"when": "false"
},
{
"command": "pr.pickInWorktree",
"when": "false"
},
{
"command": "pr.exit",
"when": "github:inReviewMode"
Expand Down Expand Up @@ -2882,6 +2892,11 @@
"when": "view == pr:github && viewItem =~ /pullrequest(:local)?:nonactive/ && (!isWeb || remoteName != codespaces && virtualWorkspace != vscode-vfs)",
"group": "1_pullrequest@3"
},
{
"command": "pr.pickInWorktree",
"when": "view == pr:github && viewItem =~ /pullrequest(:local)?:nonactive/ && !isWeb",
"group": "1_pullrequest@4"
},
{
"command": "pr.openChanges",
"when": "view =~ /(pr|prStatus):github/ && viewItem =~ /(pullrequest|description)/",
Expand Down
1 change: 1 addition & 0 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@
"command.pr.openChanges.title": "Open Changes",
"command.pr.pickOnVscodeDev.title": "Checkout Pull Request on vscode.dev",
"command.pr.pickOnCodespaces.title": "Checkout Pull Request on Codespaces",
"command.pr.pickInWorktree.title": "Checkout Pull Request in Worktree",
"command.pr.exit.title": "Checkout Default Branch",
"command.pr.dismissNotification.title": "Dismiss Notification",
"command.pr.markAllCopilotNotificationsAsRead.title": "Dismiss All Copilot Notifications",
Expand Down
134 changes: 134 additions & 0 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -825,6 +825,140 @@ export function registerCommands(
),
);

context.subscriptions.push(
vscode.commands.registerCommand('pr.pickInWorktree', async (pr: PRNode | PullRequestModel | unknown) => {
if (pr === undefined) {
Logger.error('Unexpectedly received undefined when picking a PR for worktree checkout.', logId);
return vscode.window.showErrorMessage(vscode.l10n.t('No pull request was selected to checkout, please try again.'));
}

let pullRequestModel: PullRequestModel;
let repository: Repository | undefined;

if (pr instanceof PRNode) {
pullRequestModel = pr.pullRequestModel;
repository = pr.repository;
} else if (pr instanceof PullRequestModel) {
pullRequestModel = pr;
} else {
Logger.error('Unexpectedly received unknown type when picking a PR for worktree checkout.', logId);
return vscode.window.showErrorMessage(vscode.l10n.t('No pull request was selected to checkout, please try again.'));
}

// Validate that the PR has a valid head branch
if (!pullRequestModel.head) {
return vscode.window.showErrorMessage(vscode.l10n.t('Unable to checkout pull request: missing head branch information.'));
}

// Store validated head to avoid non-null assertions later
const prHead = pullRequestModel.head;

// Get the folder manager to access the repository
const folderManager = reposManager.getManagerForIssueModel(pullRequestModel);
if (!folderManager) {
return vscode.window.showErrorMessage(vscode.l10n.t('Unable to find repository for this pull request.'));
}

const repositoryToUse = repository || folderManager.repository;

/* __GDPR__
"pr.checkoutInWorktree" : {}
*/
telemetry.sendTelemetryEvent('pr.checkoutInWorktree');

// Prepare for operations
const repoRootPath = repositoryToUse.rootUri.fsPath;
const parentDir = pathLib.dirname(repoRootPath);
const defaultWorktreePath = pathLib.join(parentDir, `pr-${pullRequestModel.number}`);
const branchName = prHead.ref;
const remoteName = pullRequestModel.remote.remoteName;

// Ask user for worktree location first (not in progress)
const worktreeUri = await vscode.window.showSaveDialog({
defaultUri: vscode.Uri.file(defaultWorktreePath),
title: vscode.l10n.t('Select Worktree Location'),
saveLabel: vscode.l10n.t('Create Worktree'),
});

if (!worktreeUri) {
return; // User cancelled
}

const worktreePath = worktreeUri.fsPath;
const trackedBranchName = `${remoteName}/${branchName}`;

try {
// Check if the createWorktree API is available
if (!repositoryToUse.createWorktree) {
throw new Error(vscode.l10n.t('Git worktree API is not available. Please update VS Code to the latest version.'));
}

// Start progress for fetch and worktree creation
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: vscode.l10n.t('Creating worktree for Pull Request #{0}...', pullRequestModel.number),
},
async () => {
// Fetch the PR branch first
try {
await repositoryToUse.fetch({ remote: remoteName, ref: branchName });
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
Logger.appendLine(`Failed to fetch branch ${branchName}: ${errorMessage}`, logId);
// Continue even if fetch fails - the branch might already be available locally
}

// Check if the branch already exists locally
let branchExists = false;
try {
await repositoryToUse.getBranch(branchName);
branchExists = true;
} catch {
// Branch doesn't exist locally, we'll create it
branchExists = false;
}

// Use the git extension's createWorktree API
// If branch already exists, don't specify the branch parameter to avoid "branch already exists" error
if (branchExists) {
await repositoryToUse.createWorktree!({
path: worktreePath,
commitish: branchName
});
} else {
await repositoryToUse.createWorktree!({
path: worktreePath,
commitish: trackedBranchName,
branch: branchName
});
}
}
);

// Ask user how they want to open the worktree (modal dialog)
const openInNewWindow = vscode.l10n.t('New Window');
const openInCurrentWindow = vscode.l10n.t('Current Window');
const result = await vscode.window.showInformationMessage(
vscode.l10n.t('Worktree created for Pull Request #{0}. How would you like to open it?', pullRequestModel.number),
{ modal: true },
openInNewWindow,
openInCurrentWindow
);

if (result === openInNewWindow) {
await commands.openFolder(worktreeUri, { forceNewWindow: true });
} else if (result === openInCurrentWindow) {
await commands.openFolder(worktreeUri, { forceNewWindow: false });
}
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
Logger.error(`Failed to create worktree: ${errorMessage}`, logId);
return vscode.window.showErrorMessage(vscode.l10n.t('Failed to create worktree: {0}', errorMessage));
}
}),
);

context.subscriptions.push(vscode.commands.registerCommand('pr.checkoutOnVscodeDevFromDescription', async (context: BaseContext | undefined) => {
if (!context) {
return vscode.window.showErrorMessage(vscode.l10n.t('No pull request context provided for checkout.'));
Expand Down
13 changes: 13 additions & 0 deletions src/github/pullRequestOverview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,8 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel<PullRequestMode
switch (message.command) {
case 'pr.checkout':
return this.checkoutPullRequest(message);
case 'pr.checkout-in-worktree':
return this.checkoutPullRequestInWorktree(message);
Comment thread
alexr00 marked this conversation as resolved.
Outdated
case 'pr.merge':
return this.mergePullRequest(message);
case 'pr.change-email':
Expand Down Expand Up @@ -827,6 +829,17 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel<PullRequestMode
);
}

private checkoutPullRequestInWorktree(message: IRequestMessage<any>): void {
vscode.commands.executeCommand('pr.pickInWorktree', this._item).then(
Comment thread
alexr00 marked this conversation as resolved.
Outdated
() => {
this._replyMessage(message, {});
},
() => {
this._replyMessage(message, {});
},
);
}

private async mergePullRequest(
message: IRequestMessage<MergeArguments>,
): Promise<void> {
Expand Down
2 changes: 2 additions & 0 deletions webviews/common/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export class PRContext {

public checkout = () => this.postMessage({ command: 'pr.checkout' });

public checkoutInWorktree = () => this.postMessage({ command: 'pr.checkout-in-worktree' });

Comment thread
alexr00 marked this conversation as resolved.
Outdated
public openChanges = (openToTheSide?: boolean) => this.postMessage({ command: 'pr.open-changes', args: { openToTheSide } });

public copyPrLink = () => this.postMessage({ command: 'pr.copy-prlink' });
Expand Down
10 changes: 9 additions & 1 deletion webviews/components/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ interface CheckoutButtonProps {
}

const CheckoutButton: React.FC<CheckoutButtonProps> = ({ isCurrentlyCheckedOut, isIssue, doneCheckoutBranch, owner, repo, number }) => {
const { exitReviewMode, checkout, openChanges } = useContext(PullRequestContext);
const { exitReviewMode, checkout, checkoutInWorktree, openChanges } = useContext(PullRequestContext);
const [isBusy, setBusy] = useState(false);

const onClick = async (command: string) => {
Expand All @@ -317,6 +317,9 @@ const CheckoutButton: React.FC<CheckoutButtonProps> = ({ isCurrentlyCheckedOut,
case 'checkout':
await checkout();
break;
case 'checkoutInWorktree':
await checkoutInWorktree();
break;
case 'exitReviewMode':
await exitReviewMode();
break;
Expand Down Expand Up @@ -357,6 +360,11 @@ const CheckoutButton: React.FC<CheckoutButtonProps> = ({ isCurrentlyCheckedOut,
value: '',
action: () => onClick('checkout')
});
actions.push({
label: 'Checkout in Worktree',
value: '',
action: () => onClick('checkoutInWorktree')
});
}

actions.push({
Expand Down
Loading