Skip to content
Draft
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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%",
"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
129 changes: 129 additions & 0 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -825,6 +825,135 @@ 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 if they want to open the worktree (after progress is finished)
const openAction = vscode.l10n.t('Open in New Window');
const result = await vscode.window.showInformationMessage(
vscode.l10n.t('Worktree created for Pull Request #{0}', pullRequestModel.number),
openAction
);

if (result === openAction) {
await commands.openFolder(worktreeUri, { forceNewWindow: true });
}
} 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);
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(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot We should not be running execute command. Instead, put a function to checkout in a worktree somewhere that pull request overview can import it from.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactored in commit ed22158. Created a standalone checkoutPRInWorktree function in src/github/worktree.ts that can be imported directly by both commands.ts and pullRequestOverview.ts, eliminating the need for executeCommand.

() => {
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' });

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