Skip to content
Merged
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
185 changes: 154 additions & 31 deletions docs/design/worktree.md

Large diffs are not rendered by default.

748 changes: 748 additions & 0 deletions docs/e2e-tests/worktree-phase-d.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/users/features/_meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export default {
},
'approval-mode': 'Approval Mode',
'auto-mode': 'Auto Mode',
worktree: 'Worktrees',
mcp: 'MCP',
lsp: 'LSP (Language Server Protocol)',
'token-caching': 'Token Caching',
Expand Down
345 changes: 345 additions & 0 deletions docs/users/features/worktree.md

Large diffs are not rendered by default.

24 changes: 24 additions & 0 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,17 @@ export interface CliArgs {
forkSession?: boolean | undefined;
/** Internal: preserve the outer session ID when relaunching in a sandbox */
sandboxSessionId?: string | undefined;
/**
* Start the session inside a git worktree. Accepted forms:
* - bare `--worktree` (empty string from yargs) → auto-generated slug
* - `--worktree foo` / `--worktree=foo` → explicit slug
* - `--worktree=#123` / `--worktree https://github.com/o/r/pull/123` → PR ref
*
* Consumed by `setupStartupWorktree()` before `loadCliConfig()`. When set,
* the CLI chdirs into `<repoRoot>/.qwen/worktrees/<slug>/` and the entire
* session runs inside that worktree.
*/
worktree?: string | undefined;
maxSessionTurns: number | undefined;
coreTools: string[] | undefined;
excludeTools: string[] | undefined;
Expand Down Expand Up @@ -824,6 +835,14 @@ export async function parseArguments(): Promise<CliArgs> {
type: 'string',
hidden: true,
})
.option('worktree', {
type: 'string',
description:
'Start the session inside a git worktree at <repoRoot>/.qwen/worktrees/<slug>/. ' +
'Pass a slug (`--worktree my-feature`), a PR reference (`--worktree=#123` or a full ' +
'GitHub pull-request URL), or use bare `--worktree` to auto-generate a slug. ' +
'On exit, the WorktreeExitDialog prompts to keep or remove the worktree.',
})
.option('max-session-turns', {
type: 'number',
description: 'Maximum number of session turns',
Expand Down Expand Up @@ -1814,6 +1833,11 @@ export async function loadCliConfig(
: undefined,
}
: undefined,
worktree: settings.worktree
? {
symlinkDirectories: settings.worktree.symlinkDirectories,
}
: undefined,
};

const config = new Config(configParams);
Expand Down
35 changes: 35 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2272,6 +2272,41 @@ const SETTINGS_SCHEMA = {
},
},
},

worktree: {
type: 'object',
label: 'Worktree',
category: 'Advanced',
requiresRestart: false,
default: {},
description:
'Configuration for general-purpose git worktrees created by the ' +
'CLI (the `enter_worktree` tool, the `agent isolation: "worktree"` ' +
'parameter, and the startup `--worktree` flag). Does NOT affect ' +
'Agent Arena worktrees — see `agents.arena.worktreeBaseDir` for those.',
showInDialog: false,
properties: {
symlinkDirectories: {
type: 'array',
label: 'Symlink Directories Into Worktrees',
category: 'Advanced',
requiresRestart: false,
default: undefined as string[] | undefined,
description:
'Directories under the main repository to symlink into every ' +
'general-purpose worktree on creation. Useful for sharing ' +
'large opt-in dirs like `node_modules` so the model can run ' +
'tests / builds inside the worktree without a fresh install. ' +
'Paths must be relative to the repo root; absolute paths, ' +
'anything containing `..`, and any path inside `.git` or ' +
'`.qwen` (the CLI-managed metadata tree, which contains ' +
'the worktrees directory itself) are rejected. Missing ' +
'source dirs and existing destination paths are silently ' +
'skipped (no overwrite, no failure).',
showInDialog: false,
},
},
},
} as const satisfies SettingsSchema;

export type SettingsSchemaType = typeof SETTINGS_SCHEMA;
Expand Down
133 changes: 132 additions & 1 deletion packages/cli/src/gemini.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
import { render } from 'ink';
import dns from 'node:dns';
import os from 'node:os';
import { basename } from 'node:path';
import path, { basename } from 'node:path';
import v8 from 'node:v8';
import React from 'react';
import { validateAuthMethod } from './config/auth.js';
Expand All @@ -39,6 +39,12 @@ import {
type InitializationResult,
} from './core/initializer.js';
import { runNonInteractive } from './nonInteractiveCli.js';
import {
setupStartupWorktree,
persistStartupWorktreeSidecar,
buildStartupWorktreeNotice,
type StartupWorktreeContext,
} from './startup/worktreeStartup.js';
import { runNonInteractiveStreamJson } from './nonInteractive/session.js';
import { AppContainer } from './ui/AppContainer.js';
import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js';
Expand Down Expand Up @@ -581,6 +587,86 @@ export async function main() {
}
}

// When --worktree is going to chdir us into a worktree below, resolve
// any relative-path argv fields to absolute paths now — BEFORE the
// chdir. Otherwise downstream `fs.existsSync('./mcp.json')` calls in
// `loadCliConfig` re-resolve against the worktree dir, where the file
// doesn't exist. Only touches values that look like paths (mcpConfig
// also accepts inline JSON — skip those).
//
// The list of fields below is hand-maintained. If you add a new
// CLI flag that takes a relative path, register it here too,
// otherwise --worktree silently breaks for that flag.
if (argv.worktree !== undefined) {
const launchCwdForPaths = process.cwd();
const looksLikeInlineJson = (v: string): boolean => {
const t = v.trim();
return t.startsWith('{') || t.startsWith('[');
};
const resolveIfPath = (v: string | undefined): string | undefined => {
if (typeof v !== 'string' || v.length === 0) return v;
if (looksLikeInlineJson(v)) return v;
return path.resolve(launchCwdForPaths, v);
};
argv.mcpConfig = resolveIfPath(argv.mcpConfig);
argv.openaiLoggingDir = resolveIfPath(argv.openaiLoggingDir);
argv.jsonFile = resolveIfPath(argv.jsonFile);
argv.inputFile = resolveIfPath(argv.inputFile);
argv.telemetryOutfile = resolveIfPath(argv.telemetryOutfile);
if (Array.isArray(argv.includeDirectories)) {
argv.includeDirectories = argv.includeDirectories.map((d) =>
typeof d === 'string' && d.length > 0
? path.resolve(launchCwdForPaths, d)
: d,
);
}
// `--json-schema` accepts either an inline schema or `@<path>`. The
// `@`-prefixed form is read from disk inside `resolveJsonSchemaArg`
// (`packages/cli/src/config/config.ts`), AFTER chdir, so a relative
// value would resolve against the worktree — fix the prefix path
// here.
if (typeof argv.jsonSchema === 'string') {
const trimmedSchema = argv.jsonSchema.trim();
if (trimmedSchema.startsWith('@')) {
const rel = trimmedSchema.slice(1);
if (rel.length > 0 && !path.isAbsolute(rel)) {
argv.jsonSchema = '@' + path.resolve(launchCwdForPaths, rel);
}
}
}
}

// Phase D-1: process --worktree before the resume picker so the picker
// (which uses process.cwd() to scope its session search) finds sessions
// saved inside the target worktree. Creates the worktree directory on
// disk and chdirs into it; on failure we emit to stderr and exit before
// any expensive initialization runs.
//
// ACP mode is exempt: the ACP host (Zed, etc.) supplies its own per-session
// cwd, and the startup-level chdir would not propagate. Reject the
// combination with a clear error rather than silently dropping --worktree.
let startupWorktreeContext: StartupWorktreeContext | null = null;
if (argv.worktree !== undefined && (argv.acp || argv.experimentalAcp)) {
writeStderrLine(
'--worktree cannot be combined with --acp / --experimental-acp. ' +
'Pass the worktree path as the cwd of the ACP loadSession / newSession ' +
'request instead.',
);
process.exit(1);
}
{
const startupRes = await setupStartupWorktree(argv.worktree, {
symlinkDirectories: settings.merged.worktree?.symlinkDirectories,
});
if (startupRes !== null) {
if (!startupRes.ok) {
writeStderrLine(startupRes.error);
process.exit(1);
}
startupWorktreeContext = startupRes.context;
}
}

// Handle --resume without a session ID, or with a custom title, by showing
// the session picker. Set the runtime output dir early so the picker can find
// sessions stored under a custom runtimeOutputDir (setRuntimeBaseDir is
Expand Down Expand Up @@ -653,6 +739,51 @@ export async function main() {
);
profileCheckpoint('after_load_cli_config');

