feat(worktree): add Git worktree support for isolated parallel sessions (#22973)

This commit is contained in:
Jerop Kipruto
2026-03-20 10:10:51 -04:00
committed by GitHub
parent b9c87c14a2
commit 5a3c7154df
23 changed files with 1090 additions and 9 deletions
+1
View File
@@ -50,6 +50,7 @@ These commands are available within the interactive REPL.
| `--model` | `-m` | string | `auto` | Model to use. See [Model Selection](#model-selection) for available values. | | `--model` | `-m` | string | `auto` | Model to use. See [Model Selection](#model-selection) for available values. |
| `--prompt` | `-p` | string | - | Prompt text. Appended to stdin input if provided. Forces non-interactive mode. | | `--prompt` | `-p` | string | - | Prompt text. Appended to stdin input if provided. Forces non-interactive mode. |
| `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode | | `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode |
| `--worktree` | `-w` | string | - | Start Gemini in a new git worktree. If no name is provided, one is generated automatically. Requires `experimental.worktrees: true` in settings. |
| `--sandbox` | `-s` | boolean | `false` | Run in a sandboxed environment for safer execution | | `--sandbox` | `-s` | boolean | `false` | Run in a sandboxed environment for safer execution |
| `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo` | | `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo` |
| `--yolo` | `-y` | boolean | `false` | **Deprecated.** Auto-approve all actions. Use `--approval-mode=yolo` instead. | | `--yolo` | `-y` | boolean | `false` | **Deprecated.** Auto-approve all actions. Use `--approval-mode=yolo` instead. |
+107
View File
@@ -0,0 +1,107 @@
# Git Worktrees (experimental)
When working on multiple tasks at once, you can use Git worktrees to give each
Gemini session its own copy of the codebase. Git worktrees create separate
working directories that each have their own files and branch while sharing the
same repository history. This prevents changes in one session from colliding
with another.
Learn more about [session management](./session-management.md).
<!-- prettier-ignore -->
> [!NOTE]
> This is an experimental feature currently under active development. Your
> feedback is invaluable as we refine this feature. If you have ideas,
> suggestions, or encounter issues:
>
> - [Open an issue](https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml) on GitHub.
> - Use the **/bug** command within Gemini CLI to file an issue.
Learn more in the official Git worktree
[documentation](https://git-scm.com/docs/git-worktree).
## How to enable Git worktrees
Git worktrees are an experimental feature. You must enable them in your settings
using the `/settings` command or by manually editing your `settings.json` file.
1. Use the `/settings` command.
2. Search for and set **Enable Git Worktrees** to `true`.
Alternatively, add the following to your `settings.json`:
```json
{
"experimental": {
"worktrees": true
}
}
```
## How to use Git worktrees
Use the `--worktree` (`-w`) flag to create an isolated worktree and start Gemini
CLI in it.
- **Start with a specific name:** The value you pass becomes both the directory
name (within `.gemini/worktrees/`) and the branch name.
```bash
gemini --worktree feature-search
```
- **Start with a random name:** If you omit the name, Gemini generates a random
one automatically (for example, `worktree-a1b2c3d4`).
```bash
gemini --worktree
```
<!-- prettier-ignore -->
> [!NOTE]
> Remember to initialize your development environment in each new
> worktree according to your project's setup. Depending on your stack, this
> might include running dependency installation (`npm install`, `yarn`), setting
> up virtual environments, or following your project's standard build process.
## How to exit a Git worktree session
When you exit a worktree session (using `/quit` or `Ctrl+C`), Gemini leaves the
worktree intact so your work is not lost. This includes your uncommitted changes
(modified files, staged changes, or untracked files) and any new commits you
have made.
Gemini prioritizes a fast and safe exit: it **does not automatically delete**
your worktree or branch. You are responsible for cleaning up your worktrees
manually once you are finished with them.
When you exit, Gemini displays instructions on how to resume your work or how to
manually remove the worktree if you no longer need it.
## Resuming work in a Git worktree
To resume a session in a worktree, navigate to the worktree directory and start
Gemini CLI with the `--resume` flag and the session ID:
```bash
cd .gemini/worktrees/feature-search
gemini --resume <session_id>
```
## Managing Git worktrees manually
For more control over worktree location and branch configuration, or to clean up
a preserved worktree, you can use Git directly:
- **Clean up a preserved Git worktree:**
```bash
git worktree remove .gemini/worktrees/feature-search --force
git branch -D worktree-feature-search
```
- **Create a Git worktree manually:**
```bash
git worktree add ../project-feature-search -b feature-search
cd ../project-feature-search && gemini
```
[Open an issue]: https://github.com/google-gemini/gemini-cli/issues
+6
View File
@@ -96,6 +96,12 @@ Compatibility aliases:
- `/chat ...` works for the same commands. - `/chat ...` works for the same commands.
- `/resume checkpoints ...` also remains supported during migration. - `/resume checkpoints ...` also remains supported during migration.
## Parallel sessions with Git worktrees
When working on multiple tasks at once, you can use
[Git worktrees](./git-worktrees.md) to give each Gemini session its own copy of
the codebase. This prevents changes in one session from colliding with another.
## Managing sessions ## Managing sessions
You can list and delete sessions to keep your history organized and manage disk You can list and delete sessions to keep your history organized and manage disk
+1
View File
@@ -151,6 +151,7 @@ they appear in the UI.
| UI Label | Setting | Description | Default | | UI Label | Setting | Description | Default |
| -------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | | -------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` | | Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` |
| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` |
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` | | Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` | | Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
| Plan | `experimental.plan` | Enable Plan Mode. | `true` | | Plan | `experimental.plan` | Enable Plan Mode. | `true` |
+5
View File
@@ -1527,6 +1527,11 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `true` - **Default:** `true`
- **Requires restart:** Yes - **Requires restart:** Yes
- **`experimental.worktrees`** (boolean):
- **Description:** Enable automated Git worktree management for parallel work.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.extensionManagement`** (boolean): - **`experimental.extensionManagement`** (boolean):
- **Description:** Enable extension management features. - **Description:** Enable extension management features.
- **Default:** `true` - **Default:** `true`
+5
View File
@@ -99,6 +99,11 @@
{ "label": "Agent Skills", "slug": "docs/cli/skills" }, { "label": "Agent Skills", "slug": "docs/cli/skills" },
{ "label": "Checkpointing", "slug": "docs/cli/checkpointing" }, { "label": "Checkpointing", "slug": "docs/cli/checkpointing" },
{ "label": "Headless mode", "slug": "docs/cli/headless" }, { "label": "Headless mode", "slug": "docs/cli/headless" },
{
"label": "Git worktrees",
"badge": "🔬",
"slug": "docs/cli/git-worktrees"
},
{ {
"label": "Hooks", "label": "Hooks",
"collapsed": true, "collapsed": true,
+45
View File
@@ -226,6 +226,51 @@ afterEach(() => {
}); });
describe('parseArguments', () => { describe('parseArguments', () => {
describe('worktree', () => {
it('should parse --worktree flag when provided with a name', async () => {
process.argv = ['node', 'script.js', '--worktree', 'my-feature'];
const settings = createTestMergedSettings();
settings.experimental.worktrees = true;
const argv = await parseArguments(settings);
expect(argv.worktree).toBe('my-feature');
});
it('should generate a random name when --worktree is provided without a name', async () => {
process.argv = ['node', 'script.js', '--worktree'];
const settings = createTestMergedSettings();
settings.experimental.worktrees = true;
const argv = await parseArguments(settings);
expect(argv.worktree).toBeDefined();
expect(argv.worktree).not.toBe('');
expect(typeof argv.worktree).toBe('string');
});
it('should throw an error when --worktree is used but experimental.worktrees is not enabled', async () => {
process.argv = ['node', 'script.js', '--worktree', 'feature'];
const settings = createTestMergedSettings();
settings.experimental.worktrees = false;
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
await expect(parseArguments(settings)).rejects.toThrow(
'process.exit called',
);
expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining(
'The --worktree flag is only available when experimental.worktrees is enabled in your settings.',
),
);
mockExit.mockRestore();
mockConsoleError.mockRestore();
});
});
it.each([ it.each([
{ {
description: 'long flags', description: 'long flags',
+105 -1
View File
@@ -4,10 +4,11 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import yargs from 'yargs/yargs'; import yargs from 'yargs';
import { hideBin } from 'yargs/helpers'; import { hideBin } from 'yargs/helpers';
import process from 'node:process'; import process from 'node:process';
import * as path from 'node:path'; import * as path from 'node:path';
import { execa } from 'execa';
import { mcpCommand } from '../commands/mcp.js'; import { mcpCommand } from '../commands/mcp.js';
import { extensionsCommand } from '../commands/extensions.js'; import { extensionsCommand } from '../commands/extensions.js';
import { skillsCommand } from '../commands/skills.js'; import { skillsCommand } from '../commands/skills.js';
@@ -38,6 +39,9 @@ import {
applyAdminAllowlist, applyAdminAllowlist,
applyRequiredServers, applyRequiredServers,
getAdminBlockedMcpServersMessage, getAdminBlockedMcpServersMessage,
getProjectRootForWorktree,
isGeminiWorktree,
type WorktreeSettings,
type HookDefinition, type HookDefinition,
type HookEventName, type HookEventName,
type OutputFormat, type OutputFormat,
@@ -48,6 +52,8 @@ import {
type MergedSettings, type MergedSettings,
saveModelChange, saveModelChange,
loadSettings, loadSettings,
isWorktreeEnabled,
type LoadedSettings,
} from './settings.js'; } from './settings.js';
import { loadSandboxConfig } from './sandboxConfig.js'; import { loadSandboxConfig } from './sandboxConfig.js';
@@ -74,6 +80,7 @@ export interface CliArgs {
debug: boolean | undefined; debug: boolean | undefined;
prompt: string | undefined; prompt: string | undefined;
promptInteractive: string | undefined; promptInteractive: string | undefined;
worktree?: string;
yolo: boolean | undefined; yolo: boolean | undefined;
approvalMode: string | undefined; approvalMode: string | undefined;
@@ -115,6 +122,36 @@ const coerceCommaSeparated = (values: string[]): string[] => {
); );
}; };
/**
* Pre-parses the command line arguments to find the worktree flag.
* Used for early setup before full argument parsing with settings.
*/
export function getWorktreeArg(argv: string[]): string | undefined {
const result = yargs(hideBin(argv))
.help(false)
.version(false)
.option('worktree', { alias: 'w', type: 'string' })
.strict(false)
.exitProcess(false)
.parseSync();
if (result.worktree === undefined) return undefined;
return typeof result.worktree === 'string' ? result.worktree.trim() : '';
}
/**
* Checks if a worktree is requested via CLI and enabled in settings.
* Returns the requested name (can be empty string for auto-generated) or undefined.
*/
export function getRequestedWorktreeName(
settings: LoadedSettings,
): string | undefined {
if (!isWorktreeEnabled(settings)) {
return undefined;
}
return getWorktreeArg(process.argv);
}
export async function parseArguments( export async function parseArguments(
settings: MergedSettings, settings: MergedSettings,
): Promise<CliArgs> { ): Promise<CliArgs> {
@@ -158,6 +195,20 @@ export async function parseArguments(
description: description:
'Execute the provided prompt and continue in interactive mode', 'Execute the provided prompt and continue in interactive mode',
}) })
.option('worktree', {
alias: 'w',
type: 'string',
skipValidation: true,
description:
'Start Gemini in a new git worktree. If no name is provided, one is generated automatically.',
coerce: (value: unknown): string => {
const trimmed = typeof value === 'string' ? value.trim() : '';
if (trimmed === '') {
return Math.random().toString(36).substring(2, 10);
}
return trimmed;
},
})
.option('sandbox', { .option('sandbox', {
alias: 's', alias: 's',
type: 'boolean', type: 'boolean',
@@ -335,6 +386,9 @@ export async function parseArguments(
) { ) {
return `Invalid values:\n Argument: output-format, Given: "${argv['outputFormat']}", Choices: "text", "json", "stream-json"`; return `Invalid values:\n Argument: output-format, Given: "${argv['outputFormat']}", Choices: "text", "json", "stream-json"`;
} }
if (argv['worktree'] && !settings.experimental?.worktrees) {
return 'The --worktree flag is only available when experimental.worktrees is enabled in your settings.';
}
return true; return true;
}); });
@@ -420,6 +474,7 @@ export interface LoadCliConfigOptions {
projectHooks?: { [K in HookEventName]?: HookDefinition[] } & { projectHooks?: { [K in HookEventName]?: HookDefinition[] } & {
disabled?: string[]; disabled?: string[];
}; };
worktreeSettings?: WorktreeSettings;
} }
export async function loadCliConfig( export async function loadCliConfig(
@@ -431,6 +486,9 @@ export async function loadCliConfig(
const { cwd = process.cwd(), projectHooks } = options; const { cwd = process.cwd(), projectHooks } = options;
const debugMode = isDebugMode(argv); const debugMode = isDebugMode(argv);
const worktreeSettings =
options.worktreeSettings ?? (await resolveWorktreeSettings(cwd));
if (argv.sandbox) { if (argv.sandbox) {
process.env['GEMINI_SANDBOX'] = 'true'; process.env['GEMINI_SANDBOX'] = 'true';
} }
@@ -802,6 +860,7 @@ export async function loadCliConfig(
importFormat: settings.context?.importFormat, importFormat: settings.context?.importFormat,
debugMode, debugMode,
question, question,
worktreeSettings,
coreTools: settings.tools?.core || undefined, coreTools: settings.tools?.core || undefined,
allowedTools: allowedTools.length > 0 ? allowedTools : undefined, allowedTools: allowedTools.length > 0 ? allowedTools : undefined,
@@ -943,3 +1002,48 @@ function mergeExcludeTools(
]); ]);
return Array.from(allExcludeTools); return Array.from(allExcludeTools);
} }
async function resolveWorktreeSettings(
cwd: string,
): Promise<WorktreeSettings | undefined> {
let worktreePath: string | undefined;
try {
const { stdout } = await execa('git', ['rev-parse', '--show-toplevel'], {
cwd,
});
const toplevel = stdout.trim();
const projectRoot = await getProjectRootForWorktree(toplevel);
if (isGeminiWorktree(toplevel, projectRoot)) {
worktreePath = toplevel;
}
} catch (_e) {
return undefined;
}
if (!worktreePath) {
return undefined;
}
let worktreeBaseSha: string | undefined;
try {
const { stdout } = await execa('git', ['rev-parse', 'HEAD'], {
cwd: worktreePath,
});
worktreeBaseSha = stdout.trim();
} catch (e: unknown) {
debugLogger.debug(
`Failed to resolve worktree base SHA at ${worktreePath}: ${e instanceof Error ? e.message : String(e)}`,
);
}
if (!worktreeBaseSha) {
return undefined;
}
return {
name: path.basename(worktreePath),
path: worktreePath,
baseSha: worktreeBaseSha,
};
}
+4
View File
@@ -632,6 +632,10 @@ export function resetSettingsCacheForTesting() {
settingsCache.clear(); settingsCache.clear();
} }
export function isWorktreeEnabled(settings: LoadedSettings): boolean {
return settings.merged.experimental.worktrees;
}
/** /**
* Loads settings from user and workspace directories. * Loads settings from user and workspace directories.
* Project settings override user settings. * Project settings override user settings.
+10
View File
@@ -1906,6 +1906,16 @@ const SETTINGS_SCHEMA = {
description: 'Enable local and remote subagents.', description: 'Enable local and remote subagents.',
showInDialog: false, showInDialog: false,
}, },
worktrees: {
type: 'boolean',
label: 'Enable Git Worktrees',
category: 'Experimental',
requiresRestart: true,
default: false,
description:
'Enable automated Git worktree management for parallel work.',
showInDialog: true,
},
extensionManagement: { extensionManagement: {
type: 'boolean', type: 'boolean',
label: 'Extension Management', label: 'Extension Management',
+2
View File
@@ -199,6 +199,8 @@ vi.mock('./config/config.js', () => ({
networkAccess: false, networkAccess: false,
}), }),
isDebugMode: vi.fn(() => false), isDebugMode: vi.fn(() => false),
getRequestedWorktreeName: vi.fn(() => undefined),
getWorktreeArg: vi.fn(() => undefined),
})); }));
vi.mock('read-package-up', () => ({ vi.mock('read-package-up', () => ({
+10
View File
@@ -9,6 +9,7 @@ import {
WarningPriority, WarningPriority,
type Config, type Config,
type ResumedSessionData, type ResumedSessionData,
type WorktreeInfo,
type OutputPayload, type OutputPayload,
type ConsoleLogPayload, type ConsoleLogPayload,
type UserFeedbackPayload, type UserFeedbackPayload,
@@ -63,6 +64,7 @@ import {
registerTelemetryConfig, registerTelemetryConfig,
setupSignalHandlers, setupSignalHandlers,
} from './utils/cleanup.js'; } from './utils/cleanup.js';
import { setupWorktree } from './utils/worktreeSetup.js';
import { import {
cleanupToolOutputFiles, cleanupToolOutputFiles,
cleanupExpiredSessions, cleanupExpiredSessions,
@@ -210,6 +212,13 @@ export async function main() {
const settings = loadSettings(); const settings = loadSettings();
loadSettingsHandle?.end(); loadSettingsHandle?.end();
// If a worktree is requested and enabled, set it up early.
const requestedWorktree = cliConfig.getRequestedWorktreeName(settings);
let worktreeInfo: WorktreeInfo | undefined;
if (requestedWorktree !== undefined) {
worktreeInfo = await setupWorktree(requestedWorktree || undefined);
}
// Report settings errors once during startup // Report settings errors once during startup
settings.errors.forEach((error) => { settings.errors.forEach((error) => {
coreEvents.emitFeedback('warning', error.message); coreEvents.emitFeedback('warning', error.message);
@@ -426,6 +435,7 @@ export async function main() {
const loadConfigHandle = startupProfiler.start('load_cli_config'); const loadConfigHandle = startupProfiler.start('load_cli_config');
const config = await loadCliConfig(settings.merged, sessionId, argv, { const config = await loadCliConfig(settings.merged, sessionId, argv, {
projectHooks: settings.workspace.settings.hooks, projectHooks: settings.workspace.settings.hooks,
worktreeSettings: worktreeInfo,
}); });
loadConfigHandle?.end(); loadConfigHandle?.end();
+2
View File
@@ -72,6 +72,8 @@ vi.mock('./config/config.js', () => ({
} as unknown as Config), } as unknown as Config),
parseArguments: vi.fn().mockResolvedValue({}), parseArguments: vi.fn().mockResolvedValue({}),
isDebugMode: vi.fn(() => false), isDebugMode: vi.fn(() => false),
getRequestedWorktreeName: vi.fn(() => undefined),
getWorktreeArg: vi.fn(() => undefined),
})); }));
vi.mock('read-package-up', () => ({ vi.mock('read-package-up', () => ({
@@ -8,10 +8,12 @@ import { renderWithProviders } from '../../test-utils/render.js';
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SessionSummaryDisplay } from './SessionSummaryDisplay.js'; import { SessionSummaryDisplay } from './SessionSummaryDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js'; import * as SessionContext from '../contexts/SessionContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { type SessionMetrics } from '../contexts/SessionContext.js'; import { type SessionMetrics } from '../contexts/SessionContext.js';
import { import {
ToolCallDecision, ToolCallDecision,
getShellConfiguration, getShellConfiguration,
type WorktreeSettings,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
vi.mock('@google/gemini-cli-core', async (importOriginal) => { vi.mock('@google/gemini-cli-core', async (importOriginal) => {
@@ -24,19 +26,30 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
}); });
vi.mock('../contexts/SessionContext.js', async (importOriginal) => { vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
const actual = await importOriginal<typeof SessionContext>(); const actual =
await importOriginal<typeof import('../contexts/SessionContext.js')>();
return { return {
...actual, ...actual,
useSessionStats: vi.fn(), useSessionStats: vi.fn(),
}; };
}); });
vi.mock('../contexts/ConfigContext.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../contexts/ConfigContext.js')>();
return {
...actual,
useConfig: vi.fn(),
};
});
const getShellConfigurationMock = vi.mocked(getShellConfiguration); const getShellConfigurationMock = vi.mocked(getShellConfiguration);
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats); const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
const renderWithMockedStats = async ( const renderWithMockedStats = async (
metrics: SessionMetrics, metrics: SessionMetrics,
sessionId = 'test-session', sessionId = 'test-session',
worktreeSettings?: WorktreeSettings,
) => { ) => {
useSessionStatsMock.mockReturnValue({ useSessionStatsMock.mockReturnValue({
stats: { stats: {
@@ -49,7 +62,11 @@ const renderWithMockedStats = async (
getPromptCount: () => 5, getPromptCount: () => 5,
startNewPrompt: vi.fn(), startNewPrompt: vi.fn(),
}); } as unknown as ReturnType<typeof SessionContext.useSessionStats>);
vi.mocked(useConfig).mockReturnValue({
getWorktreeSettings: () => worktreeSettings,
} as never);
const result = await renderWithProviders( const result = await renderWithProviders(
<SessionSummaryDisplay duration="1h 23m 45s" />, <SessionSummaryDisplay duration="1h 23m 45s" />,
@@ -188,4 +205,30 @@ describe('<SessionSummaryDisplay />', () => {
unmount(); unmount();
}); });
}); });
describe('Worktree status', () => {
it('renders worktree instructions when worktreeSettings are present', async () => {
const worktreeSettings: WorktreeSettings = {
name: 'foo-bar',
path: '/path/to/foo-bar',
baseSha: 'base-sha',
};
const { lastFrame, unmount } = await renderWithMockedStats(
emptyMetrics,
'test-session',
worktreeSettings,
);
const output = lastFrame();
expect(output).toContain('To resume work in this worktree:');
expect(output).toContain(
'cd /path/to/foo-bar && gemini --resume test-session',
);
expect(output).toContain(
'To remove manually: git worktree remove /path/to/foo-bar',
);
unmount();
});
});
}); });
@@ -7,6 +7,7 @@
import type React from 'react'; import type React from 'react';
import { StatsDisplay } from './StatsDisplay.js'; import { StatsDisplay } from './StatsDisplay.js';
import { useSessionStats } from '../contexts/SessionContext.js'; import { useSessionStats } from '../contexts/SessionContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { escapeShellArg, getShellConfiguration } from '@google/gemini-cli-core'; import { escapeShellArg, getShellConfiguration } from '@google/gemini-cli-core';
interface SessionSummaryDisplayProps { interface SessionSummaryDisplayProps {
@@ -17,8 +18,19 @@ export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
duration, duration,
}) => { }) => {
const { stats } = useSessionStats(); const { stats } = useSessionStats();
const config = useConfig();
const { shell } = getShellConfiguration(); const { shell } = getShellConfiguration();
const footer = `To resume this session: gemini --resume ${escapeShellArg(stats.sessionId, shell)}`;
const worktreeSettings = config.getWorktreeSettings();
const escapedSessionId = escapeShellArg(stats.sessionId, shell);
let footer = `To resume this session: gemini --resume ${escapedSessionId}`;
if (worktreeSettings) {
footer =
`To resume work in this worktree: cd ${escapeShellArg(worktreeSettings.path, shell)} && gemini --resume ${escapedSessionId}\n` +
`To remove manually: git worktree remove ${escapeShellArg(worktreeSettings.path, shell)}`;
}
return ( return (
<StatsDisplay <StatsDisplay
@@ -0,0 +1,124 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { setupWorktree } from './worktreeSetup.js';
import * as coreFunctions from '@google/gemini-cli-core';
// Mock dependencies
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
getProjectRootForWorktree: vi.fn(),
createWorktreeService: vi.fn(),
debugLogger: {
log: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
writeToStdout: vi.fn(),
writeToStderr: vi.fn(),
};
});
describe('setupWorktree', () => {
const originalEnv = { ...process.env };
const originalCwd = process.cwd;
const mockService = {
setup: vi.fn(),
maybeCleanup: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
// Mock process.cwd and process.chdir
let currentPath = '/mock/project';
process.cwd = vi.fn().mockImplementation(() => currentPath);
process.chdir = vi.fn().mockImplementation((newPath) => {
currentPath = newPath;
});
// Mock successful execution of core utilities
vi.mocked(coreFunctions.getProjectRootForWorktree).mockResolvedValue(
'/mock/project',
);
vi.mocked(coreFunctions.createWorktreeService).mockResolvedValue(
mockService as never,
);
mockService.setup.mockResolvedValue({
name: 'my-feature',
path: '/mock/project/.gemini/worktrees/my-feature',
baseSha: 'base-sha',
});
});
afterEach(() => {
process.env = { ...originalEnv };
process.cwd = originalCwd;
delete (process as { chdir?: typeof process.chdir }).chdir;
});
it('should create and switch to a new worktree', async () => {
await setupWorktree('my-feature');
expect(coreFunctions.getProjectRootForWorktree).toHaveBeenCalledWith(
'/mock/project',
);
expect(coreFunctions.createWorktreeService).toHaveBeenCalledWith(
'/mock/project',
);
expect(mockService.setup).toHaveBeenCalledWith('my-feature');
expect(process.chdir).toHaveBeenCalledWith(
'/mock/project/.gemini/worktrees/my-feature',
);
expect(process.env['GEMINI_CLI_WORKTREE_HANDLED']).toBe('1');
});
it('should generate a name if worktreeName is undefined', async () => {
mockService.setup.mockResolvedValue({
name: 'generated-name',
path: '/mock/project/.gemini/worktrees/generated-name',
baseSha: 'base-sha',
});
await setupWorktree(undefined);
expect(mockService.setup).toHaveBeenCalledWith(undefined);
});
it('should skip worktree creation if GEMINI_CLI_WORKTREE_HANDLED is set', async () => {
process.env['GEMINI_CLI_WORKTREE_HANDLED'] = '1';
await setupWorktree('my-feature');
expect(coreFunctions.createWorktreeService).not.toHaveBeenCalled();
expect(process.chdir).not.toHaveBeenCalled();
});
it('should handle errors gracefully and exit', async () => {
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('PROCESS_EXIT');
});
mockService.setup.mockRejectedValue(new Error('Git failure'));
await expect(setupWorktree('my-feature')).rejects.toThrow('PROCESS_EXIT');
expect(coreFunctions.writeToStderr).toHaveBeenCalledWith(
expect.stringContaining(
'Failed to create or switch to worktree: Git failure',
),
);
expect(mockExit).toHaveBeenCalledWith(1);
mockExit.mockRestore();
});
});
+43
View File
@@ -0,0 +1,43 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
getProjectRootForWorktree,
createWorktreeService,
writeToStderr,
type WorktreeInfo,
} from '@google/gemini-cli-core';
/**
* Sets up a git worktree for parallel sessions.
*
* This function uses a guard (GEMINI_CLI_WORKTREE_HANDLED) to ensure that
* when the CLI relaunches itself (e.g. for memory allocation), it doesn't
* attempt to create a nested worktree.
*/
export async function setupWorktree(
worktreeName: string | undefined,
): Promise<WorktreeInfo | undefined> {
if (process.env['GEMINI_CLI_WORKTREE_HANDLED'] === '1') {
return undefined;
}
try {
const projectRoot = await getProjectRootForWorktree(process.cwd());
const service = await createWorktreeService(projectRoot);
const worktreeInfo = await service.setup(worktreeName || undefined);
process.chdir(worktreeInfo.path);
process.env['GEMINI_CLI_WORKTREE_HANDLED'] = '1';
return worktreeInfo;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
writeToStderr(`Failed to create or switch to worktree: ${errorMessage}\n`);
process.exit(1);
}
}
+13
View File
@@ -528,6 +528,12 @@ export interface PolicyUpdateConfirmationRequest {
newHash: string; newHash: string;
} }
export interface WorktreeSettings {
name: string;
path: string;
baseSha: string;
}
export interface ConfigParameters { export interface ConfigParameters {
sessionId: string; sessionId: string;
clientName?: string; clientName?: string;
@@ -651,6 +657,7 @@ export interface ConfigParameters {
plan?: boolean; plan?: boolean;
tracker?: boolean; tracker?: boolean;
planSettings?: PlanSettings; planSettings?: PlanSettings;
worktreeSettings?: WorktreeSettings;
modelSteering?: boolean; modelSteering?: boolean;
onModelChange?: (model: string) => void; onModelChange?: (model: string) => void;
mcpEnabled?: boolean; mcpEnabled?: boolean;
@@ -695,6 +702,7 @@ export class Config implements McpContext, AgentLoopContext {
private workspaceContext: WorkspaceContext; private workspaceContext: WorkspaceContext;
private readonly debugMode: boolean; private readonly debugMode: boolean;
private readonly question: string | undefined; private readonly question: string | undefined;
private readonly worktreeSettings: WorktreeSettings | undefined;
readonly enableConseca: boolean; readonly enableConseca: boolean;
private readonly coreTools: string[] | undefined; private readonly coreTools: string[] | undefined;
@@ -925,6 +933,7 @@ export class Config implements McpContext, AgentLoopContext {
this.pendingIncludeDirectories = params.includeDirectories ?? []; this.pendingIncludeDirectories = params.includeDirectories ?? [];
this.debugMode = params.debugMode; this.debugMode = params.debugMode;
this.question = params.question; this.question = params.question;
this.worktreeSettings = params.worktreeSettings;
this.coreTools = params.coreTools; this.coreTools = params.coreTools;
this.mainAgentTools = params.mainAgentTools; this.mainAgentTools = params.mainAgentTools;
@@ -1555,6 +1564,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.promptId; return this.promptId;
} }
getWorktreeSettings(): WorktreeSettings | undefined {
return this.worktreeSettings;
}
getClientName(): string | undefined { getClientName(): string | undefined {
return this.clientName; return this.clientName;
} }
+1
View File
@@ -237,6 +237,7 @@ export * from './agents/types.js';
// Export stdio utils // Export stdio utils
export * from './utils/stdio.js'; export * from './utils/stdio.js';
export * from './utils/terminal.js'; export * from './utils/terminal.js';
export * from './services/worktreeService.js';
// Export voice utilities // Export voice utilities
export * from './voice/responseFormatter.js'; export * from './voice/responseFormatter.js';
@@ -0,0 +1,311 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import {
getProjectRootForWorktree,
createWorktree,
isGeminiWorktree,
hasWorktreeChanges,
cleanupWorktree,
getWorktreePath,
WorktreeService,
} from './worktreeService.js';
import { execa } from 'execa';
vi.mock('execa');
vi.mock('node:fs/promises');
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return {
...actual,
realpathSync: vi.fn((p: string) => p),
};
});
describe('worktree utilities', () => {
const projectRoot = '/mock/project';
const worktreeName = 'test-feature';
const expectedPath = path.join(
projectRoot,
'.gemini',
'worktrees',
worktreeName,
);
beforeEach(() => {
vi.clearAllMocks();
});
describe('getProjectRootForWorktree', () => {
it('should return the project root from git common dir', async () => {
// In main repo, git-common-dir is often just ".git"
vi.mocked(execa).mockResolvedValue({
stdout: '.git\n',
} as never);
const result = await getProjectRootForWorktree('/mock/project');
expect(result).toBe('/mock/project');
expect(execa).toHaveBeenCalledWith(
'git',
['rev-parse', '--git-common-dir'],
{ cwd: '/mock/project' },
);
});
it('should resolve absolute git common dir paths (as seen in worktrees)', async () => {
// Inside a worktree, git-common-dir is usually an absolute path to the main .git folder
vi.mocked(execa).mockResolvedValue({
stdout: '/mock/project/.git\n',
} as never);
const result = await getProjectRootForWorktree(
'/mock/project/.gemini/worktrees/my-feature',
);
expect(result).toBe('/mock/project');
});
it('should fallback to cwd if git command fails', async () => {
vi.mocked(execa).mockRejectedValue(new Error('not a git repo'));
const result = await getProjectRootForWorktree('/mock/non-git/src');
expect(result).toBe('/mock/non-git/src');
});
});
describe('getWorktreePath', () => {
it('should return the correct path for a given name', () => {
expect(getWorktreePath(projectRoot, worktreeName)).toBe(expectedPath);
});
});
describe('createWorktree', () => {
it('should execute git worktree add with correct branch and path', async () => {
vi.mocked(execa).mockResolvedValue({ stdout: '' } as never);
const resultPath = await createWorktree(projectRoot, worktreeName);
expect(resultPath).toBe(expectedPath);
expect(execa).toHaveBeenCalledWith(
'git',
['worktree', 'add', expectedPath, '-b', `worktree-${worktreeName}`],
{ cwd: projectRoot },
);
});
it('should throw an error if git worktree add fails', async () => {
vi.mocked(execa).mockRejectedValue(new Error('git failed'));
await expect(createWorktree(projectRoot, worktreeName)).rejects.toThrow(
'git failed',
);
});
});
describe('isGeminiWorktree', () => {
it('should return true for a valid gemini worktree path', () => {
expect(isGeminiWorktree(expectedPath, projectRoot)).toBe(true);
expect(
isGeminiWorktree(path.join(expectedPath, 'src'), projectRoot),
).toBe(true);
});
it('should return false for a path outside gemini worktrees', () => {
expect(isGeminiWorktree(path.join(projectRoot, 'src'), projectRoot)).toBe(
false,
);
expect(isGeminiWorktree('/some/other/path', projectRoot)).toBe(false);
});
});
describe('hasWorktreeChanges', () => {
it('should return true if git status --porcelain has output', async () => {
vi.mocked(execa).mockResolvedValue({
stdout: ' M somefile.txt\n?? newfile.txt',
} as never);
const hasChanges = await hasWorktreeChanges(expectedPath);
expect(hasChanges).toBe(true);
expect(execa).toHaveBeenCalledWith('git', ['status', '--porcelain'], {
cwd: expectedPath,
});
});
it('should return true if there are untracked files', async () => {
vi.mocked(execa).mockResolvedValue({
stdout: '?? untracked-file.txt\n',
} as never);
const hasChanges = await hasWorktreeChanges(expectedPath);
expect(hasChanges).toBe(true);
});
it('should return true if HEAD differs from baseSha', async () => {
vi.mocked(execa)
.mockResolvedValueOnce({ stdout: '' } as never) // status clean
.mockResolvedValueOnce({ stdout: 'different-sha' } as never); // HEAD moved
const hasChanges = await hasWorktreeChanges(expectedPath, 'base-sha');
expect(hasChanges).toBe(true);
});
it('should return false if status is clean and HEAD matches baseSha', async () => {
vi.mocked(execa)
.mockResolvedValueOnce({ stdout: '' } as never) // status clean
.mockResolvedValueOnce({ stdout: 'base-sha' } as never); // HEAD same
const hasChanges = await hasWorktreeChanges(expectedPath, 'base-sha');
expect(hasChanges).toBe(false);
});
it('should return true if any git command fails', async () => {
vi.mocked(execa).mockRejectedValue(new Error('git error'));
const hasChanges = await hasWorktreeChanges(expectedPath);
expect(hasChanges).toBe(true);
});
});
describe('cleanupWorktree', () => {
it('should remove the worktree and delete the branch', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(execa)
.mockResolvedValueOnce({
stdout: `worktree-${worktreeName}\n`,
} as never) // branch --show-current
.mockResolvedValueOnce({ stdout: '' } as never) // remove
.mockResolvedValueOnce({ stdout: '' } as never); // branch -D
await cleanupWorktree(expectedPath, projectRoot);
expect(execa).toHaveBeenCalledTimes(3);
expect(execa).toHaveBeenNthCalledWith(
1,
'git',
['-C', expectedPath, 'branch', '--show-current'],
{ cwd: projectRoot },
);
expect(execa).toHaveBeenNthCalledWith(
2,
'git',
['worktree', 'remove', expectedPath, '--force'],
{ cwd: projectRoot },
);
expect(execa).toHaveBeenNthCalledWith(
3,
'git',
['branch', '-D', `worktree-${worktreeName}`],
{ cwd: projectRoot },
);
});
it('should handle branch discovery failure gracefully', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(execa)
.mockResolvedValueOnce({ stdout: '' } as never) // no branch found
.mockResolvedValueOnce({ stdout: '' } as never); // remove
await cleanupWorktree(expectedPath, projectRoot);
expect(execa).toHaveBeenCalledTimes(2);
expect(execa).toHaveBeenNthCalledWith(
2,
'git',
['worktree', 'remove', expectedPath, '--force'],
{ cwd: projectRoot },
);
});
});
});
describe('WorktreeService', () => {
const projectRoot = '/mock/project';
const service = new WorktreeService(projectRoot);
beforeEach(() => {
vi.clearAllMocks();
});
describe('setup', () => {
it('should capture baseSha and create a worktree', async () => {
vi.mocked(execa).mockResolvedValue({
stdout: 'current-sha\n',
} as never);
const info = await service.setup('feature-x');
expect(execa).toHaveBeenCalledWith('git', ['rev-parse', 'HEAD'], {
cwd: projectRoot,
});
expect(info.name).toBe('feature-x');
expect(info.baseSha).toBe('current-sha');
expect(info.path).toContain('feature-x');
});
it('should generate a timestamped name if none provided', async () => {
vi.mocked(execa).mockResolvedValue({
stdout: 'current-sha\n',
} as never);
const info = await service.setup();
expect(info.name).toMatch(/^\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-\w+/);
expect(info.path).toContain(info.name);
});
});
describe('maybeCleanup', () => {
const info = {
name: 'feature-x',
path: '/mock/project/.gemini/worktrees/feature-x',
baseSha: 'base-sha',
};
it('should cleanup unmodified worktrees', async () => {
// Mock hasWorktreeChanges -> false (no changes)
vi.mocked(execa)
.mockResolvedValueOnce({ stdout: '' } as never) // status check
.mockResolvedValueOnce({ stdout: 'base-sha' } as never); // SHA check
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(execa).mockResolvedValue({ stdout: '' } as never); // cleanup calls
const cleanedUp = await service.maybeCleanup(info);
expect(cleanedUp).toBe(true);
// Verify cleanupWorktree utilities were called (execa calls inside cleanupWorktree)
expect(execa).toHaveBeenCalledWith(
expect.anything(),
expect.arrayContaining(['worktree', 'remove', info.path, '--force']),
expect.anything(),
);
});
it('should preserve modified worktrees', async () => {
// Mock hasWorktreeChanges -> true (changes detected)
vi.mocked(execa).mockResolvedValue({
stdout: ' M modified-file.ts',
} as never);
const cleanedUp = await service.maybeCleanup(info);
expect(cleanedUp).toBe(false);
// Ensure cleanupWorktree was NOT called
expect(execa).not.toHaveBeenCalledWith(
expect.anything(),
expect.arrayContaining(['worktree', 'remove']),
expect.anything(),
);
});
});
});
@@ -0,0 +1,225 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import { realpathSync } from 'node:fs';
import { execa } from 'execa';
import { debugLogger } from '../utils/debugLogger.js';
export interface WorktreeInfo {
name: string;
path: string;
baseSha: string;
}
/**
* Service for managing Git worktrees within Gemini CLI.
* Handles creation, cleanup, and environment setup for isolated sessions.
*/
export class WorktreeService {
constructor(private readonly projectRoot: string) {}
/**
* Creates a new worktree and prepares the environment.
*/
async setup(name?: string): Promise<WorktreeInfo> {
let worktreeName = name?.trim();
if (!worktreeName) {
const now = new Date();
const timestamp = now
.toISOString()
.replace(/[:.]/g, '-')
.replace('T', '-')
.replace('Z', '');
const randomSuffix = Math.random().toString(36).substring(2, 6);
worktreeName = `${timestamp}-${randomSuffix}`;
}
// Capture the base commit before creating the worktree
const { stdout: baseSha } = await execa('git', ['rev-parse', 'HEAD'], {
cwd: this.projectRoot,
});
const worktreePath = await createWorktree(this.projectRoot, worktreeName);
return {
name: worktreeName,
path: worktreePath,
baseSha: baseSha.trim(),
};
}
/**
* Checks if a worktree has changes and cleans it up if it's unmodified.
*/
async maybeCleanup(info: WorktreeInfo): Promise<boolean> {
const hasChanges = await hasWorktreeChanges(info.path, info.baseSha);
if (!hasChanges) {
try {
await cleanupWorktree(info.path, this.projectRoot);
debugLogger.log(
`Automatically cleaned up unmodified worktree: ${info.path}`,
);
return true;
} catch (error) {
debugLogger.error(
`Failed to clean up worktree ${info.path}: ${error instanceof Error ? error.message : String(error)}`,
);
}
} else {
debugLogger.debug(
`Preserving worktree ${info.path} because it has changes.`,
);
}
return false;
}
}
export async function createWorktreeService(
cwd: string,
): Promise<WorktreeService> {
const projectRoot = await getProjectRootForWorktree(cwd);
return new WorktreeService(projectRoot);
}
// Low-level worktree utilities
export async function getProjectRootForWorktree(cwd: string): Promise<string> {
try {
const { stdout } = await execa('git', ['rev-parse', '--git-common-dir'], {
cwd,
});
const gitCommonDir = stdout.trim();
const absoluteGitDir = path.isAbsolute(gitCommonDir)
? gitCommonDir
: path.resolve(cwd, gitCommonDir);
// The project root is the parent of the .git directory/file
return path.dirname(absoluteGitDir);
} catch (e: unknown) {
debugLogger.debug(
`Failed to get project root for worktree at ${cwd}: ${e instanceof Error ? e.message : String(e)}`,
);
return cwd;
}
}
export function getWorktreePath(projectRoot: string, name: string): string {
return path.join(projectRoot, '.gemini', 'worktrees', name);
}
export async function createWorktree(
projectRoot: string,
name: string,
): Promise<string> {
const worktreePath = getWorktreePath(projectRoot, name);
const branchName = `worktree-${name}`;
await execa('git', ['worktree', 'add', worktreePath, '-b', branchName], {
cwd: projectRoot,
});
return worktreePath;
}
export function isGeminiWorktree(
dirPath: string,
projectRoot: string,
): boolean {
try {
const realDirPath = realpathSync(dirPath);
const realProjectRoot = realpathSync(projectRoot);
const worktreesBaseDir = path.join(realProjectRoot, '.gemini', 'worktrees');
const relative = path.relative(worktreesBaseDir, realDirPath);
return !relative.startsWith('..') && !path.isAbsolute(relative);
} catch {
return false;
}
}
export async function hasWorktreeChanges(
dirPath: string,
baseSha?: string,
): Promise<boolean> {
try {
// 1. Check for uncommitted changes (index or working tree)
const { stdout: status } = await execa('git', ['status', '--porcelain'], {
cwd: dirPath,
});
if (status.trim() !== '') {
return true;
}
// 2. Check if the current commit has moved from the base
if (baseSha) {
const { stdout: currentSha } = await execa('git', ['rev-parse', 'HEAD'], {
cwd: dirPath,
});
if (currentSha.trim() !== baseSha) {
return true;
}
}
return false;
} catch (e: unknown) {
debugLogger.debug(
`Failed to check worktree changes at ${dirPath}: ${e instanceof Error ? e.message : String(e)}`,
);
// If any git command fails, assume the worktree is dirty to be safe.
return true;
}
}
export async function cleanupWorktree(
dirPath: string,
projectRoot: string,
): Promise<void> {
try {
await fs.access(dirPath);
} catch {
return; // Worktree already gone
}
let branchName: string | undefined;
try {
// 1. Discover the branch name associated with this worktree path
const { stdout } = await execa(
'git',
['-C', dirPath, 'branch', '--show-current'],
{
cwd: projectRoot,
},
);
branchName = stdout.trim() || undefined;
// 2. Remove the worktree
await execa('git', ['worktree', 'remove', dirPath, '--force'], {
cwd: projectRoot,
});
} catch (e: unknown) {
debugLogger.debug(
`Failed to remove worktree ${dirPath}: ${e instanceof Error ? e.message : String(e)}`,
);
} finally {
// 3. Delete the branch if we found it
if (branchName) {
try {
await execa('git', ['branch', '-D', branchName], {
cwd: projectRoot,
});
} catch (e: unknown) {
debugLogger.debug(
`Failed to delete branch ${branchName}: ${e instanceof Error ? e.message : String(e)}`,
);
}
}
}
}
@@ -48,16 +48,16 @@ export interface ProcessImportsResult {
importTree: MemoryFile; importTree: MemoryFile;
} }
// Helper to find the project root (looks for .git directory) // Helper to find the project root (looks for .git directory or file for worktrees)
async function findProjectRoot(startDir: string): Promise<string> { async function findProjectRoot(startDir: string): Promise<string> {
let currentDir = path.resolve(startDir); let currentDir = path.resolve(startDir);
while (true) { while (true) {
const gitPath = path.join(currentDir, '.git'); const gitPath = path.join(currentDir, '.git');
try { try {
const stats = await fs.lstat(gitPath); // Check for existence only — .git can be a directory (normal repos)
if (stats.isDirectory()) { // or a file (submodules / worktrees).
return currentDir; await fs.access(gitPath);
} return currentDir;
} catch { } catch {
// .git not found, continue to parent // .git not found, continue to parent
} }
+7
View File
@@ -2663,6 +2663,13 @@
"default": true, "default": true,
"type": "boolean" "type": "boolean"
}, },
"worktrees": {
"title": "Enable Git Worktrees",
"description": "Enable automated Git worktree management for parallel work.",
"markdownDescription": "Enable automated Git worktree management for parallel work.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `false`",
"default": false,
"type": "boolean"
},
"extensionManagement": { "extensionManagement": {
"title": "Extension Management", "title": "Extension Management",
"description": "Enable extension management features.", "description": "Enable extension management features.",