Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
23 changes: 23 additions & 0 deletions docs/users/common-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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/<name>`
- A new branch is created for the worktree (named `worktree/<name>`)
- 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
Expand Down
17 changes: 17 additions & 0 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -345,6 +350,18 @@ export async function parseArguments(): Promise<CliArgs> {
'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 <name> (string)
if (typeof value === 'boolean') {
return value ? true : undefined;
}
return value;
},
})
.option('channel', {
type: 'string',
choices: ['VSCode', 'ACP', 'SDK', 'CI'],
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/gemini.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,7 @@ describe('gemini.tsx main function kitty protocol', () => {
channel: undefined,
chatRecording: undefined,
sessionId: undefined,
worktree: undefined,
});

await main();
Expand Down
44 changes: 44 additions & 0 deletions packages/cli/src/gemini.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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(
Expand Down
265 changes: 265 additions & 0 deletions packages/cli/src/utils/worktree.ts
Original file line number Diff line number Diff line change
@@ -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<WorktreeInfo> = {};

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}`;
}