// Phase D-1: persist the WorktreeSession sidecar so Phase C's restore
// machinery on a subsequent `--resume` picks the worktree back up, and
// capture any override of a previously-resumed session's worktree so
// we can emit a one-shot notice on the model's first prompt.
//
// The notice is set BEFORE the persist attempt and AGAIN inside the
// try block (so the override addendum can be appended on success).
// A persist failure must NOT silently drop the notice — the cwd is
// already switched, and the model needs to know which worktree it's
// operating in regardless of whether the sidecar landed.
if (startupWorktreeContext) {
config.setPendingStartupWorktreeNotice(
buildStartupWorktreeNotice(startupWorktreeContext),
);
try {
const startupWorktreePersist = await persistStartupWorktreeSidecar(
config,
startupWorktreeContext,
);
if (startupWorktreePersist.overrodeResumedWorktree) {
writeStderrLine(
`--worktree overrode the resumed session's previous worktree ` +
`"${startupWorktreePersist.overriddenSlug ?? '(unknown)'}". ` +
`That worktree directory was left intact on disk.`,
);
}
// Refresh the notice with the override addendum (if any). When
// there is no override this is a no-op text-wise; on override it
// gives the model the "you overrode <previous-slug>" hint. TUI
// and headless consume this via Config.consumePendingStartupWorktreeNotice();
// ACP is excluded above (`--worktree` × `--acp` is mutually
// exclusive — see the mutex check earlier in this function).
config.setPendingStartupWorktreeNotice(
buildStartupWorktreeNotice(
startupWorktreeContext,
startupWorktreePersist,
),
);
} catch (error) {
debugLogger.warn(
`--worktree sidecar persist failed (non-fatal, notice preserved): ${error instanceof Error ? error.message : String(error)}`,
);
}
}

// Register cleanup for MCP clients as early as possible
// This ensures MCP server subprocesses are properly terminated on exit
registerCleanup(() => config.shutdown());
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/nonInteractiveCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,12 @@ describe('runNonInteractive', () => {
// restore worktree context. These tests don't exercise resume, so
// return undefined to short-circuit the helper.
getResumedSessionData: vi.fn().mockReturnValue(undefined),
// Phase D-1: nonInteractiveCli calls this on every prompt to pick
// up the one-shot startup-worktree notice (set by gemini.tsx
// when --worktree was passed). These tests don't exercise the
// --worktree flag, so return null to short-circuit injection
// and let the resume-restore branch run.
consumePendingStartupWorktreeNotice: vi.fn().mockReturnValue(null),
} as unknown as Config;

mockSettings = {
Expand Down
45 changes: 30 additions & 15 deletions packages/cli/src/nonInteractiveCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,27 +375,42 @@ export async function runNonInteractive(
initialPartList = [{ text: input }];
}

// Phase C: when --resume restored a session with an active worktree,
// prepend a system-reminder block to the user prompt so the model
// knows to keep using the worktree path. Stale sidecars (worktree
// dir deleted between sessions) are cleaned up inside the helper.
// TUI does this via historyManager.addItem(INFO); headless does it
// here because there is no UI history to write into.
if (config.getResumedSessionData()) {
// Inject a worktree context notice into the model's first prompt.
// Two sources: the `--worktree` startup flag (set by gemini.tsx
// before loadCliConfig) takes precedence over the Phase C resume
// restore. TUI does this via historyManager.addItem(INFO); here in
// headless we prepend a `<system-reminder>` block since there is
// no UI history to write into.
const withReminder = (
existing: PartListUnion,
text: string,
): PartListUnion => {
const reminderPart: Part = {
text: `<system-reminder>\n${text}\n</system-reminder>\n\n`,
};
return Array.isArray(existing)
? [reminderPart, ...existing]
: [reminderPart, existing];
};

const startupNotice = config.consumePendingStartupWorktreeNotice();
if (startupNotice) {
initialPartList = withReminder(initialPartList, startupNotice);
adapter.emitSystemMessage('worktree_started', {
notice: startupNotice,
});
} else if (config.getResumedSessionData()) {
try {
const sessionPath = config
.getSessionService()
.getWorktreeSessionPath(sessionId);
const restored = await restoreWorktreeContext(sessionPath);
if (restored.contextMessage) {
const reminderPart: Part = {
text: `<system-reminder>\n${restored.contextMessage}\n</system-reminder>\n\n`,
};
const partsArr = Array.isArray(initialPartList)
? initialPartList
: [initialPartList];
initialPartList = [reminderPart, ...partsArr];
// Also surface the notice in the JSON stream so SDK consumers
initialPartList = withReminder(
initialPartList,
restored.contextMessage,
);
// Surface the notice in the JSON stream so SDK consumers
// can react to it (logging, UI hints, etc.).
adapter.emitSystemMessage('worktree_restored', {
slug: restored.session?.slug,
Expand Down
Loading
Loading