diff --git a/docs/users/common-workflow.md b/docs/users/common-workflow.md index 078447cf18..7ff5ef182b 100644 --- a/docs/users/common-workflow.md +++ b/docs/users/common-workflow.md @@ -400,6 +400,8 @@ Use arrow keys to navigate and press Enter to select a conversation. Press Esc t Suppose you need to work on multiple tasks simultaneously with complete code isolation between Qwen Code instances. +### Option 1: Manual worktree management + **1. Understand Git worktrees** Git worktrees allow you to check out multiple branches from the same repository into separate directories. Each worktree has its own working directory with isolated files, while sharing the same Git history. Learn more in the [official Git worktree documentation](https://git-scm.com/docs/git-worktree). @@ -443,6 +445,27 @@ git worktree list git worktree remove ../project-feature-a ``` +### Option 2: Using the `--worktree` flag (recommended) + +Qwen Code can automatically create and manage worktrees for you using the `--worktree` flag: + +```bash +# Create a new worktree automatically and start Qwen Code +qwen --worktree + +# Or provide a custom name for the worktree +qwen --worktree feature-auth + +# The worktree is created in .qwen/worktrees/ within your project +``` + +When you use `--worktree`: + +- Qwen Code automatically creates a new git worktree in `.qwen/worktrees/` +- A new branch is created for the worktree (named `worktree/`) +- The session runs in the isolated worktree directory +- Each session has completely isolated file changes + > [!tip] > > - Each worktree has its own independent file state, making it perfect for parallel Qwen Code sessions diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 88153fe750..ca89b51ef3 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -158,6 +158,11 @@ export interface CliArgs { excludeTools: string[] | undefined; authType: string | undefined; channel: string | undefined; + /** + * Enable git worktree mode for parallel session execution. + * Each session runs in an isolated worktree with its own working directory. + */ + worktree: boolean | string | undefined; } function normalizeOutputFormat( @@ -345,6 +350,18 @@ export async function parseArguments(): Promise { 'Enable experimental hooks feature for lifecycle event customization', default: false, }) + .option('worktree', { + type: 'string', + description: + 'Enable git worktree mode for parallel session execution. Creates an isolated worktree for this session. Optionally provide a worktree name.', + coerce: (value: string | boolean) => { + // Handle both --worktree (boolean) and --worktree (string) + if (typeof value === 'boolean') { + return value ? true : undefined; + } + return value; + }, + }) .option('channel', { type: 'string', choices: ['VSCode', 'ACP', 'SDK', 'CI'], diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 9b47de5b54..69b42092ea 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -508,6 +508,7 @@ describe('gemini.tsx main function kitty protocol', () => { channel: undefined, chatRecording: undefined, sessionId: undefined, + worktree: undefined, }); await main(); diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 58a735c73c..7f5e73ca24 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -60,6 +60,11 @@ import { computeWindowTitle } from './utils/windowTitle.js'; import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js'; import { showResumeSessionPicker } from './ui/components/StandaloneSessionPicker.js'; import { initializeLlmOutputLanguage } from './utils/languageUtils.js'; +import { + setupWorktree, + isGitRepository, + generateWorktreeName, +} from './utils/worktree.js'; const debugLogger = createDebugLogger('STARTUP'); @@ -213,6 +218,45 @@ export async function main() { let argv = await parseArguments(); + // Handle --worktree flag: create isolated worktree for parallel session execution + if (argv.worktree) { + const cwd = process.cwd(); + + // Check if we're in a git repository + if (!isGitRepository(cwd)) { + writeStderrLine( + 'Error: --worktree requires a git repository. Please initialize git first.', + ); + process.exit(1); + } + + try { + // Generate worktree name if not provided + const worktreeName = + typeof argv.worktree === 'string' && argv.worktree !== 'true' + ? argv.worktree + : generateWorktreeName(); + + // Setup the worktree + const result = setupWorktree(cwd, worktreeName); + + if (result.created) { + writeStderrLine(`Created worktree at: ${result.worktreePath}`); + writeStderrLine(`Branch: ${result.branch}`); + writeStderrLine(`Starting Qwen Code in isolated worktree...`); + } else { + writeStderrLine(`Using existing worktree: ${result.worktreePath}`); + } + + // Change to the worktree directory + process.chdir(result.worktreePath); + } catch (error) { + const err = error as Error; + writeStderrLine(`Error setting up worktree: ${err.message}`); + process.exit(1); + } + } + // Check for invalid input combinations early to prevent crashes if (argv.promptInteractive && !process.stdin.isTTY) { writeStderrLine( diff --git a/packages/cli/src/utils/worktree.ts b/packages/cli/src/utils/worktree.ts new file mode 100644 index 0000000000..4f11404ab9 --- /dev/null +++ b/packages/cli/src/utils/worktree.ts @@ -0,0 +1,265 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { homedir } from 'node:os'; + +export interface WorktreeInfo { + path: string; + branch: string; + head: string; + isCurrent: boolean; +} + +export interface WorktreeSetupResult { + worktreePath: string; + branch: string; + created: boolean; +} + +/** + * Resolves a path that may contain ~ or %USERPROFILE% to an absolute path + */ +export function expandHomeDir(p: string): string { + if (!p) { + return ''; + } + let expandedPath = p; + if (p.toLowerCase().startsWith('%userprofile%')) { + expandedPath = homedir() + p.substring('%userprofile%'.length); + } else if (p === '~' || p.startsWith('~/')) { + expandedPath = homedir() + p.substring(1); + } + return path.normalize(expandedPath); +} + +/** + * Checks if a directory is a valid git repository + */ +export function isGitRepository(dir: string): boolean { + try { + execSync('git rev-parse --git-dir', { cwd: dir, stdio: 'pipe' }); + return true; + } catch { + return false; + } +} + +/** + * Gets the current branch name + */ +export function getCurrentBranch(dir: string): string { + try { + return execSync('git rev-parse --abbrev-ref HEAD', { + cwd: dir, + encoding: 'utf-8', + }).trim(); + } catch { + return 'HEAD'; + } +} + +/** + * Lists all worktrees for a git repository + */ +export function listWorktrees(repoRoot: string): WorktreeInfo[] { + try { + const output = execSync('git worktree list --porcelain', { + cwd: repoRoot, + encoding: 'utf-8', + }); + + const worktrees: WorktreeInfo[] = []; + let current: Partial = {}; + + for (const line of output.split('\n')) { + if (line.startsWith('worktree ')) { + if (current.path) { + worktrees.push(current as WorktreeInfo); + } + current = { path: line.substring(9), isCurrent: false }; + } else if (line.startsWith('branch ')) { + current.branch = line.substring(7); + } else if (line.startsWith('HEAD ')) { + current.head = line.substring(5); + } else if (line === '') { + // Empty line marks end of a worktree entry + if (current.path && current.branch) { + worktrees.push(current as WorktreeInfo); + current = {}; + } + } + } + + // Don't forget the last entry + if (current.path && current.branch) { + worktrees.push(current as WorktreeInfo); + } + + // Mark the current worktree + const currentWorktree = getCurrentWorktreePath(repoRoot); + for (const wt of worktrees) { + wt.isCurrent = wt.path === currentWorktree; + } + + return worktrees; + } catch { + return []; + } +} + +/** + * Gets the path to the current worktree + */ +function getCurrentWorktreePath(repoRoot: string): string { + try { + return execSync('git rev-parse --show-toplevel', { + cwd: repoRoot, + encoding: 'utf-8', + }).trim(); + } catch { + return repoRoot; + } +} + +/** + * Creates a new worktree + * @param repoRoot - The root of the git repository + * @param worktreePath - The path where the worktree should be created + * @param branch - The branch to create/check out in the worktree + * @param createBranch - Whether to create a new branch + * @returns The path to the created worktree + */ +export function createWorktree( + repoRoot: string, + worktreePath: string, + branch: string, + createBranch: boolean = true, +): string { + try { + // Ensure parent directory exists + const parentDir = path.dirname(worktreePath); + if (!fs.existsSync(parentDir)) { + fs.mkdirSync(parentDir, { recursive: true }); + } + + let cmd: string; + if (createBranch) { + // Create a new branch for the worktree + cmd = `git worktree add -b "${branch}" "${worktreePath}"`; + } else { + // Check out existing branch + cmd = `git worktree add "${worktreePath}" "${branch}"`; + } + + execSync(cmd, { cwd: repoRoot, encoding: 'utf-8' }); + return worktreePath; + } catch (error) { + const err = error as Error; + throw new Error(`Failed to create worktree: ${err.message}`); + } +} + +/** + * Removes a worktree + */ +export function removeWorktree(repoRoot: string, worktreePath: string): void { + try { + execSync(`git worktree remove --force "${worktreePath}"`, { + cwd: repoRoot, + encoding: 'utf-8', + }); + } catch (error) { + // Ignore errors during cleanup - worktree may already be removed + const err = error as Error; + if (!err.message.includes('not found')) { + // Try to manually remove if git fails + try { + if (fs.existsSync(worktreePath)) { + fs.rmSync(worktreePath, { recursive: true, force: true }); + } + } catch { + // Ignore manual removal errors too + } + } + } +} + +/** + * Sets up a worktree for parallel session execution + * This is the main entry point for the --worktree flag functionality + * + * @param repoRoot - The root of the git repository + * @param worktreeName - Optional name for the worktree (auto-generated if not provided) + * @param baseBranch - Optional base branch to create worktree from + * @returns Information about the setup result + */ +export function setupWorktree( + repoRoot: string, + worktreeName?: string, + _baseBranch?: string, +): WorktreeSetupResult { + if (!isGitRepository(repoRoot)) { + throw new Error( + 'Worktree functionality requires a git repository. Please initialize git first.', + ); + } + + // Generate a unique worktree name if not provided + const name = worktreeName || `worktree-${Date.now()}`; + + // Determine worktree base directory + // Use .qwen/worktrees in the repo root, similar to Claude Code + const worktreeBase = path.join(repoRoot, '.qwen', 'worktrees'); + if (!fs.existsSync(worktreeBase)) { + fs.mkdirSync(worktreeBase, { recursive: true }); + } + + const worktreePath = path.join(worktreeBase, name); + + // Determine branch name + const branchName = `worktree/${name}`; + + // Check if worktree already exists + const existingWorktrees = listWorktrees(repoRoot); + const existing = existingWorktrees.find((wt) => wt.path === worktreePath); + + if (existing) { + // Worktree already exists, just return its info + return { + worktreePath, + branch: existing.branch.replace('refs/heads/', ''), + created: false, + }; + } + + // Create the worktree + createWorktree(repoRoot, worktreePath, branchName, true); + + return { + worktreePath, + branch: branchName, + created: true, + }; +} + +/** + * Cleans up a worktree + */ +export function cleanupWorktree(repoRoot: string, worktreePath: string): void { + removeWorktree(repoRoot, worktreePath); +} + +/** + * Gets the default worktree name based on current directory and timestamp + */ +export function generateWorktreeName(): string { + const dirName = path.basename(process.cwd()); + const timestamp = Date.now().toString(36); + return `${dirName}-${timestamp}`; +}