-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Git worktree management #10458
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Git worktree management #10458
Conversation
All previously flagged issues have been resolved. No new issues found in the latest changes.
Mention @roomote in a comment to request specific changes to this pull request or fix all unresolved issues. |
| * Normalize a path for comparison (handle trailing slashes, etc.) | ||
| */ | ||
| private normalizePath(p: string): string { | ||
| return path.normalize(p).replace(/\/+$/, "") |
Check failure
Code scanning / CodeQL
Polynomial regular expression used on uncontrolled data High
regular expression
library input
This
regular expression
library input
This
regular expression
library input
This
regular expression
library input
This
regular expression
library input
| } | ||
| } else if (branch) { | ||
| // Checkout existing branch: git worktree add <path> <branch> | ||
| command += ` "${worktreePath}" "${branch}"` |
Check warning
Code scanning / CodeQL
Unsafe shell command constructed from library input Medium
library input
shell command
This string concatenation which depends on
library input
shell command
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 4 days ago
In general, the fix is to avoid constructing a single shell command string from dynamic input and running it with exec, and instead invoke the underlying executable (git) with an argument array using execFile (or spawn). This prevents the shell from interpreting special characters in branch, baseBranch, or worktreePath, eliminating injection risks and making handling of spaces and quoting robust.
Concretely for createWorktree in packages/core/src/worktree/worktree-service.ts, we should:
- Keep using
execfor fixed, constant commands like"git --version"and"git rev-parse ...", which are not built from tainted input. - Change only the
createWorktreemethod to build an arrayargs: string[]equivalent to the current command and rungitviaexecFile. - Reuse the existing
execAsyncwrapper, but extend its type to allow passing anexecFile-compatible function, or more simply, add a small local promisified wrapper aroundexecFilejust for this method.
Minimal, focused approach:
- Import
execFilefromchild_processalongsideexec. - Create
const execFileAsync = promisify(execFile)nearexecAsync. - In
createWorktree, replace thelet command = ...string building with construction ofconst args: string[] = [...]based oncreateNewBranch,branch, andbaseBranch:- Always start with
["worktree", "add"]. - If
createNewBranch && branch: push"-b",branch,worktreePath, and optionallybaseBranch. - Else if
branch: pushworktreePath,branch. - Else: push
"--detach",worktreePath.
- Always start with
- Replace
await execAsync(command, { cwd })withawait execFileAsync("git", args, { cwd }).
This preserves existing functionality and messages, but removes shell interpretation of user-controlled inputs.
-
Copy modified line R8 -
Copy modified line R22 -
Copy modified lines R109-R110 -
Copy modified line R114 -
Copy modified line R116 -
Copy modified line R120 -
Copy modified line R123 -
Copy modified line R126
| @@ -5,7 +5,7 @@ | ||
| * Uses simple-git and native CLI commands - no VSCode dependencies. | ||
| */ | ||
|
|
||
| import { exec } from "child_process" | ||
| import { exec, execFile } from "child_process" | ||
| import * as path from "path" | ||
| import { promisify } from "util" | ||
|
|
||
| @@ -19,6 +19,7 @@ | ||
| } from "./types.js" | ||
|
|
||
| const execAsync = promisify(exec) | ||
| const execFileAsync = promisify(execFile) | ||
|
|
||
| /** | ||
| * Service for managing git worktrees. | ||
| @@ -105,24 +106,24 @@ | ||
| try { | ||
| const { path: worktreePath, branch, baseBranch, createNewBranch } = options | ||
|
|
||
| // Build the git worktree add command | ||
| let command = `git worktree add` | ||
| // Build the git worktree add command arguments | ||
| const args: string[] = ["worktree", "add"] | ||
|
|
||
| if (createNewBranch && branch) { | ||
| // Create new branch: git worktree add -b <branch> <path> [<base>] | ||
| command += ` -b "${branch}" "${worktreePath}"` | ||
| args.push("-b", branch, worktreePath) | ||
| if (baseBranch) { | ||
| command += ` "${baseBranch}"` | ||
| args.push(baseBranch) | ||
| } | ||
| } else if (branch) { | ||
| // Checkout existing branch: git worktree add <path> <branch> | ||
| command += ` "${worktreePath}" "${branch}"` | ||
| args.push(worktreePath, branch) | ||
| } else { | ||
| // Detached HEAD at current commit | ||
| command += ` --detach "${worktreePath}"` | ||
| args.push("--detach", worktreePath) | ||
| } | ||
|
|
||
| await execAsync(command, { cwd }) | ||
| await execFileAsync("git", args, { cwd }) | ||
|
|
||
| // Get the created worktree info | ||
| const worktrees = await this.listWorktrees(cwd) |
| command += ` "${worktreePath}" "${branch}"` | ||
| } else { | ||
| // Detached HEAD at current commit | ||
| command += ` --detach "${worktreePath}"` |
Check warning
Code scanning / CodeQL
Unsafe shell command constructed from library input Medium
library input
shell command
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 4 days ago
In general, the fix is to stop constructing a single shell command string with unescaped input and instead invoke Git using child_process.execFile (or spawn) with an argument array. This prevents the shell from interpreting special characters in worktreePath, branch, or baseBranch. Since listWorktrees already uses a static command string and does not include tainted input, only createWorktree needs changes for this specific issue.
Concretely, in packages/core/src/worktree/worktree-service.ts, within createWorktree, replace the string-based command building logic and the execAsync(command, { cwd }) call with construction of an array of arguments, then call execFile (promisified) with "git" as the executable and the arguments array. Keep the operational semantics the same: when createNewBranch && branch, run git worktree add -b <branch> <path> [<baseBranch>]; when branch only, run git worktree add <path> <branch>; otherwise run git worktree add --detach <path>. To implement this, we will:
- Import
execFilefromchild_processin addition toexec. - Create a new
execFileAsyncconstant viapromisify(execFile). - In
createWorktree, replace thecommandstring with aconst args: string[] = ["worktree", "add"]and push appropriate arguments based on the conditions. - Replace
await execAsync(command, { cwd })withawait execFileAsync("git", args, { cwd }).
This keeps functionality unchanged while eliminating shell interpretation of tainted inputs.
-
Copy modified line R8 -
Copy modified line R22 -
Copy modified lines R109-R110 -
Copy modified line R114 -
Copy modified line R116 -
Copy modified line R120 -
Copy modified line R123 -
Copy modified line R126
| @@ -5,7 +5,7 @@ | ||
| * Uses simple-git and native CLI commands - no VSCode dependencies. | ||
| */ | ||
|
|
||
| import { exec } from "child_process" | ||
| import { exec, execFile } from "child_process" | ||
| import * as path from "path" | ||
| import { promisify } from "util" | ||
|
|
||
| @@ -19,6 +19,7 @@ | ||
| } from "./types.js" | ||
|
|
||
| const execAsync = promisify(exec) | ||
| const execFileAsync = promisify(execFile) | ||
|
|
||
| /** | ||
| * Service for managing git worktrees. | ||
| @@ -105,24 +106,24 @@ | ||
| try { | ||
| const { path: worktreePath, branch, baseBranch, createNewBranch } = options | ||
|
|
||
| // Build the git worktree add command | ||
| let command = `git worktree add` | ||
| // Build the git worktree add command arguments | ||
| const args: string[] = ["worktree", "add"] | ||
|
|
||
| if (createNewBranch && branch) { | ||
| // Create new branch: git worktree add -b <branch> <path> [<base>] | ||
| command += ` -b "${branch}" "${worktreePath}"` | ||
| args.push("-b", branch, worktreePath) | ||
| if (baseBranch) { | ||
| command += ` "${baseBranch}"` | ||
| args.push(baseBranch) | ||
| } | ||
| } else if (branch) { | ||
| // Checkout existing branch: git worktree add <path> <branch> | ||
| command += ` "${worktreePath}" "${branch}"` | ||
| args.push(worktreePath, branch) | ||
| } else { | ||
| // Detached HEAD at current commit | ||
| command += ` --detach "${worktreePath}"` | ||
| args.push("--detach", worktreePath) | ||
| } | ||
|
|
||
| await execAsync(command, { cwd }) | ||
| await execFileAsync("git", args, { cwd }) | ||
|
|
||
| // Get the created worktree info | ||
| const worktrees = await this.listWorktrees(cwd) |
| ) | ||
|
|
||
| const forceFlag = force ? " --force" : "" | ||
| await execAsync(`git worktree remove${forceFlag} "${worktreePath}"`, { cwd }) |
Check warning
Code scanning / CodeQL
Unsafe shell command constructed from library input Medium
library input
shell command
This string concatenation which depends on
library input
shell command
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 4 days ago
In general, the fix is to avoid passing a single interpolated command string to child_process.exec when it contains dynamic values. Instead, use an API that accepts the command and its arguments as an array and does not invoke a shell, such as child_process.execFile or child_process.spawn/spawnSync. This way, dynamic values are treated purely as arguments and are not interpreted by the shell, eliminating command-injection risks and making paths with spaces work correctly.
For this file, the best targeted change is:
- Import
execFilefromchild_process. - Create a promisified
execFileAsyncalongsideexecAsync. - For the vulnerable calls that interpolate
worktreePathand branch names, switch fromexecAsyncwith a templated string toexecFileAsyncwith a program and argument list:- Replace
execAsync(git worktree remove${forceFlag} "${worktreePath}", { cwd })withexecFileAsync("git", ["worktree", "remove", ...(force ? ["--force"] : []), worktreePath], { cwd }). - Replace
execAsync(git branch -d "${worktreeToDelete.branch}", { cwd })withexecFileAsync("git", ["branch", "-d", worktreeToDelete.branch], { cwd }).
- Replace
These changes preserve behaviour (same commands and options, still run in cwd) but avoid shell interpretation of worktreePath and branch names. Other uses of execAsync in this file that do not interpolate tainted values (like fixed git branch --format=...) can be left as-is, since they are not constructing commands from untrusted input.
Concretely:
- In
packages/core/src/worktree/worktree-service.ts, adjust the import on line 8 to includeexecFile, and add a newexecFileAsyncconstant afterexecAsync. - In the
deleteWorktreemethod, refactor the twoexecAsynccalls on lines 159 and 164 to useexecFileAsyncwith argument arrays as described above.
-
Copy modified line R8 -
Copy modified line R22 -
Copy modified lines R159-R160 -
Copy modified line R165
| @@ -5,7 +5,7 @@ | ||
| * Uses simple-git and native CLI commands - no VSCode dependencies. | ||
| */ | ||
|
|
||
| import { exec } from "child_process" | ||
| import { exec, execFile } from "child_process" | ||
| import * as path from "path" | ||
| import { promisify } from "util" | ||
|
|
||
| @@ -19,6 +19,7 @@ | ||
| } from "./types.js" | ||
|
|
||
| const execAsync = promisify(exec) | ||
| const execFileAsync = promisify(execFile) | ||
|
|
||
| /** | ||
| * Service for managing git worktrees. | ||
| @@ -155,13 +156,13 @@ | ||
| (wt) => this.normalizePath(wt.path) === this.normalizePath(worktreePath), | ||
| ) | ||
|
|
||
| const forceFlag = force ? " --force" : "" | ||
| await execAsync(`git worktree remove${forceFlag} "${worktreePath}"`, { cwd }) | ||
| const forceArgs = force ? ["--force"] : [] | ||
| await execFileAsync("git", ["worktree", "remove", ...forceArgs, worktreePath], { cwd }) | ||
|
|
||
| // Also try to delete the branch if it exists | ||
| if (worktreeToDelete?.branch) { | ||
| try { | ||
| await execAsync(`git branch -d "${worktreeToDelete.branch}"`, { cwd }) | ||
| await execFileAsync("git", ["branch", "-d", worktreeToDelete.branch], { cwd }) | ||
| } catch { | ||
| // Branch deletion is best-effort | ||
| } |
| } | ||
|
|
||
| // Ensure we're on the target branch | ||
| await execAsync(`git checkout "${targetBranch}"`, { cwd: mergeCwd }) |
Check warning
Code scanning / CodeQL
Unsafe shell command constructed from library input Medium
library input
shell command
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 4 days ago
In general, the safest fix is to avoid interpolating untrusted data into a shell command string. Instead, use child_process.execFile or spawn with an argument array so the shell does not interpret the arguments. When using execFile, you pass the program name (git) and an array of arguments (e.g. ["checkout", targetBranch]), and the OS will invoke the program directly without passing through a shell. This preserves functionality but removes shell-injection risk.
In this file, execAsync is a promisified exec, which always goes through the shell and currently runs commands like git checkout "${targetBranch}" and git merge "${sourceBranch}" --no-edit. To fix the specific vulnerable use at line 283 without changing external behavior, introduce a separate promisified execFile helper (for example, execFileAsync) and use it for these git operations. Replace execAsync calls that interpolate targetBranch/sourceBranch into a shell string with execFileAsync("git", [...args], { cwd }). For the status check, we can also safely switch to execFileAsync("git", ["status", "--porcelain"], ...), which avoids using the shell at all. Concretely:
- Add an import of
execFilefromchild_process. - Add
const execFileAsync = promisify(execFile)next toexecAsync. - Change:
execAsync("git status --porcelain", { cwd: worktreePath })toexecFileAsync("git", ["status", "--porcelain"], { cwd: worktreePath }).execAsync(\git checkout "${targetBranch}"`, { cwd: mergeCwd })toexecFileAsync("git", ["checkout", targetBranch], { cwd: mergeCwd })`.execAsync(\git merge "${sourceBranch}" --no-edit`, { cwd: mergeCwd })toexecFileAsync("git", ["merge", sourceBranch, "--no-edit"], { cwd: mergeCwd })`.
This keeps behavior (same git commands, same cwd) while ensuring targetBranch and sourceBranch are passed as arguments instead of being parsed by a shell.
-
Copy modified line R8 -
Copy modified line R22 -
Copy modified line R268 -
Copy modified line R284 -
Copy modified line R288
| @@ -5,7 +5,7 @@ | ||
| * Uses simple-git and native CLI commands - no VSCode dependencies. | ||
| */ | ||
|
|
||
| import { exec } from "child_process" | ||
| import { exec, execFile } from "child_process" | ||
| import * as path from "path" | ||
| import { promisify } from "util" | ||
|
|
||
| @@ -19,6 +19,7 @@ | ||
| } from "./types.js" | ||
|
|
||
| const execAsync = promisify(exec) | ||
| const execFileAsync = promisify(execFile) | ||
|
|
||
| /** | ||
| * Service for managing git worktrees. | ||
| @@ -264,7 +265,7 @@ | ||
|
|
||
| // Check for uncommitted changes in source worktree | ||
| try { | ||
| const { stdout: statusOutput } = await execAsync("git status --porcelain", { cwd: worktreePath }) | ||
| const { stdout: statusOutput } = await execFileAsync("git", ["status", "--porcelain"], { cwd: worktreePath }) | ||
| if (statusOutput.trim()) { | ||
| return { | ||
| success: false, | ||
| @@ -280,11 +281,11 @@ | ||
| } | ||
|
|
||
| // Ensure we're on the target branch | ||
| await execAsync(`git checkout "${targetBranch}"`, { cwd: mergeCwd }) | ||
| await execFileAsync("git", ["checkout", targetBranch], { cwd: mergeCwd }) | ||
|
|
||
| // Attempt the merge | ||
| try { | ||
| await execAsync(`git merge "${sourceBranch}" --no-edit`, { cwd: mergeCwd }) | ||
| await execFileAsync("git", ["merge", sourceBranch, "--no-edit"], { cwd: mergeCwd }) | ||
|
|
||
| // Merge succeeded | ||
| if (deleteAfterMerge) { |
| */ | ||
| async checkoutBranch(cwd: string, branch: string): Promise<WorktreeResult> { | ||
| try { | ||
| await execAsync(`git checkout "${branch}"`, { cwd }) |
Check warning
Code scanning / CodeQL
Unsafe shell command constructed from library input Medium
library input
shell command
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 4 days ago
In general, the safe way to fix this issue is to avoid constructing shell command strings that embed untrusted input and then passing them to exec, which invokes a shell. Instead, use APIs such as child_process.execFile or spawn with an argument array so that the untrusted value is passed as a separate argument and not interpreted by a shell. This way, even if the value contains spaces or shell metacharacters, they are treated as literal argument content, not as shell syntax.
For this specific case, the best fix with minimal behavior change is to:
- Replace the use of
execAsync(which wrapsexec) with a direct use ofchild_process.execFile(or a promisified variant) for thegit checkoutcall. - Pass
"git"as the command and["checkout", branch]as the argument array, using the existingcwdoption. - Avoid manual quoting entirely; let
execFilehandle argument passing togit.
To keep changes localized:
- Import
execFilefromchild_processalongside the existingexecimport. - Create a
promisify(execFile)helper (e.g.,execFileAsync) near the existingexecAsyncdeclaration. - In
checkoutBranch, replaceawait execAsync(\git checkout "${branch}"`, { cwd })withawait execFileAsync("git", ["checkout", branch], { cwd })`.
No other functionality or method signatures need to change.
-
Copy modified line R8 -
Copy modified line R22 -
Copy modified line R359
| @@ -5,7 +5,7 @@ | ||
| * Uses simple-git and native CLI commands - no VSCode dependencies. | ||
| */ | ||
|
|
||
| import { exec } from "child_process" | ||
| import { exec, execFile } from "child_process" | ||
| import * as path from "path" | ||
| import { promisify } from "util" | ||
|
|
||
| @@ -19,6 +19,7 @@ | ||
| } from "./types.js" | ||
|
|
||
| const execAsync = promisify(exec) | ||
| const execFileAsync = promisify(execFile) | ||
|
|
||
| /** | ||
| * Service for managing git worktrees. | ||
| @@ -355,7 +356,7 @@ | ||
| */ | ||
| async checkoutBranch(cwd: string, branch: string): Promise<WorktreeResult> { | ||
| try { | ||
| await execAsync(`git checkout "${branch}"`, { cwd }) | ||
| await execFileAsync("git", ["checkout", branch], { cwd }) | ||
| return { | ||
| success: true, | ||
| message: `Checked out branch ${branch}`, |
879aa3a to
c4e9396
Compare
- Add worktrees.json translation files for all 17 locales - Add command.worktrees.title translation to all package.nls locale files
Performance: - Parallelize git commands in getAvailableBranches() using Promise.all - Add maxDisplayItems prop to SearchableSelect (default 50) to improve dropdown performance - Disable cmdk's slow built-in filtering for large option lists UX Improvements: - Add loading spinner while branches load with i18n support - Add onWheel handler to CommandList for VSCode webview scroll compatibility - Round modal input fields with rounded-full class - Apply consistent WorktreesView styling (rounded-xl, transparent borders, hover effects) Branch .worktreeinclude Check: - Add branchHasWorktreeInclude() to check if selected base branch has .worktreeinclude committed - Show warning when base branch doesn't have .worktreeinclude (not local filesystem) - Add checkBranchWorktreeInclude message type and handler Translations: - Add searchBranch, noBranchFound, loadingBranches keys to all 18 locales
a607d71 to
f023901
Compare
The test performs 9 sequential git operations which can exceed the default 5000ms timeout on Windows CI. Increased to 30s for reliability.
Closes #10646
Thanks to Cline / Saoud for the inspiration.
I had Opus take a peek at their WIP implementation and port it over.
I also added a
--worktreeoption to the Roo Code cli which will automatically run your task in a new worktree.Important
This pull request adds comprehensive Git worktree management to the Roo Code extension, including UI components, backend handlers, and internationalization support for creating, deleting, and managing worktrees.
.worktreeincludefiles for copying untracked files to new worktrees.CreateWorktreeModal,DeleteWorktreeModal, andWorktreesViewfor managing worktrees in the UI.App.tsxto include a new "worktrees" tab and handle related actions.handlers.tsfor worktree operations, including creation, deletion, and merging.extension.tsto check for worktree auto-open paths and initialize worktree-related commands.package.jsonfor worktree management.This description was created by
for f538743. You can customize this summary. It will automatically update as commits are pushed.