mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
feat(worktree): add Git worktree support for isolated parallel sessions (#22973)
This commit is contained in:
@@ -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. |
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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` |
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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', () => ({
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
Reference in New Issue
Block a user