Restore Checkpoint Feature (#934)
This commit is contained in:
parent
f75c48323c
commit
e0f4f428fc
|
@ -1166,6 +1166,21 @@
|
||||||
"tslib": "2"
|
"tslib": "2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@kwsites/file-exists": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@kwsites/promise-deferred": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@modelcontextprotocol/sdk": {
|
"node_modules/@modelcontextprotocol/sdk": {
|
||||||
"version": "1.12.1",
|
"version": "1.12.1",
|
||||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.12.1.tgz",
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.12.1.tgz",
|
||||||
|
@ -8522,6 +8537,21 @@
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/simple-git": {
|
||||||
|
"version": "3.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.28.0.tgz",
|
||||||
|
"integrity": "sha512-Rs/vQRwsn1ILH1oBUy8NucJlXmnnLeLCfcvbSehkPzbv3wwoFWIdtfd6Ndo6ZPhlPsCZ60CPI4rxurnwAa+a2w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@kwsites/file-exists": "^1.1.1",
|
||||||
|
"@kwsites/promise-deferred": "^1.1.1",
|
||||||
|
"debug": "^4.4.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/steveukx/git-js?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/slice-ansi": {
|
"node_modules/slice-ansi": {
|
||||||
"version": "7.1.0",
|
"version": "7.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz",
|
||||||
|
@ -10765,6 +10795,7 @@
|
||||||
"ignore": "^7.0.0",
|
"ignore": "^7.0.0",
|
||||||
"open": "^10.1.2",
|
"open": "^10.1.2",
|
||||||
"shell-quote": "^1.8.2",
|
"shell-quote": "^1.8.2",
|
||||||
|
"simple-git": "^3.27.0",
|
||||||
"strip-ansi": "^7.1.0",
|
"strip-ansi": "^7.1.0",
|
||||||
"undici": "^7.10.0"
|
"undici": "^7.10.0"
|
||||||
},
|
},
|
||||||
|
|
|
@ -43,6 +43,7 @@ interface CliArgs {
|
||||||
show_memory_usage: boolean | undefined;
|
show_memory_usage: boolean | undefined;
|
||||||
yolo: boolean | undefined;
|
yolo: boolean | undefined;
|
||||||
telemetry: boolean | undefined;
|
telemetry: boolean | undefined;
|
||||||
|
checkpoint: boolean | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function parseArguments(): Promise<CliArgs> {
|
async function parseArguments(): Promise<CliArgs> {
|
||||||
|
@ -91,6 +92,12 @@ async function parseArguments(): Promise<CliArgs> {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
description: 'Enable telemetry?',
|
description: 'Enable telemetry?',
|
||||||
})
|
})
|
||||||
|
.option('checkpoint', {
|
||||||
|
alias: 'c',
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Enables checkpointing of file edits',
|
||||||
|
default: false,
|
||||||
|
})
|
||||||
.version(process.env.CLI_VERSION || '0.0.0') // This will enable the --version flag based on package.json
|
.version(process.env.CLI_VERSION || '0.0.0') // This will enable the --version flag based on package.json
|
||||||
.help()
|
.help()
|
||||||
.alias('h', 'help')
|
.alias('h', 'help')
|
||||||
|
@ -178,6 +185,7 @@ export async function loadCliConfig(
|
||||||
fileFilteringAllowBuildArtifacts:
|
fileFilteringAllowBuildArtifacts:
|
||||||
settings.fileFiltering?.allowBuildArtifacts,
|
settings.fileFiltering?.allowBuildArtifacts,
|
||||||
enableModifyWithExternalEditors: settings.enableModifyWithExternalEditors,
|
enableModifyWithExternalEditors: settings.enableModifyWithExternalEditors,
|
||||||
|
checkpoint: argv.checkpoint,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -17,6 +17,7 @@ import { getStartupWarnings } from './utils/startupWarnings.js';
|
||||||
import { runNonInteractive } from './nonInteractiveCli.js';
|
import { runNonInteractive } from './nonInteractiveCli.js';
|
||||||
import { loadGeminiIgnorePatterns } from './utils/loadIgnorePatterns.js';
|
import { loadGeminiIgnorePatterns } from './utils/loadIgnorePatterns.js';
|
||||||
import { loadExtensions, ExtensionConfig } from './config/extension.js';
|
import { loadExtensions, ExtensionConfig } from './config/extension.js';
|
||||||
|
import { cleanupCheckpoints } from './utils/cleanup.js';
|
||||||
import {
|
import {
|
||||||
ApprovalMode,
|
ApprovalMode,
|
||||||
Config,
|
Config,
|
||||||
|
@ -40,7 +41,7 @@ export async function main() {
|
||||||
setWindowTitle(basename(workspaceRoot), settings);
|
setWindowTitle(basename(workspaceRoot), settings);
|
||||||
|
|
||||||
const geminiIgnorePatterns = loadGeminiIgnorePatterns(workspaceRoot);
|
const geminiIgnorePatterns = loadGeminiIgnorePatterns(workspaceRoot);
|
||||||
|
await cleanupCheckpoints();
|
||||||
if (settings.errors.length > 0) {
|
if (settings.errors.length > 0) {
|
||||||
for (const error of settings.errors) {
|
for (const error of settings.errors) {
|
||||||
let errorMessage = `Error in ${error.path}: ${error.message}`;
|
let errorMessage = `Error in ${error.path}: ${error.message}`;
|
||||||
|
@ -63,6 +64,13 @@ export async function main() {
|
||||||
|
|
||||||
// Initialize centralized FileDiscoveryService
|
// Initialize centralized FileDiscoveryService
|
||||||
await config.getFileService();
|
await config.getFileService();
|
||||||
|
if (config.getCheckpointEnabled()) {
|
||||||
|
try {
|
||||||
|
await config.getGitService();
|
||||||
|
} catch {
|
||||||
|
// For now swallow the error, later log it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (settings.merged.theme) {
|
if (settings.merged.theme) {
|
||||||
if (!themeManager.setActiveTheme(settings.merged.theme)) {
|
if (!themeManager.setActiveTheme(settings.merged.theme)) {
|
||||||
|
|
|
@ -63,6 +63,7 @@ interface MockServerConfig {
|
||||||
getVertexAI: Mock<() => boolean | undefined>;
|
getVertexAI: Mock<() => boolean | undefined>;
|
||||||
getShowMemoryUsage: Mock<() => boolean>;
|
getShowMemoryUsage: Mock<() => boolean>;
|
||||||
getAccessibility: Mock<() => AccessibilitySettings>;
|
getAccessibility: Mock<() => AccessibilitySettings>;
|
||||||
|
getProjectRoot: Mock<() => string | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mock @gemini-cli/core and its Config class
|
// Mock @gemini-cli/core and its Config class
|
||||||
|
@ -120,7 +121,9 @@ vi.mock('@gemini-cli/core', async (importOriginal) => {
|
||||||
getVertexAI: vi.fn(() => opts.vertexai),
|
getVertexAI: vi.fn(() => opts.vertexai),
|
||||||
getShowMemoryUsage: vi.fn(() => opts.showMemoryUsage ?? false),
|
getShowMemoryUsage: vi.fn(() => opts.showMemoryUsage ?? false),
|
||||||
getAccessibility: vi.fn(() => opts.accessibility ?? {}),
|
getAccessibility: vi.fn(() => opts.accessibility ?? {}),
|
||||||
|
getProjectRoot: vi.fn(() => opts.projectRoot),
|
||||||
getGeminiClient: vi.fn(() => ({})),
|
getGeminiClient: vi.fn(() => ({})),
|
||||||
|
getCheckpointEnabled: vi.fn(() => opts.checkpoint ?? true),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -66,7 +66,7 @@ export const AppWrapper = (props: AppProps) => (
|
||||||
);
|
);
|
||||||
|
|
||||||
const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
||||||
const { history, addItem, clearItems } = useHistory();
|
const { history, addItem, clearItems, loadHistory } = useHistory();
|
||||||
const {
|
const {
|
||||||
consoleMessages,
|
consoleMessages,
|
||||||
handleNewMessage,
|
handleNewMessage,
|
||||||
|
@ -151,8 +151,10 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
||||||
|
|
||||||
const { handleSlashCommand, slashCommands } = useSlashCommandProcessor(
|
const { handleSlashCommand, slashCommands } = useSlashCommandProcessor(
|
||||||
config,
|
config,
|
||||||
|
history,
|
||||||
addItem,
|
addItem,
|
||||||
clearItems,
|
clearItems,
|
||||||
|
loadHistory,
|
||||||
refreshStatic,
|
refreshStatic,
|
||||||
setShowHelp,
|
setShowHelp,
|
||||||
setDebugMessage,
|
setDebugMessage,
|
||||||
|
@ -217,6 +219,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
||||||
const { streamingState, submitQuery, initError, pendingHistoryItems } =
|
const { streamingState, submitQuery, initError, pendingHistoryItems } =
|
||||||
useGeminiStream(
|
useGeminiStream(
|
||||||
config.getGeminiClient(),
|
config.getGeminiClient(),
|
||||||
|
history,
|
||||||
addItem,
|
addItem,
|
||||||
setShowHelp,
|
setShowHelp,
|
||||||
config,
|
config,
|
||||||
|
@ -512,7 +515,6 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Footer
|
<Footer
|
||||||
model={config.getModel()}
|
model={config.getModel()}
|
||||||
targetDir={config.getTargetDir()}
|
targetDir={config.getTargetDir()}
|
||||||
|
|
|
@ -65,6 +65,14 @@ import {
|
||||||
} from '@gemini-cli/core';
|
} from '@gemini-cli/core';
|
||||||
import { useSessionStats } from '../contexts/SessionContext.js';
|
import { useSessionStats } from '../contexts/SessionContext.js';
|
||||||
|
|
||||||
|
vi.mock('@gemini-code/core', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@gemini-code/core')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
GitService: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
import * as ShowMemoryCommandModule from './useShowMemoryCommand.js';
|
import * as ShowMemoryCommandModule from './useShowMemoryCommand.js';
|
||||||
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
|
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
|
||||||
|
|
||||||
|
@ -84,6 +92,7 @@ vi.mock('open', () => ({
|
||||||
describe('useSlashCommandProcessor', () => {
|
describe('useSlashCommandProcessor', () => {
|
||||||
let mockAddItem: ReturnType<typeof vi.fn>;
|
let mockAddItem: ReturnType<typeof vi.fn>;
|
||||||
let mockClearItems: ReturnType<typeof vi.fn>;
|
let mockClearItems: ReturnType<typeof vi.fn>;
|
||||||
|
let mockLoadHistory: ReturnType<typeof vi.fn>;
|
||||||
let mockRefreshStatic: ReturnType<typeof vi.fn>;
|
let mockRefreshStatic: ReturnType<typeof vi.fn>;
|
||||||
let mockSetShowHelp: ReturnType<typeof vi.fn>;
|
let mockSetShowHelp: ReturnType<typeof vi.fn>;
|
||||||
let mockOnDebugMessage: ReturnType<typeof vi.fn>;
|
let mockOnDebugMessage: ReturnType<typeof vi.fn>;
|
||||||
|
@ -96,6 +105,7 @@ describe('useSlashCommandProcessor', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockAddItem = vi.fn();
|
mockAddItem = vi.fn();
|
||||||
mockClearItems = vi.fn();
|
mockClearItems = vi.fn();
|
||||||
|
mockLoadHistory = vi.fn();
|
||||||
mockRefreshStatic = vi.fn();
|
mockRefreshStatic = vi.fn();
|
||||||
mockSetShowHelp = vi.fn();
|
mockSetShowHelp = vi.fn();
|
||||||
mockOnDebugMessage = vi.fn();
|
mockOnDebugMessage = vi.fn();
|
||||||
|
@ -105,6 +115,8 @@ describe('useSlashCommandProcessor', () => {
|
||||||
getDebugMode: vi.fn(() => false),
|
getDebugMode: vi.fn(() => false),
|
||||||
getSandbox: vi.fn(() => 'test-sandbox'),
|
getSandbox: vi.fn(() => 'test-sandbox'),
|
||||||
getModel: vi.fn(() => 'test-model'),
|
getModel: vi.fn(() => 'test-model'),
|
||||||
|
getProjectRoot: vi.fn(() => '/test/dir'),
|
||||||
|
getCheckpointEnabled: vi.fn(() => true),
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
mockCorgiMode = vi.fn();
|
mockCorgiMode = vi.fn();
|
||||||
mockUseSessionStats.mockReturnValue({
|
mockUseSessionStats.mockReturnValue({
|
||||||
|
@ -133,8 +145,10 @@ describe('useSlashCommandProcessor', () => {
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useSlashCommandProcessor(
|
useSlashCommandProcessor(
|
||||||
mockConfig,
|
mockConfig,
|
||||||
|
[],
|
||||||
mockAddItem,
|
mockAddItem,
|
||||||
mockClearItems,
|
mockClearItems,
|
||||||
|
mockLoadHistory,
|
||||||
mockRefreshStatic,
|
mockRefreshStatic,
|
||||||
mockSetShowHelp,
|
mockSetShowHelp,
|
||||||
mockOnDebugMessage,
|
mockOnDebugMessage,
|
||||||
|
@ -153,7 +167,7 @@ describe('useSlashCommandProcessor', () => {
|
||||||
const fact = 'Remember this fact';
|
const fact = 'Remember this fact';
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
let commandResult: SlashCommandActionReturn | boolean = false;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
commandResult = handleSlashCommand(`/memory add ${fact}`);
|
commandResult = await handleSlashCommand(`/memory add ${fact}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockAddItem).toHaveBeenNthCalledWith(
|
expect(mockAddItem).toHaveBeenNthCalledWith(
|
||||||
|
@ -187,7 +201,7 @@ describe('useSlashCommandProcessor', () => {
|
||||||
const { handleSlashCommand } = getProcessor();
|
const { handleSlashCommand } = getProcessor();
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
let commandResult: SlashCommandActionReturn | boolean = false;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
commandResult = handleSlashCommand('/memory add ');
|
commandResult = await handleSlashCommand('/memory add ');
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockAddItem).toHaveBeenNthCalledWith(
|
expect(mockAddItem).toHaveBeenNthCalledWith(
|
||||||
|
@ -211,7 +225,7 @@ describe('useSlashCommandProcessor', () => {
|
||||||
const { handleSlashCommand } = getProcessor();
|
const { handleSlashCommand } = getProcessor();
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
let commandResult: SlashCommandActionReturn | boolean = false;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
commandResult = handleSlashCommand('/memory show');
|
commandResult = await handleSlashCommand('/memory show');
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
ShowMemoryCommandModule.createShowMemoryAction,
|
ShowMemoryCommandModule.createShowMemoryAction,
|
||||||
|
@ -226,7 +240,7 @@ describe('useSlashCommandProcessor', () => {
|
||||||
const { handleSlashCommand } = getProcessor();
|
const { handleSlashCommand } = getProcessor();
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
let commandResult: SlashCommandActionReturn | boolean = false;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
commandResult = handleSlashCommand('/memory refresh');
|
commandResult = await handleSlashCommand('/memory refresh');
|
||||||
});
|
});
|
||||||
expect(mockPerformMemoryRefresh).toHaveBeenCalled();
|
expect(mockPerformMemoryRefresh).toHaveBeenCalled();
|
||||||
expect(commandResult).toBe(true);
|
expect(commandResult).toBe(true);
|
||||||
|
@ -238,7 +252,7 @@ describe('useSlashCommandProcessor', () => {
|
||||||
const { handleSlashCommand } = getProcessor();
|
const { handleSlashCommand } = getProcessor();
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
let commandResult: SlashCommandActionReturn | boolean = false;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
commandResult = handleSlashCommand('/memory foobar');
|
commandResult = await handleSlashCommand('/memory foobar');
|
||||||
});
|
});
|
||||||
expect(mockAddItem).toHaveBeenNthCalledWith(
|
expect(mockAddItem).toHaveBeenNthCalledWith(
|
||||||
2,
|
2,
|
||||||
|
@ -300,7 +314,7 @@ describe('useSlashCommandProcessor', () => {
|
||||||
const { handleSlashCommand } = getProcessor();
|
const { handleSlashCommand } = getProcessor();
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
let commandResult: SlashCommandActionReturn | boolean = false;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
commandResult = handleSlashCommand('/help');
|
commandResult = await handleSlashCommand('/help');
|
||||||
});
|
});
|
||||||
expect(mockSetShowHelp).toHaveBeenCalledWith(true);
|
expect(mockSetShowHelp).toHaveBeenCalledWith(true);
|
||||||
expect(commandResult).toBe(true);
|
expect(commandResult).toBe(true);
|
||||||
|
@ -373,7 +387,7 @@ Add any other context about the problem here.
|
||||||
);
|
);
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
let commandResult: SlashCommandActionReturn | boolean = false;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
commandResult = handleSlashCommand(`/bug ${bugDescription}`);
|
commandResult = await handleSlashCommand(`/bug ${bugDescription}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockAddItem).toHaveBeenCalledTimes(2);
|
expect(mockAddItem).toHaveBeenCalledTimes(2);
|
||||||
|
@ -387,7 +401,7 @@ Add any other context about the problem here.
|
||||||
const { handleSlashCommand } = getProcessor();
|
const { handleSlashCommand } = getProcessor();
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
let commandResult: SlashCommandActionReturn | boolean = false;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
commandResult = handleSlashCommand('/unknowncommand');
|
commandResult = await handleSlashCommand('/unknowncommand');
|
||||||
});
|
});
|
||||||
expect(mockAddItem).toHaveBeenNthCalledWith(
|
expect(mockAddItem).toHaveBeenNthCalledWith(
|
||||||
2,
|
2,
|
||||||
|
@ -410,7 +424,7 @@ Add any other context about the problem here.
|
||||||
const { handleSlashCommand } = getProcessor();
|
const { handleSlashCommand } = getProcessor();
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
let commandResult: SlashCommandActionReturn | boolean = false;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
commandResult = handleSlashCommand('/tools');
|
commandResult = await handleSlashCommand('/tools');
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockAddItem).toHaveBeenNthCalledWith(
|
expect(mockAddItem).toHaveBeenNthCalledWith(
|
||||||
|
@ -434,7 +448,7 @@ Add any other context about the problem here.
|
||||||
const { handleSlashCommand } = getProcessor();
|
const { handleSlashCommand } = getProcessor();
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
let commandResult: SlashCommandActionReturn | boolean = false;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
commandResult = handleSlashCommand('/tools');
|
commandResult = await handleSlashCommand('/tools');
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockAddItem).toHaveBeenNthCalledWith(
|
expect(mockAddItem).toHaveBeenNthCalledWith(
|
||||||
|
@ -467,7 +481,7 @@ Add any other context about the problem here.
|
||||||
const { handleSlashCommand } = getProcessor();
|
const { handleSlashCommand } = getProcessor();
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
let commandResult: SlashCommandActionReturn | boolean = false;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
commandResult = handleSlashCommand('/tools');
|
commandResult = await handleSlashCommand('/tools');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Should only show tool1 and tool2, not the MCP tools
|
// Should only show tool1 and tool2, not the MCP tools
|
||||||
|
@ -499,7 +513,7 @@ Add any other context about the problem here.
|
||||||
const { handleSlashCommand } = getProcessor();
|
const { handleSlashCommand } = getProcessor();
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
let commandResult: SlashCommandActionReturn | boolean = false;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
commandResult = handleSlashCommand('/tools');
|
commandResult = await handleSlashCommand('/tools');
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockAddItem).toHaveBeenNthCalledWith(
|
expect(mockAddItem).toHaveBeenNthCalledWith(
|
||||||
|
@ -545,7 +559,7 @@ Add any other context about the problem here.
|
||||||
const { handleSlashCommand } = getProcessor();
|
const { handleSlashCommand } = getProcessor();
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
let commandResult: SlashCommandActionReturn | boolean = false;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
commandResult = handleSlashCommand('/mcp');
|
commandResult = await handleSlashCommand('/mcp');
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockAddItem).toHaveBeenNthCalledWith(
|
expect(mockAddItem).toHaveBeenNthCalledWith(
|
||||||
|
@ -571,7 +585,7 @@ Add any other context about the problem here.
|
||||||
const { handleSlashCommand } = getProcessor();
|
const { handleSlashCommand } = getProcessor();
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
let commandResult: SlashCommandActionReturn | boolean = false;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
commandResult = handleSlashCommand('/mcp');
|
commandResult = await handleSlashCommand('/mcp');
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockAddItem).toHaveBeenNthCalledWith(
|
expect(mockAddItem).toHaveBeenNthCalledWith(
|
||||||
|
@ -633,7 +647,7 @@ Add any other context about the problem here.
|
||||||
const { handleSlashCommand } = getProcessor();
|
const { handleSlashCommand } = getProcessor();
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
let commandResult: SlashCommandActionReturn | boolean = false;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
commandResult = handleSlashCommand('/mcp');
|
commandResult = await handleSlashCommand('/mcp');
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockAddItem).toHaveBeenNthCalledWith(
|
expect(mockAddItem).toHaveBeenNthCalledWith(
|
||||||
|
@ -706,7 +720,7 @@ Add any other context about the problem here.
|
||||||
const { handleSlashCommand } = getProcessor(true);
|
const { handleSlashCommand } = getProcessor(true);
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
let commandResult: SlashCommandActionReturn | boolean = false;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
commandResult = handleSlashCommand('/mcp');
|
commandResult = await handleSlashCommand('/mcp');
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockAddItem).toHaveBeenNthCalledWith(
|
expect(mockAddItem).toHaveBeenNthCalledWith(
|
||||||
|
@ -780,7 +794,7 @@ Add any other context about the problem here.
|
||||||
const { handleSlashCommand } = getProcessor();
|
const { handleSlashCommand } = getProcessor();
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
let commandResult: SlashCommandActionReturn | boolean = false;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
commandResult = handleSlashCommand('/mcp');
|
commandResult = await handleSlashCommand('/mcp');
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockAddItem).toHaveBeenNthCalledWith(
|
expect(mockAddItem).toHaveBeenNthCalledWith(
|
||||||
|
@ -846,7 +860,7 @@ Add any other context about the problem here.
|
||||||
const { handleSlashCommand } = getProcessor();
|
const { handleSlashCommand } = getProcessor();
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
let commandResult: SlashCommandActionReturn | boolean = false;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
commandResult = handleSlashCommand('/mcp');
|
commandResult = await handleSlashCommand('/mcp');
|
||||||
});
|
});
|
||||||
|
|
||||||
const message = mockAddItem.mock.calls[1][0].text;
|
const message = mockAddItem.mock.calls[1][0].text;
|
||||||
|
|
|
@ -11,14 +11,22 @@ import process from 'node:process';
|
||||||
import { UseHistoryManagerReturn } from './useHistoryManager.js';
|
import { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||||
import {
|
import {
|
||||||
Config,
|
Config,
|
||||||
|
GitService,
|
||||||
Logger,
|
Logger,
|
||||||
MCPDiscoveryState,
|
MCPDiscoveryState,
|
||||||
MCPServerStatus,
|
MCPServerStatus,
|
||||||
getMCPDiscoveryState,
|
getMCPDiscoveryState,
|
||||||
getMCPServerStatus,
|
getMCPServerStatus,
|
||||||
} from '@gemini-cli/core';
|
} from '@gemini-cli/core';
|
||||||
import { Message, MessageType, HistoryItemWithoutId } from '../types.js';
|
|
||||||
import { useSessionStats } from '../contexts/SessionContext.js';
|
import { useSessionStats } from '../contexts/SessionContext.js';
|
||||||
|
import {
|
||||||
|
Message,
|
||||||
|
MessageType,
|
||||||
|
HistoryItemWithoutId,
|
||||||
|
HistoryItem,
|
||||||
|
} from '../types.js';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
import { createShowMemoryAction } from './useShowMemoryCommand.js';
|
import { createShowMemoryAction } from './useShowMemoryCommand.js';
|
||||||
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
|
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
|
||||||
import { formatDuration, formatMemoryUsage } from '../utils/formatters.js';
|
import { formatDuration, formatMemoryUsage } from '../utils/formatters.js';
|
||||||
|
@ -39,7 +47,10 @@ export interface SlashCommand {
|
||||||
mainCommand: string,
|
mainCommand: string,
|
||||||
subCommand?: string,
|
subCommand?: string,
|
||||||
args?: string,
|
args?: string,
|
||||||
) => void | SlashCommandActionReturn; // Action can now return this object
|
) =>
|
||||||
|
| void
|
||||||
|
| SlashCommandActionReturn
|
||||||
|
| Promise<void | SlashCommandActionReturn>; // Action can now return this object
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -47,8 +58,10 @@ export interface SlashCommand {
|
||||||
*/
|
*/
|
||||||
export const useSlashCommandProcessor = (
|
export const useSlashCommandProcessor = (
|
||||||
config: Config | null,
|
config: Config | null,
|
||||||
|
history: HistoryItem[],
|
||||||
addItem: UseHistoryManagerReturn['addItem'],
|
addItem: UseHistoryManagerReturn['addItem'],
|
||||||
clearItems: UseHistoryManagerReturn['clearItems'],
|
clearItems: UseHistoryManagerReturn['clearItems'],
|
||||||
|
loadHistory: UseHistoryManagerReturn['loadHistory'],
|
||||||
refreshStatic: () => void,
|
refreshStatic: () => void,
|
||||||
setShowHelp: React.Dispatch<React.SetStateAction<boolean>>,
|
setShowHelp: React.Dispatch<React.SetStateAction<boolean>>,
|
||||||
onDebugMessage: (message: string) => void,
|
onDebugMessage: (message: string) => void,
|
||||||
|
@ -58,6 +71,13 @@ export const useSlashCommandProcessor = (
|
||||||
showToolDescriptions: boolean = false,
|
showToolDescriptions: boolean = false,
|
||||||
) => {
|
) => {
|
||||||
const session = useSessionStats();
|
const session = useSessionStats();
|
||||||
|
const gitService = useMemo(() => {
|
||||||
|
if (!config?.getProjectRoot()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return new GitService(config.getProjectRoot());
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
const addMessage = useCallback(
|
const addMessage = useCallback(
|
||||||
(message: Message) => {
|
(message: Message) => {
|
||||||
// Convert Message to HistoryItemWithoutId
|
// Convert Message to HistoryItemWithoutId
|
||||||
|
@ -126,8 +146,8 @@ export const useSlashCommandProcessor = (
|
||||||
[addMessage],
|
[addMessage],
|
||||||
);
|
);
|
||||||
|
|
||||||
const slashCommands: SlashCommand[] = useMemo(
|
const slashCommands: SlashCommand[] = useMemo(() => {
|
||||||
() => [
|
const commands: SlashCommand[] = [
|
||||||
{
|
{
|
||||||
name: 'help',
|
name: 'help',
|
||||||
altName: '?',
|
altName: '?',
|
||||||
|
@ -408,7 +428,9 @@ export const useSlashCommandProcessor = (
|
||||||
if (process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec') {
|
if (process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec') {
|
||||||
sandboxEnv = process.env.SANDBOX;
|
sandboxEnv = process.env.SANDBOX;
|
||||||
} else if (process.env.SANDBOX === 'sandbox-exec') {
|
} else if (process.env.SANDBOX === 'sandbox-exec') {
|
||||||
sandboxEnv = `sandbox-exec (${process.env.SEATBELT_PROFILE || 'unknown'})`;
|
sandboxEnv = `sandbox-exec (${
|
||||||
|
process.env.SEATBELT_PROFILE || 'unknown'
|
||||||
|
})`;
|
||||||
}
|
}
|
||||||
const modelVersion = config?.getModel() || 'Unknown';
|
const modelVersion = config?.getModel() || 'Unknown';
|
||||||
const cliVersion = getCliVersion();
|
const cliVersion = getCliVersion();
|
||||||
|
@ -437,7 +459,9 @@ export const useSlashCommandProcessor = (
|
||||||
if (process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec') {
|
if (process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec') {
|
||||||
sandboxEnv = process.env.SANDBOX.replace(/^gemini-(?:code-)?/, '');
|
sandboxEnv = process.env.SANDBOX.replace(/^gemini-(?:code-)?/, '');
|
||||||
} else if (process.env.SANDBOX === 'sandbox-exec') {
|
} else if (process.env.SANDBOX === 'sandbox-exec') {
|
||||||
sandboxEnv = `sandbox-exec (${process.env.SEATBELT_PROFILE || 'unknown'})`;
|
sandboxEnv = `sandbox-exec (${
|
||||||
|
process.env.SEATBELT_PROFILE || 'unknown'
|
||||||
|
})`;
|
||||||
}
|
}
|
||||||
const modelVersion = config?.getModel() || 'Unknown';
|
const modelVersion = config?.getModel() || 'Unknown';
|
||||||
const memoryUsage = formatMemoryUsage(process.memoryUsage().rss);
|
const memoryUsage = formatMemoryUsage(process.memoryUsage().rss);
|
||||||
|
@ -569,31 +593,140 @@ Add any other context about the problem here.
|
||||||
name: 'quit',
|
name: 'quit',
|
||||||
altName: 'exit',
|
altName: 'exit',
|
||||||
description: 'exit the cli',
|
description: 'exit the cli',
|
||||||
action: (_mainCommand, _subCommand, _args) => {
|
action: async (_mainCommand, _subCommand, _args) => {
|
||||||
onDebugMessage('Quitting. Good-bye.');
|
onDebugMessage('Quitting. Good-bye.');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
];
|
||||||
[
|
|
||||||
onDebugMessage,
|
if (config?.getCheckpointEnabled()) {
|
||||||
setShowHelp,
|
commands.push({
|
||||||
refreshStatic,
|
name: 'restore',
|
||||||
openThemeDialog,
|
description:
|
||||||
clearItems,
|
'restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested',
|
||||||
performMemoryRefresh,
|
action: async (_mainCommand, subCommand, _args) => {
|
||||||
showMemoryAction,
|
const checkpointDir = config?.getGeminiDir()
|
||||||
addMemoryAction,
|
? path.join(config.getGeminiDir(), 'checkpoints')
|
||||||
addMessage,
|
: undefined;
|
||||||
toggleCorgiMode,
|
|
||||||
config,
|
if (!checkpointDir) {
|
||||||
showToolDescriptions,
|
addMessage({
|
||||||
session,
|
type: MessageType.ERROR,
|
||||||
],
|
content: 'Could not determine the .gemini directory path.',
|
||||||
);
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Ensure the directory exists before trying to read it.
|
||||||
|
await fs.mkdir(checkpointDir, { recursive: true });
|
||||||
|
const files = await fs.readdir(checkpointDir);
|
||||||
|
const jsonFiles = files.filter((file) => file.endsWith('.json'));
|
||||||
|
|
||||||
|
if (!subCommand) {
|
||||||
|
if (jsonFiles.length === 0) {
|
||||||
|
addMessage({
|
||||||
|
type: MessageType.INFO,
|
||||||
|
content: 'No restorable tool calls found.',
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const truncatedFiles = jsonFiles.map((file) => {
|
||||||
|
const components = file.split('.');
|
||||||
|
if (components.length <= 1) {
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
components.pop();
|
||||||
|
return components.join('.');
|
||||||
|
});
|
||||||
|
const fileList = truncatedFiles.join('\n');
|
||||||
|
addMessage({
|
||||||
|
type: MessageType.INFO,
|
||||||
|
content: `Available tool calls to restore:\n\n${fileList}`,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedFile = subCommand.endsWith('.json')
|
||||||
|
? subCommand
|
||||||
|
: `${subCommand}.json`;
|
||||||
|
|
||||||
|
if (!jsonFiles.includes(selectedFile)) {
|
||||||
|
addMessage({
|
||||||
|
type: MessageType.ERROR,
|
||||||
|
content: `File not found: ${selectedFile}`,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(checkpointDir, selectedFile);
|
||||||
|
const data = await fs.readFile(filePath, 'utf-8');
|
||||||
|
const toolCallData = JSON.parse(data);
|
||||||
|
|
||||||
|
if (toolCallData.history) {
|
||||||
|
loadHistory(toolCallData.history);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolCallData.clientHistory) {
|
||||||
|
await config
|
||||||
|
?.getGeminiClient()
|
||||||
|
?.setHistory(toolCallData.clientHistory);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolCallData.commitHash) {
|
||||||
|
await gitService?.restoreProjectFromSnapshot(
|
||||||
|
toolCallData.commitHash,
|
||||||
|
);
|
||||||
|
addMessage({
|
||||||
|
type: MessageType.INFO,
|
||||||
|
content: `Restored project to the state before the tool call.`,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
shouldScheduleTool: true,
|
||||||
|
toolName: toolCallData.toolCall.name,
|
||||||
|
toolArgs: toolCallData.toolCall.args,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
addMessage({
|
||||||
|
type: MessageType.ERROR,
|
||||||
|
content: `Could not read restorable tool calls. This is the error: ${error}`,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return commands;
|
||||||
|
}, [
|
||||||
|
onDebugMessage,
|
||||||
|
setShowHelp,
|
||||||
|
refreshStatic,
|
||||||
|
openThemeDialog,
|
||||||
|
clearItems,
|
||||||
|
performMemoryRefresh,
|
||||||
|
showMemoryAction,
|
||||||
|
addMemoryAction,
|
||||||
|
addMessage,
|
||||||
|
toggleCorgiMode,
|
||||||
|
config,
|
||||||
|
showToolDescriptions,
|
||||||
|
session,
|
||||||
|
gitService,
|
||||||
|
loadHistory,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleSlashCommand = useCallback(
|
const handleSlashCommand = useCallback(
|
||||||
(rawQuery: PartListUnion): SlashCommandActionReturn | boolean => {
|
async (
|
||||||
|
rawQuery: PartListUnion,
|
||||||
|
): Promise<SlashCommandActionReturn | boolean> => {
|
||||||
if (typeof rawQuery !== 'string') {
|
if (typeof rawQuery !== 'string') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -625,7 +758,7 @@ Add any other context about the problem here.
|
||||||
|
|
||||||
for (const cmd of slashCommands) {
|
for (const cmd of slashCommands) {
|
||||||
if (mainCommand === cmd.name || mainCommand === cmd.altName) {
|
if (mainCommand === cmd.name || mainCommand === cmd.altName) {
|
||||||
const actionResult = cmd.action(mainCommand, subCommand, args);
|
const actionResult = await cmd.action(mainCommand, subCommand, args);
|
||||||
if (
|
if (
|
||||||
typeof actionResult === 'object' &&
|
typeof actionResult === 'object' &&
|
||||||
actionResult?.shouldScheduleTool
|
actionResult?.shouldScheduleTool
|
||||||
|
|
|
@ -18,6 +18,7 @@ import {
|
||||||
import { Config } from '@gemini-cli/core';
|
import { Config } from '@gemini-cli/core';
|
||||||
import { Part, PartListUnion } from '@google/genai';
|
import { Part, PartListUnion } from '@google/genai';
|
||||||
import { UseHistoryManagerReturn } from './useHistoryManager.js';
|
import { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||||
|
import { HistoryItem } from '../types.js';
|
||||||
import { Dispatch, SetStateAction } from 'react';
|
import { Dispatch, SetStateAction } from 'react';
|
||||||
|
|
||||||
// --- MOCKS ---
|
// --- MOCKS ---
|
||||||
|
@ -38,9 +39,9 @@ const MockedGeminiClientClass = vi.hoisted(() =>
|
||||||
vi.mock('@gemini-cli/core', async (importOriginal) => {
|
vi.mock('@gemini-cli/core', async (importOriginal) => {
|
||||||
const actualCoreModule = (await importOriginal()) as any;
|
const actualCoreModule = (await importOriginal()) as any;
|
||||||
return {
|
return {
|
||||||
...(actualCoreModule || {}),
|
...actualCoreModule,
|
||||||
GeminiClient: MockedGeminiClientClass, // Export the class for type checking or other direct uses
|
GitService: vi.fn(),
|
||||||
Config: actualCoreModule.Config, // Ensure Config is passed through
|
GeminiClient: MockedGeminiClientClass,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -277,11 +278,13 @@ describe('useGeminiStream', () => {
|
||||||
getToolRegistry: vi.fn(
|
getToolRegistry: vi.fn(
|
||||||
() => ({ getToolSchemaList: vi.fn(() => []) }) as any,
|
() => ({ getToolSchemaList: vi.fn(() => []) }) as any,
|
||||||
),
|
),
|
||||||
|
getProjectRoot: vi.fn(() => '/test/dir'),
|
||||||
|
getCheckpointEnabled: vi.fn(() => false),
|
||||||
getGeminiClient: mockGetGeminiClient,
|
getGeminiClient: mockGetGeminiClient,
|
||||||
addHistory: vi.fn(),
|
addHistory: vi.fn(),
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
mockOnDebugMessage = vi.fn();
|
mockOnDebugMessage = vi.fn();
|
||||||
mockHandleSlashCommand = vi.fn().mockReturnValue(false);
|
mockHandleSlashCommand = vi.fn().mockResolvedValue(false);
|
||||||
|
|
||||||
// Mock return value for useReactToolScheduler
|
// Mock return value for useReactToolScheduler
|
||||||
mockScheduleToolCalls = vi.fn();
|
mockScheduleToolCalls = vi.fn();
|
||||||
|
@ -322,19 +325,22 @@ describe('useGeminiStream', () => {
|
||||||
const { result, rerender } = renderHook(
|
const { result, rerender } = renderHook(
|
||||||
(props: {
|
(props: {
|
||||||
client: any;
|
client: any;
|
||||||
|
history: HistoryItem[];
|
||||||
addItem: UseHistoryManagerReturn['addItem'];
|
addItem: UseHistoryManagerReturn['addItem'];
|
||||||
setShowHelp: Dispatch<SetStateAction<boolean>>;
|
setShowHelp: Dispatch<SetStateAction<boolean>>;
|
||||||
config: Config;
|
config: Config;
|
||||||
onDebugMessage: (message: string) => void;
|
onDebugMessage: (message: string) => void;
|
||||||
handleSlashCommand: (
|
handleSlashCommand: (
|
||||||
command: PartListUnion,
|
cmd: PartListUnion,
|
||||||
) =>
|
) => Promise<
|
||||||
| import('./slashCommandProcessor.js').SlashCommandActionReturn
|
| import('./slashCommandProcessor.js').SlashCommandActionReturn
|
||||||
| boolean;
|
| boolean
|
||||||
|
>;
|
||||||
shellModeActive: boolean;
|
shellModeActive: boolean;
|
||||||
}) =>
|
}) =>
|
||||||
useGeminiStream(
|
useGeminiStream(
|
||||||
props.client,
|
props.client,
|
||||||
|
props.history,
|
||||||
props.addItem,
|
props.addItem,
|
||||||
props.setShowHelp,
|
props.setShowHelp,
|
||||||
props.config,
|
props.config,
|
||||||
|
@ -345,12 +351,17 @@ describe('useGeminiStream', () => {
|
||||||
{
|
{
|
||||||
initialProps: {
|
initialProps: {
|
||||||
client,
|
client,
|
||||||
|
history: [],
|
||||||
addItem: mockAddItem as unknown as UseHistoryManagerReturn['addItem'],
|
addItem: mockAddItem as unknown as UseHistoryManagerReturn['addItem'],
|
||||||
setShowHelp: mockSetShowHelp,
|
setShowHelp: mockSetShowHelp,
|
||||||
config: mockConfig,
|
config: mockConfig,
|
||||||
onDebugMessage: mockOnDebugMessage,
|
onDebugMessage: mockOnDebugMessage,
|
||||||
handleSlashCommand:
|
handleSlashCommand: mockHandleSlashCommand as unknown as (
|
||||||
mockHandleSlashCommand as unknown as typeof mockHandleSlashCommand,
|
cmd: PartListUnion,
|
||||||
|
) => Promise<
|
||||||
|
| import('./slashCommandProcessor.js').SlashCommandActionReturn
|
||||||
|
| boolean
|
||||||
|
>,
|
||||||
shellModeActive: false,
|
shellModeActive: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -467,7 +478,8 @@ describe('useGeminiStream', () => {
|
||||||
act(() => {
|
act(() => {
|
||||||
rerender({
|
rerender({
|
||||||
client,
|
client,
|
||||||
addItem: mockAddItem as unknown as UseHistoryManagerReturn['addItem'],
|
history: [],
|
||||||
|
addItem: mockAddItem,
|
||||||
setShowHelp: mockSetShowHelp,
|
setShowHelp: mockSetShowHelp,
|
||||||
config: mockConfig,
|
config: mockConfig,
|
||||||
onDebugMessage: mockOnDebugMessage,
|
onDebugMessage: mockOnDebugMessage,
|
||||||
|
@ -521,7 +533,8 @@ describe('useGeminiStream', () => {
|
||||||
act(() => {
|
act(() => {
|
||||||
rerender({
|
rerender({
|
||||||
client,
|
client,
|
||||||
addItem: mockAddItem as unknown as UseHistoryManagerReturn['addItem'],
|
history: [],
|
||||||
|
addItem: mockAddItem,
|
||||||
setShowHelp: mockSetShowHelp,
|
setShowHelp: mockSetShowHelp,
|
||||||
config: mockConfig,
|
config: mockConfig,
|
||||||
onDebugMessage: mockOnDebugMessage,
|
onDebugMessage: mockOnDebugMessage,
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
||||||
import { useInput } from 'ink';
|
import { useInput } from 'ink';
|
||||||
import {
|
import {
|
||||||
|
Config,
|
||||||
GeminiClient,
|
GeminiClient,
|
||||||
GeminiEventType as ServerGeminiEventType,
|
GeminiEventType as ServerGeminiEventType,
|
||||||
ServerGeminiStreamEvent as GeminiEvent,
|
ServerGeminiStreamEvent as GeminiEvent,
|
||||||
|
@ -14,14 +15,15 @@ import {
|
||||||
ServerGeminiErrorEvent as ErrorEvent,
|
ServerGeminiErrorEvent as ErrorEvent,
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
isNodeError,
|
isNodeError,
|
||||||
Config,
|
|
||||||
MessageSenderType,
|
MessageSenderType,
|
||||||
ToolCallRequestInfo,
|
ToolCallRequestInfo,
|
||||||
logUserPrompt,
|
logUserPrompt,
|
||||||
|
GitService,
|
||||||
} from '@gemini-cli/core';
|
} from '@gemini-cli/core';
|
||||||
import { type Part, type PartListUnion } from '@google/genai';
|
import { type Part, type PartListUnion } from '@google/genai';
|
||||||
import {
|
import {
|
||||||
StreamingState,
|
StreamingState,
|
||||||
|
HistoryItem,
|
||||||
HistoryItemWithoutId,
|
HistoryItemWithoutId,
|
||||||
HistoryItemToolGroup,
|
HistoryItemToolGroup,
|
||||||
MessageType,
|
MessageType,
|
||||||
|
@ -35,6 +37,8 @@ import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
|
||||||
import { useStateAndRef } from './useStateAndRef.js';
|
import { useStateAndRef } from './useStateAndRef.js';
|
||||||
import { UseHistoryManagerReturn } from './useHistoryManager.js';
|
import { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||||
import { useLogger } from './useLogger.js';
|
import { useLogger } from './useLogger.js';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
import {
|
import {
|
||||||
useReactToolScheduler,
|
useReactToolScheduler,
|
||||||
mapToDisplay as mapTrackedToolCallsToDisplay,
|
mapToDisplay as mapTrackedToolCallsToDisplay,
|
||||||
|
@ -68,13 +72,16 @@ enum StreamProcessingStatus {
|
||||||
*/
|
*/
|
||||||
export const useGeminiStream = (
|
export const useGeminiStream = (
|
||||||
geminiClient: GeminiClient | null,
|
geminiClient: GeminiClient | null,
|
||||||
|
history: HistoryItem[],
|
||||||
addItem: UseHistoryManagerReturn['addItem'],
|
addItem: UseHistoryManagerReturn['addItem'],
|
||||||
setShowHelp: React.Dispatch<React.SetStateAction<boolean>>,
|
setShowHelp: React.Dispatch<React.SetStateAction<boolean>>,
|
||||||
config: Config,
|
config: Config,
|
||||||
onDebugMessage: (message: string) => void,
|
onDebugMessage: (message: string) => void,
|
||||||
handleSlashCommand: (
|
handleSlashCommand: (
|
||||||
cmd: PartListUnion,
|
cmd: PartListUnion,
|
||||||
) => import('./slashCommandProcessor.js').SlashCommandActionReturn | boolean,
|
) => Promise<
|
||||||
|
import('./slashCommandProcessor.js').SlashCommandActionReturn | boolean
|
||||||
|
>,
|
||||||
shellModeActive: boolean,
|
shellModeActive: boolean,
|
||||||
) => {
|
) => {
|
||||||
const [initError, setInitError] = useState<string | null>(null);
|
const [initError, setInitError] = useState<string | null>(null);
|
||||||
|
@ -84,6 +91,12 @@ export const useGeminiStream = (
|
||||||
useStateAndRef<HistoryItemWithoutId | null>(null);
|
useStateAndRef<HistoryItemWithoutId | null>(null);
|
||||||
const logger = useLogger();
|
const logger = useLogger();
|
||||||
const { startNewTurn, addUsage } = useSessionStats();
|
const { startNewTurn, addUsage } = useSessionStats();
|
||||||
|
const gitService = useMemo(() => {
|
||||||
|
if (!config.getProjectRoot()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return new GitService(config.getProjectRoot());
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
const [toolCalls, scheduleToolCalls, markToolsAsSubmitted] =
|
const [toolCalls, scheduleToolCalls, markToolsAsSubmitted] =
|
||||||
useReactToolScheduler(
|
useReactToolScheduler(
|
||||||
|
@ -178,7 +191,7 @@ export const useGeminiStream = (
|
||||||
await logger?.logMessage(MessageSenderType.USER, trimmedQuery);
|
await logger?.logMessage(MessageSenderType.USER, trimmedQuery);
|
||||||
|
|
||||||
// Handle UI-only commands first
|
// Handle UI-only commands first
|
||||||
const slashCommandResult = handleSlashCommand(trimmedQuery);
|
const slashCommandResult = await handleSlashCommand(trimmedQuery);
|
||||||
if (typeof slashCommandResult === 'boolean' && slashCommandResult) {
|
if (typeof slashCommandResult === 'boolean' && slashCommandResult) {
|
||||||
// Command was handled, and it doesn't require a tool call from here
|
// Command was handled, and it doesn't require a tool call from here
|
||||||
return { queryToSend: null, shouldProceed: false };
|
return { queryToSend: null, shouldProceed: false };
|
||||||
|
@ -605,6 +618,106 @@ export const useGeminiStream = (
|
||||||
pendingToolCallGroupDisplay,
|
pendingToolCallGroupDisplay,
|
||||||
].filter((i) => i !== undefined && i !== null);
|
].filter((i) => i !== undefined && i !== null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const saveRestorableToolCalls = async () => {
|
||||||
|
if (!config.getCheckpointEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const restorableToolCalls = toolCalls.filter(
|
||||||
|
(toolCall) =>
|
||||||
|
(toolCall.request.name === 'replace' ||
|
||||||
|
toolCall.request.name === 'write_file') &&
|
||||||
|
toolCall.status === 'awaiting_approval',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (restorableToolCalls.length > 0) {
|
||||||
|
const checkpointDir = config.getGeminiDir()
|
||||||
|
? path.join(config.getGeminiDir(), 'checkpoints')
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (!checkpointDir) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.mkdir(checkpointDir, { recursive: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (!isNodeError(error) || error.code !== 'EEXIST') {
|
||||||
|
onDebugMessage(
|
||||||
|
`Failed to create checkpoint directory: ${getErrorMessage(error)}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const toolCall of restorableToolCalls) {
|
||||||
|
const filePath = toolCall.request.args['file_path'] as string;
|
||||||
|
if (!filePath) {
|
||||||
|
onDebugMessage(
|
||||||
|
`Skipping restorable tool call due to missing file_path: ${toolCall.request.name}`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let commitHash = await gitService?.createFileSnapshot(
|
||||||
|
`Snapshot for ${toolCall.request.name}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!commitHash) {
|
||||||
|
commitHash = await gitService?.getCurrentCommitHash();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!commitHash) {
|
||||||
|
onDebugMessage(
|
||||||
|
`Failed to create snapshot for ${filePath}. Skipping restorable tool call.`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = new Date()
|
||||||
|
.toISOString()
|
||||||
|
.replace(/:/g, '-')
|
||||||
|
.replace(/\./g, '_');
|
||||||
|
const toolName = toolCall.request.name;
|
||||||
|
const fileName = path.basename(filePath);
|
||||||
|
const toolCallWithSnapshotFileName = `${timestamp}-${fileName}-${toolName}.json`;
|
||||||
|
const clientHistory = await geminiClient?.getHistory();
|
||||||
|
const toolCallWithSnapshotFilePath = path.join(
|
||||||
|
checkpointDir,
|
||||||
|
toolCallWithSnapshotFileName,
|
||||||
|
);
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
toolCallWithSnapshotFilePath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
history,
|
||||||
|
clientHistory,
|
||||||
|
toolCall: {
|
||||||
|
name: toolCall.request.name,
|
||||||
|
args: toolCall.request.args,
|
||||||
|
},
|
||||||
|
commitHash,
|
||||||
|
filePath,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
onDebugMessage(
|
||||||
|
`Failed to write restorable tool call file: ${getErrorMessage(
|
||||||
|
error,
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
saveRestorableToolCalls();
|
||||||
|
}, [toolCalls, config, onDebugMessage, gitService, history, geminiClient]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
streamingState,
|
streamingState,
|
||||||
submitQuery,
|
submitQuery,
|
||||||
|
|
|
@ -20,6 +20,7 @@ export interface UseHistoryManagerReturn {
|
||||||
updates: Partial<Omit<HistoryItem, 'id'>> | HistoryItemUpdater,
|
updates: Partial<Omit<HistoryItem, 'id'>> | HistoryItemUpdater,
|
||||||
) => void;
|
) => void;
|
||||||
clearItems: () => void;
|
clearItems: () => void;
|
||||||
|
loadHistory: (newHistory: HistoryItem[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -38,6 +39,10 @@ export function useHistory(): UseHistoryManagerReturn {
|
||||||
return baseTimestamp + messageIdCounterRef.current;
|
return baseTimestamp + messageIdCounterRef.current;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadHistory = useCallback((newHistory: HistoryItem[]) => {
|
||||||
|
setHistory(newHistory);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Adds a new item to the history state with a unique ID.
|
// Adds a new item to the history state with a unique ID.
|
||||||
const addItem = useCallback(
|
const addItem = useCallback(
|
||||||
(itemData: Omit<HistoryItem, 'id'>, baseTimestamp: number): number => {
|
(itemData: Omit<HistoryItem, 'id'>, baseTimestamp: number): number => {
|
||||||
|
@ -101,5 +106,6 @@ export function useHistory(): UseHistoryManagerReturn {
|
||||||
addItem,
|
addItem,
|
||||||
updateItem,
|
updateItem,
|
||||||
clearItems,
|
clearItems,
|
||||||
|
loadHistory,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,8 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { sessionId } from '@gemini-cli/core';
|
import { sessionId, Logger } from '@gemini-cli/core';
|
||||||
import { Logger } from '@gemini-cli/core';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to manage the logger instance.
|
* Hook to manage the logger instance.
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
export async function cleanupCheckpoints() {
|
||||||
|
const geminiDir = join(process.cwd(), '.gemini');
|
||||||
|
const checkpointsDir = join(geminiDir, 'checkpoints');
|
||||||
|
try {
|
||||||
|
await fs.rm(checkpointsDir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// Ignore errors if the directory doesn't exist or fails to delete.
|
||||||
|
}
|
||||||
|
}
|
|
@ -36,6 +36,7 @@
|
||||||
"ignore": "^7.0.0",
|
"ignore": "^7.0.0",
|
||||||
"open": "^10.1.2",
|
"open": "^10.1.2",
|
||||||
"shell-quote": "^1.8.2",
|
"shell-quote": "^1.8.2",
|
||||||
|
"simple-git": "^3.27.0",
|
||||||
"strip-ansi": "^7.1.0",
|
"strip-ansi": "^7.1.0",
|
||||||
"undici": "^7.10.0"
|
"undici": "^7.10.0"
|
||||||
},
|
},
|
||||||
|
|
|
@ -25,6 +25,7 @@ import { WebSearchTool } from '../tools/web-search.js';
|
||||||
import { GeminiClient } from '../core/client.js';
|
import { GeminiClient } from '../core/client.js';
|
||||||
import { GEMINI_CONFIG_DIR as GEMINI_DIR } from '../tools/memoryTool.js';
|
import { GEMINI_CONFIG_DIR as GEMINI_DIR } from '../tools/memoryTool.js';
|
||||||
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
|
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
|
||||||
|
import { GitService } from '../services/gitService.js';
|
||||||
import { initializeTelemetry } from '../telemetry/index.js';
|
import { initializeTelemetry } from '../telemetry/index.js';
|
||||||
|
|
||||||
export enum ApprovalMode {
|
export enum ApprovalMode {
|
||||||
|
@ -80,6 +81,7 @@ export interface ConfigParameters {
|
||||||
fileFilteringRespectGitIgnore?: boolean;
|
fileFilteringRespectGitIgnore?: boolean;
|
||||||
fileFilteringAllowBuildArtifacts?: boolean;
|
fileFilteringAllowBuildArtifacts?: boolean;
|
||||||
enableModifyWithExternalEditors?: boolean;
|
enableModifyWithExternalEditors?: boolean;
|
||||||
|
checkpoint?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Config {
|
export class Config {
|
||||||
|
@ -111,6 +113,8 @@ export class Config {
|
||||||
private readonly fileFilteringAllowBuildArtifacts: boolean;
|
private readonly fileFilteringAllowBuildArtifacts: boolean;
|
||||||
private readonly enableModifyWithExternalEditors: boolean;
|
private readonly enableModifyWithExternalEditors: boolean;
|
||||||
private fileDiscoveryService: FileDiscoveryService | null = null;
|
private fileDiscoveryService: FileDiscoveryService | null = null;
|
||||||
|
private gitService: GitService | undefined = undefined;
|
||||||
|
private readonly checkpoint: boolean;
|
||||||
|
|
||||||
constructor(params: ConfigParameters) {
|
constructor(params: ConfigParameters) {
|
||||||
this.sessionId = params.sessionId;
|
this.sessionId = params.sessionId;
|
||||||
|
@ -142,6 +146,7 @@ export class Config {
|
||||||
params.fileFilteringAllowBuildArtifacts ?? false;
|
params.fileFilteringAllowBuildArtifacts ?? false;
|
||||||
this.enableModifyWithExternalEditors =
|
this.enableModifyWithExternalEditors =
|
||||||
params.enableModifyWithExternalEditors ?? false;
|
params.enableModifyWithExternalEditors ?? false;
|
||||||
|
this.checkpoint = params.checkpoint ?? false;
|
||||||
|
|
||||||
if (params.contextFileName) {
|
if (params.contextFileName) {
|
||||||
setGeminiMdFilename(params.contextFileName);
|
setGeminiMdFilename(params.contextFileName);
|
||||||
|
@ -182,6 +187,10 @@ export class Config {
|
||||||
return this.targetDir;
|
return this.targetDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getProjectRoot(): string {
|
||||||
|
return this.targetDir;
|
||||||
|
}
|
||||||
|
|
||||||
async getToolRegistry(): Promise<ToolRegistry> {
|
async getToolRegistry(): Promise<ToolRegistry> {
|
||||||
return this.toolRegistry;
|
return this.toolRegistry;
|
||||||
}
|
}
|
||||||
|
@ -265,6 +274,10 @@ export class Config {
|
||||||
return this.geminiClient;
|
return this.geminiClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getGeminiDir(): string {
|
||||||
|
return path.join(this.targetDir, GEMINI_DIR);
|
||||||
|
}
|
||||||
|
|
||||||
getGeminiIgnorePatterns(): string[] {
|
getGeminiIgnorePatterns(): string[] {
|
||||||
return this.geminiIgnorePatterns;
|
return this.geminiIgnorePatterns;
|
||||||
}
|
}
|
||||||
|
@ -281,6 +294,10 @@ export class Config {
|
||||||
return this.enableModifyWithExternalEditors;
|
return this.enableModifyWithExternalEditors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getCheckpointEnabled(): boolean {
|
||||||
|
return this.checkpoint;
|
||||||
|
}
|
||||||
|
|
||||||
async getFileService(): Promise<FileDiscoveryService> {
|
async getFileService(): Promise<FileDiscoveryService> {
|
||||||
if (!this.fileDiscoveryService) {
|
if (!this.fileDiscoveryService) {
|
||||||
this.fileDiscoveryService = new FileDiscoveryService(this.targetDir);
|
this.fileDiscoveryService = new FileDiscoveryService(this.targetDir);
|
||||||
|
@ -291,6 +308,14 @@ export class Config {
|
||||||
}
|
}
|
||||||
return this.fileDiscoveryService;
|
return this.fileDiscoveryService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getGitService(): Promise<GitService> {
|
||||||
|
if (!this.gitService) {
|
||||||
|
this.gitService = new GitService(this.targetDir);
|
||||||
|
await this.gitService.initialize();
|
||||||
|
}
|
||||||
|
return this.gitService;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function findEnvFile(startDir: string): string | null {
|
function findEnvFile(startDir: string): string | null {
|
||||||
|
|
|
@ -77,6 +77,16 @@ export class GeminiClient {
|
||||||
return this.chat;
|
return this.chat;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getHistory(): Promise<Content[]> {
|
||||||
|
const chat = await this.chat;
|
||||||
|
return chat.getHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
async setHistory(history: Content[]): Promise<void> {
|
||||||
|
const chat = await this.chat;
|
||||||
|
chat.setHistory(history);
|
||||||
|
}
|
||||||
|
|
||||||
private async getEnvironment(): Promise<Part[]> {
|
private async getEnvironment(): Promise<Part[]> {
|
||||||
const cwd = process.cwd();
|
const cwd = process.cwd();
|
||||||
const today = new Date().toLocaleDateString(undefined, {
|
const today = new Date().toLocaleDateString(undefined, {
|
||||||
|
|
|
@ -297,6 +297,9 @@ export class GeminiChat {
|
||||||
addHistory(content: Content): void {
|
addHistory(content: Content): void {
|
||||||
this.history.push(content);
|
this.history.push(content);
|
||||||
}
|
}
|
||||||
|
setHistory(history: Content[]): void {
|
||||||
|
this.history = history;
|
||||||
|
}
|
||||||
|
|
||||||
private async *processStreamResponse(
|
private async *processStreamResponse(
|
||||||
streamResponse: AsyncGenerator<GenerateContentResponse>,
|
streamResponse: AsyncGenerator<GenerateContentResponse>,
|
||||||
|
|
|
@ -29,6 +29,7 @@ export * from './utils/editor.js';
|
||||||
|
|
||||||
// Export services
|
// Export services
|
||||||
export * from './services/fileDiscoveryService.js';
|
export * from './services/fileDiscoveryService.js';
|
||||||
|
export * from './services/gitService.js';
|
||||||
|
|
||||||
// Export base tool definitions
|
// Export base tool definitions
|
||||||
export * from './tools/tools.js';
|
export * from './tools/tools.js';
|
||||||
|
|
|
@ -0,0 +1,254 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { GitService, historyDirName } from './gitService.js';
|
||||||
|
import * as path from 'path';
|
||||||
|
import type * as FsPromisesModule from 'fs/promises';
|
||||||
|
import type { ChildProcess } from 'node:child_process';
|
||||||
|
|
||||||
|
const hoistedMockExec = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock('node:child_process', () => ({
|
||||||
|
exec: hoistedMockExec,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const hoistedMockMkdir = vi.hoisted(() => vi.fn());
|
||||||
|
const hoistedMockReadFile = vi.hoisted(() => vi.fn());
|
||||||
|
const hoistedMockWriteFile = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock('fs/promises', async (importOriginal) => {
|
||||||
|
const actual = (await importOriginal()) as typeof FsPromisesModule;
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
mkdir: hoistedMockMkdir,
|
||||||
|
readFile: hoistedMockReadFile,
|
||||||
|
writeFile: hoistedMockWriteFile,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const hoistedMockSimpleGit = vi.hoisted(() => vi.fn());
|
||||||
|
const hoistedMockCheckIsRepo = vi.hoisted(() => vi.fn());
|
||||||
|
const hoistedMockInit = vi.hoisted(() => vi.fn());
|
||||||
|
const hoistedMockRaw = vi.hoisted(() => vi.fn());
|
||||||
|
const hoistedMockAdd = vi.hoisted(() => vi.fn());
|
||||||
|
const hoistedMockCommit = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock('simple-git', () => ({
|
||||||
|
simpleGit: hoistedMockSimpleGit.mockImplementation(() => ({
|
||||||
|
checkIsRepo: hoistedMockCheckIsRepo,
|
||||||
|
init: hoistedMockInit,
|
||||||
|
raw: hoistedMockRaw,
|
||||||
|
add: hoistedMockAdd,
|
||||||
|
commit: hoistedMockCommit,
|
||||||
|
})),
|
||||||
|
CheckRepoActions: { IS_REPO_ROOT: 'is-repo-root' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const hoistedIsGitRepositoryMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock('../utils/gitUtils.js', () => ({
|
||||||
|
isGitRepository: hoistedIsGitRepositoryMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const hoistedMockIsNodeError = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock('../utils/errors.js', () => ({
|
||||||
|
isNodeError: hoistedMockIsNodeError,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('GitService', () => {
|
||||||
|
const mockProjectRoot = '/test/project';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
hoistedIsGitRepositoryMock.mockReturnValue(true);
|
||||||
|
hoistedMockExec.mockImplementation((command, callback) => {
|
||||||
|
if (command === 'git --version') {
|
||||||
|
callback(null, 'git version 2.0.0');
|
||||||
|
} else {
|
||||||
|
callback(new Error('Command not mocked'));
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
hoistedMockMkdir.mockResolvedValue(undefined);
|
||||||
|
hoistedMockReadFile.mockResolvedValue('');
|
||||||
|
hoistedMockWriteFile.mockResolvedValue(undefined);
|
||||||
|
hoistedMockIsNodeError.mockImplementation((e) => e instanceof Error);
|
||||||
|
|
||||||
|
hoistedMockSimpleGit.mockImplementation(() => ({
|
||||||
|
checkIsRepo: hoistedMockCheckIsRepo,
|
||||||
|
init: hoistedMockInit,
|
||||||
|
raw: hoistedMockRaw,
|
||||||
|
add: hoistedMockAdd,
|
||||||
|
commit: hoistedMockCommit,
|
||||||
|
}));
|
||||||
|
hoistedMockCheckIsRepo.mockResolvedValue(false);
|
||||||
|
hoistedMockInit.mockResolvedValue(undefined);
|
||||||
|
hoistedMockRaw.mockResolvedValue('');
|
||||||
|
hoistedMockAdd.mockResolvedValue(undefined);
|
||||||
|
hoistedMockCommit.mockResolvedValue({
|
||||||
|
commit: 'initial',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('should successfully create an instance if projectRoot is a Git repository', () => {
|
||||||
|
expect(() => new GitService(mockProjectRoot)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('verifyGitAvailability', () => {
|
||||||
|
it('should resolve true if git --version command succeeds', async () => {
|
||||||
|
const service = new GitService(mockProjectRoot);
|
||||||
|
await expect(service.verifyGitAvailability()).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should resolve false if git --version command fails', async () => {
|
||||||
|
hoistedMockExec.mockImplementation((command, callback) => {
|
||||||
|
callback(new Error('git not found'));
|
||||||
|
return {} as ChildProcess;
|
||||||
|
});
|
||||||
|
const service = new GitService(mockProjectRoot);
|
||||||
|
await expect(service.verifyGitAvailability()).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('initialize', () => {
|
||||||
|
it('should throw an error if projectRoot is not a Git repository', async () => {
|
||||||
|
hoistedIsGitRepositoryMock.mockReturnValue(false);
|
||||||
|
const service = new GitService(mockProjectRoot);
|
||||||
|
await expect(service.initialize()).rejects.toThrow(
|
||||||
|
'GitService requires a Git repository',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw an error if Git is not available', async () => {
|
||||||
|
hoistedMockExec.mockImplementation((command, callback) => {
|
||||||
|
callback(new Error('git not found'));
|
||||||
|
return {} as ChildProcess;
|
||||||
|
});
|
||||||
|
const service = new GitService(mockProjectRoot);
|
||||||
|
await expect(service.initialize()).rejects.toThrow(
|
||||||
|
'GitService requires Git to be installed',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call setupHiddenGitRepository if Git is available', async () => {
|
||||||
|
const service = new GitService(mockProjectRoot);
|
||||||
|
const setupSpy = vi
|
||||||
|
.spyOn(service, 'setupHiddenGitRepository')
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await service.initialize();
|
||||||
|
expect(setupSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setupHiddenGitRepository', () => {
|
||||||
|
const historyDir = path.join(mockProjectRoot, historyDirName);
|
||||||
|
const repoDir = path.join(historyDir, 'repository');
|
||||||
|
const hiddenGitIgnorePath = path.join(repoDir, '.gitignore');
|
||||||
|
const visibleGitIgnorePath = path.join(mockProjectRoot, '.gitignore');
|
||||||
|
|
||||||
|
it('should create history and repository directories', async () => {
|
||||||
|
const service = new GitService(mockProjectRoot);
|
||||||
|
await service.setupHiddenGitRepository();
|
||||||
|
expect(hoistedMockMkdir).toHaveBeenCalledWith(repoDir, {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize git repo in historyDir if not already initialized', async () => {
|
||||||
|
hoistedMockCheckIsRepo.mockResolvedValue(false);
|
||||||
|
const service = new GitService(mockProjectRoot);
|
||||||
|
await service.setupHiddenGitRepository();
|
||||||
|
expect(hoistedMockSimpleGit).toHaveBeenCalledWith(repoDir);
|
||||||
|
expect(hoistedMockInit).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not initialize git repo if already initialized', async () => {
|
||||||
|
hoistedMockCheckIsRepo.mockResolvedValue(true);
|
||||||
|
const service = new GitService(mockProjectRoot);
|
||||||
|
await service.setupHiddenGitRepository();
|
||||||
|
expect(hoistedMockInit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should copy .gitignore from projectRoot if it exists', async () => {
|
||||||
|
const gitignoreContent = `node_modules/\n.env`;
|
||||||
|
hoistedMockReadFile.mockImplementation(async (filePath) => {
|
||||||
|
if (filePath === visibleGitIgnorePath) {
|
||||||
|
return gitignoreContent;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
const service = new GitService(mockProjectRoot);
|
||||||
|
await service.setupHiddenGitRepository();
|
||||||
|
expect(hoistedMockReadFile).toHaveBeenCalledWith(
|
||||||
|
visibleGitIgnorePath,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
expect(hoistedMockWriteFile).toHaveBeenCalledWith(
|
||||||
|
hiddenGitIgnorePath,
|
||||||
|
gitignoreContent,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw an error if reading projectRoot .gitignore fails with other errors', async () => {
|
||||||
|
const readError = new Error('Read permission denied');
|
||||||
|
hoistedMockReadFile.mockImplementation(async (filePath) => {
|
||||||
|
if (filePath === visibleGitIgnorePath) {
|
||||||
|
throw readError;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
hoistedMockIsNodeError.mockImplementation(
|
||||||
|
(e: unknown): e is NodeJS.ErrnoException =>
|
||||||
|
e === readError &&
|
||||||
|
e instanceof Error &&
|
||||||
|
(e as NodeJS.ErrnoException).code !== 'ENOENT',
|
||||||
|
);
|
||||||
|
|
||||||
|
const service = new GitService(mockProjectRoot);
|
||||||
|
await expect(service.setupHiddenGitRepository()).rejects.toThrow(
|
||||||
|
'Read permission denied',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add historyDirName to projectRoot .gitignore if not present', async () => {
|
||||||
|
const initialGitignoreContent = 'node_modules/';
|
||||||
|
hoistedMockReadFile.mockImplementation(async (filePath) => {
|
||||||
|
if (filePath === visibleGitIgnorePath) {
|
||||||
|
return initialGitignoreContent;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
const service = new GitService(mockProjectRoot);
|
||||||
|
await service.setupHiddenGitRepository();
|
||||||
|
const expectedContent = `${initialGitignoreContent}\n# Gemini CLI history directory\n${historyDirName}\n`;
|
||||||
|
expect(hoistedMockWriteFile).toHaveBeenCalledWith(
|
||||||
|
visibleGitIgnorePath,
|
||||||
|
expectedContent,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should make an initial commit if no commits exist in history repo', async () => {
|
||||||
|
hoistedMockRaw.mockResolvedValue('');
|
||||||
|
const service = new GitService(mockProjectRoot);
|
||||||
|
await service.setupHiddenGitRepository();
|
||||||
|
expect(hoistedMockAdd).toHaveBeenCalledWith(hiddenGitIgnorePath);
|
||||||
|
expect(hoistedMockCommit).toHaveBeenCalledWith('Initial commit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not make an initial commit if commits already exist', async () => {
|
||||||
|
hoistedMockRaw.mockResolvedValue('test-commit');
|
||||||
|
const service = new GitService(mockProjectRoot);
|
||||||
|
await service.setupHiddenGitRepository();
|
||||||
|
expect(hoistedMockAdd).not.toHaveBeenCalled();
|
||||||
|
expect(hoistedMockCommit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,132 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'fs/promises';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { isNodeError } from '../utils/errors.js';
|
||||||
|
import { isGitRepository } from '../utils/gitUtils.js';
|
||||||
|
import { exec } from 'node:child_process';
|
||||||
|
import { simpleGit, SimpleGit, CheckRepoActions } from 'simple-git';
|
||||||
|
|
||||||
|
export const historyDirName = '.gemini_cli_history';
|
||||||
|
|
||||||
|
export class GitService {
|
||||||
|
private projectRoot: string;
|
||||||
|
|
||||||
|
constructor(projectRoot: string) {
|
||||||
|
this.projectRoot = path.resolve(projectRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
if (!isGitRepository(this.projectRoot)) {
|
||||||
|
throw new Error('GitService requires a Git repository');
|
||||||
|
}
|
||||||
|
const gitAvailable = await this.verifyGitAvailability();
|
||||||
|
if (!gitAvailable) {
|
||||||
|
throw new Error('GitService requires Git to be installed');
|
||||||
|
}
|
||||||
|
this.setupHiddenGitRepository();
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyGitAvailability(): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
exec('git --version', (error) => {
|
||||||
|
if (error) {
|
||||||
|
resolve(false);
|
||||||
|
} else {
|
||||||
|
resolve(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a hidden git repository in the project root.
|
||||||
|
* The Git repository is used to support checkpointing.
|
||||||
|
*/
|
||||||
|
async setupHiddenGitRepository() {
|
||||||
|
const historyDir = path.join(this.projectRoot, historyDirName);
|
||||||
|
const repoDir = path.join(historyDir, 'repository');
|
||||||
|
|
||||||
|
await fs.mkdir(repoDir, { recursive: true });
|
||||||
|
const repoInstance: SimpleGit = simpleGit(repoDir);
|
||||||
|
const isRepoDefined = await repoInstance.checkIsRepo(
|
||||||
|
CheckRepoActions.IS_REPO_ROOT,
|
||||||
|
);
|
||||||
|
if (!isRepoDefined) {
|
||||||
|
await repoInstance.init();
|
||||||
|
try {
|
||||||
|
await repoInstance.raw([
|
||||||
|
'worktree',
|
||||||
|
'add',
|
||||||
|
this.projectRoot,
|
||||||
|
'--force',
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Failed to add worktree:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibileGitIgnorePath = path.join(this.projectRoot, '.gitignore');
|
||||||
|
const hiddenGitIgnorePath = path.join(repoDir, '.gitignore');
|
||||||
|
|
||||||
|
let visibileGitIgnoreContent = ``;
|
||||||
|
try {
|
||||||
|
visibileGitIgnoreContent = await fs.readFile(
|
||||||
|
visibileGitIgnorePath,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (isNodeError(error) && error.code !== 'ENOENT') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.writeFile(hiddenGitIgnorePath, visibileGitIgnoreContent);
|
||||||
|
|
||||||
|
if (!visibileGitIgnoreContent.includes(historyDirName)) {
|
||||||
|
const updatedContent = `${visibileGitIgnoreContent}\n# Gemini CLI history directory\n${historyDirName}\n`;
|
||||||
|
await fs.writeFile(visibileGitIgnorePath, updatedContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
const commit = await repoInstance.raw([
|
||||||
|
'rev-list',
|
||||||
|
'--all',
|
||||||
|
'--max-count=1',
|
||||||
|
]);
|
||||||
|
if (!commit) {
|
||||||
|
await repoInstance.add(hiddenGitIgnorePath);
|
||||||
|
|
||||||
|
await repoInstance.commit('Initial commit');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private get hiddenGitRepository(): SimpleGit {
|
||||||
|
const historyDir = path.join(this.projectRoot, historyDirName);
|
||||||
|
const repoDir = path.join(historyDir, 'repository');
|
||||||
|
return simpleGit(this.projectRoot).env({
|
||||||
|
GIT_DIR: path.join(repoDir, '.git'),
|
||||||
|
GIT_WORK_TREE: this.projectRoot,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCurrentCommitHash(): Promise<string> {
|
||||||
|
const hash = await this.hiddenGitRepository.raw('rev-parse', 'HEAD');
|
||||||
|
return hash.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async createFileSnapshot(message: string): Promise<string> {
|
||||||
|
const repo = this.hiddenGitRepository;
|
||||||
|
await repo.add('.');
|
||||||
|
const commitResult = await repo.commit(message);
|
||||||
|
return commitResult.commit;
|
||||||
|
}
|
||||||
|
|
||||||
|
async restoreProjectFromSnapshot(commitHash: string): Promise<void> {
|
||||||
|
const repo = this.hiddenGitRepository;
|
||||||
|
await repo.raw(['restore', '--source', commitHash, '.']);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue