mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-13 15:40:57 -07:00
477 lines
13 KiB
TypeScript
477 lines
13 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import fs from 'node:fs/promises';
|
|
import {
|
|
calculateTurnStats,
|
|
calculateRewindImpact,
|
|
revertFileChanges,
|
|
} from './rewindFileOps.js';
|
|
import {
|
|
coreEvents,
|
|
type ConversationRecord,
|
|
type MessageRecord,
|
|
type ToolCallRecord,
|
|
} from '@google/gemini-cli-core';
|
|
|
|
// Mock fs/promises
|
|
vi.mock('node:fs/promises', () => ({
|
|
default: {
|
|
readFile: vi.fn(),
|
|
writeFile: vi.fn(),
|
|
rm: vi.fn(),
|
|
unlink: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
// Mock @google/gemini-cli-core
|
|
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
|
const actual =
|
|
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
|
return {
|
|
...actual,
|
|
debugLogger: {
|
|
log: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
debug: vi.fn(),
|
|
},
|
|
getFileDiffFromResultDisplay: vi.fn(),
|
|
computeAddedAndRemovedLines: vi.fn(),
|
|
};
|
|
});
|
|
|
|
describe('rewindFileOps', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.spyOn(coreEvents, 'emitFeedback');
|
|
});
|
|
|
|
describe('calculateTurnStats', () => {
|
|
it('returns null if no edits found after user message', () => {
|
|
const userMsg = { type: 'user' } as unknown as MessageRecord;
|
|
const conversation = {
|
|
messages: [
|
|
userMsg,
|
|
{ type: 'gemini', text: 'Hello' } as unknown as MessageRecord,
|
|
],
|
|
};
|
|
const result = calculateTurnStats(
|
|
conversation as unknown as ConversationRecord,
|
|
userMsg,
|
|
);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('calculates stats for single turn correctly', async () => {
|
|
const { getFileDiffFromResultDisplay, computeAddedAndRemovedLines } =
|
|
await import('@google/gemini-cli-core');
|
|
vi.mocked(getFileDiffFromResultDisplay).mockReturnValue({
|
|
filePath: 'test.ts',
|
|
fileName: 'test.ts',
|
|
originalContent: 'old',
|
|
newContent: 'new',
|
|
isNewFile: false,
|
|
diffStat: {
|
|
model_added_lines: 0,
|
|
model_removed_lines: 0,
|
|
model_added_chars: 0,
|
|
model_removed_chars: 0,
|
|
user_added_lines: 0,
|
|
user_removed_lines: 0,
|
|
user_added_chars: 0,
|
|
user_removed_chars: 0,
|
|
},
|
|
fileDiff: 'diff',
|
|
});
|
|
vi.mocked(computeAddedAndRemovedLines).mockReturnValue({
|
|
addedLines: 3,
|
|
removedLines: 3,
|
|
});
|
|
|
|
const userMsg = { type: 'user' } as unknown as MessageRecord;
|
|
const conversation = {
|
|
messages: [
|
|
userMsg,
|
|
{
|
|
type: 'gemini',
|
|
toolCalls: [
|
|
{
|
|
name: 'replace',
|
|
args: {},
|
|
resultDisplay: 'diff',
|
|
},
|
|
],
|
|
} as unknown as MessageRecord,
|
|
],
|
|
};
|
|
|
|
const result = calculateTurnStats(
|
|
conversation as unknown as ConversationRecord,
|
|
userMsg,
|
|
);
|
|
expect(result).toEqual({
|
|
fileCount: 1,
|
|
addedLines: 3,
|
|
removedLines: 3,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('calculateRewindImpact', () => {
|
|
it('calculates cumulative stats across multiple turns', async () => {
|
|
const { getFileDiffFromResultDisplay, computeAddedAndRemovedLines } =
|
|
await import('@google/gemini-cli-core');
|
|
vi.mocked(getFileDiffFromResultDisplay)
|
|
.mockReturnValueOnce({
|
|
filePath: 'file1.ts',
|
|
fileName: 'file1.ts',
|
|
originalContent: '123',
|
|
newContent: '12345',
|
|
isNewFile: false,
|
|
diffStat: {
|
|
model_added_lines: 0,
|
|
model_removed_lines: 0,
|
|
model_added_chars: 0,
|
|
model_removed_chars: 0,
|
|
user_added_lines: 0,
|
|
user_removed_lines: 0,
|
|
user_added_chars: 0,
|
|
user_removed_chars: 0,
|
|
},
|
|
fileDiff: 'diff1',
|
|
})
|
|
.mockReturnValueOnce({
|
|
filePath: 'file2.ts',
|
|
fileName: 'file2.ts',
|
|
originalContent: 'abc',
|
|
newContent: 'abcd',
|
|
isNewFile: true,
|
|
diffStat: {
|
|
model_added_lines: 0,
|
|
model_removed_lines: 0,
|
|
model_added_chars: 0,
|
|
model_removed_chars: 0,
|
|
user_added_lines: 0,
|
|
user_removed_lines: 0,
|
|
user_added_chars: 0,
|
|
user_removed_chars: 0,
|
|
},
|
|
fileDiff: 'diff2',
|
|
});
|
|
|
|
vi.mocked(computeAddedAndRemovedLines)
|
|
.mockReturnValueOnce({ addedLines: 5, removedLines: 3 })
|
|
.mockReturnValueOnce({ addedLines: 4, removedLines: 0 });
|
|
|
|
const userMsg = { type: 'user' } as unknown as MessageRecord;
|
|
const conversation = {
|
|
messages: [
|
|
userMsg,
|
|
{
|
|
type: 'gemini',
|
|
toolCalls: [
|
|
{
|
|
resultDisplay: 'd1',
|
|
} as unknown as ToolCallRecord,
|
|
],
|
|
} as unknown as MessageRecord,
|
|
{
|
|
type: 'user',
|
|
} as unknown as MessageRecord,
|
|
{
|
|
type: 'gemini',
|
|
toolCalls: [
|
|
{
|
|
resultDisplay: 'd2',
|
|
} as unknown as ToolCallRecord,
|
|
],
|
|
} as unknown as MessageRecord,
|
|
],
|
|
};
|
|
|
|
const result = calculateRewindImpact(
|
|
conversation as unknown as ConversationRecord,
|
|
userMsg,
|
|
);
|
|
expect(result).toEqual({
|
|
fileCount: 2,
|
|
addedLines: 9, // 5 + 4
|
|
removedLines: 3, // 3 + 0
|
|
details: [
|
|
{ fileName: 'file1.ts', diff: 'diff1' },
|
|
{ fileName: 'file2.ts', diff: 'diff2' },
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('revertFileChanges', () => {
|
|
it('does nothing if message not found', async () => {
|
|
await revertFileChanges(
|
|
{ messages: [] } as unknown as ConversationRecord,
|
|
'missing',
|
|
);
|
|
expect(fs.writeFile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('reverts exact match', async () => {
|
|
const { getFileDiffFromResultDisplay } = await import(
|
|
'@google/gemini-cli-core'
|
|
);
|
|
vi.mocked(getFileDiffFromResultDisplay).mockReturnValue({
|
|
filePath: '/abs/path/test.ts',
|
|
fileName: 'test.ts',
|
|
originalContent: 'ORIGINAL_CONTENT',
|
|
newContent: 'NEW_CONTENT',
|
|
isNewFile: false,
|
|
diffStat: {
|
|
model_added_lines: 0,
|
|
model_removed_lines: 0,
|
|
model_added_chars: 0,
|
|
model_removed_chars: 0,
|
|
user_added_lines: 0,
|
|
user_removed_lines: 0,
|
|
user_added_chars: 0,
|
|
user_removed_chars: 0,
|
|
},
|
|
fileDiff: 'diff',
|
|
});
|
|
|
|
const userMsg = {
|
|
type: 'user',
|
|
id: 'target',
|
|
} as unknown as MessageRecord;
|
|
const conversation = {
|
|
messages: [
|
|
userMsg,
|
|
{
|
|
type: 'gemini',
|
|
toolCalls: [{ resultDisplay: 'diff' } as unknown as ToolCallRecord],
|
|
} as unknown as MessageRecord,
|
|
],
|
|
};
|
|
|
|
vi.mocked(fs.readFile).mockResolvedValue('NEW_CONTENT');
|
|
|
|
await revertFileChanges(
|
|
conversation as unknown as ConversationRecord,
|
|
'target',
|
|
);
|
|
|
|
expect(fs.writeFile).toHaveBeenCalledWith(
|
|
'/abs/path/test.ts',
|
|
'ORIGINAL_CONTENT',
|
|
);
|
|
});
|
|
|
|
it('deletes new file on revert', async () => {
|
|
const { getFileDiffFromResultDisplay } = await import(
|
|
'@google/gemini-cli-core'
|
|
);
|
|
vi.mocked(getFileDiffFromResultDisplay).mockReturnValue({
|
|
filePath: '/abs/path/new.ts',
|
|
fileName: 'new.ts',
|
|
originalContent: '',
|
|
newContent: 'SOME_CONTENT',
|
|
isNewFile: true,
|
|
diffStat: {
|
|
model_added_lines: 0,
|
|
model_removed_lines: 0,
|
|
model_added_chars: 0,
|
|
model_removed_chars: 0,
|
|
user_added_lines: 0,
|
|
user_removed_lines: 0,
|
|
user_added_chars: 0,
|
|
user_removed_chars: 0,
|
|
},
|
|
fileDiff: 'diff',
|
|
});
|
|
|
|
const userMsg = {
|
|
type: 'user',
|
|
id: 'target',
|
|
} as unknown as MessageRecord;
|
|
const conversation = {
|
|
messages: [
|
|
userMsg,
|
|
{
|
|
type: 'gemini',
|
|
toolCalls: [{ resultDisplay: 'diff' } as unknown as ToolCallRecord],
|
|
} as unknown as MessageRecord,
|
|
],
|
|
};
|
|
|
|
vi.mocked(fs.readFile).mockResolvedValue('SOME_CONTENT');
|
|
|
|
await revertFileChanges(
|
|
conversation as unknown as ConversationRecord,
|
|
'target',
|
|
);
|
|
|
|
expect(fs.unlink).toHaveBeenCalledWith('/abs/path/new.ts');
|
|
});
|
|
|
|
it('handles smart revert (patching) successfully', async () => {
|
|
const { getFileDiffFromResultDisplay } = await import(
|
|
'@google/gemini-cli-core'
|
|
);
|
|
vi.mocked(getFileDiffFromResultDisplay).mockReturnValue({
|
|
filePath: '/abs/path/test.ts',
|
|
fileName: 'test.ts',
|
|
originalContent: 'LINE1\nLINE2\nLINE3',
|
|
newContent: 'LINE1\nEDITED\nLINE3',
|
|
isNewFile: false,
|
|
diffStat: {
|
|
model_added_lines: 0,
|
|
model_removed_lines: 0,
|
|
model_added_chars: 0,
|
|
model_removed_chars: 0,
|
|
user_added_lines: 0,
|
|
user_removed_lines: 0,
|
|
user_added_chars: 0,
|
|
user_removed_chars: 0,
|
|
},
|
|
fileDiff: 'diff',
|
|
});
|
|
|
|
const userMsg = {
|
|
type: 'user',
|
|
id: 'target',
|
|
} as unknown as MessageRecord;
|
|
const conversation = {
|
|
messages: [
|
|
userMsg,
|
|
{
|
|
type: 'gemini',
|
|
toolCalls: [{ resultDisplay: 'diff' } as unknown as ToolCallRecord],
|
|
} as unknown as MessageRecord,
|
|
],
|
|
};
|
|
|
|
// Current content has FURTHER changes
|
|
vi.mocked(fs.readFile).mockResolvedValue('LINE1\nEDITED\nLINE3\nNEWLINE');
|
|
|
|
await revertFileChanges(
|
|
conversation as unknown as ConversationRecord,
|
|
'target',
|
|
);
|
|
|
|
// Should have successfully patched it back to ORIGINAL state but kept the NEWLINE
|
|
expect(fs.writeFile).toHaveBeenCalledWith(
|
|
'/abs/path/test.ts',
|
|
'LINE1\nLINE2\nLINE3\nNEWLINE',
|
|
);
|
|
});
|
|
|
|
it('emits warning on smart revert failure', async () => {
|
|
const { getFileDiffFromResultDisplay } = await import(
|
|
'@google/gemini-cli-core'
|
|
);
|
|
vi.mocked(getFileDiffFromResultDisplay).mockReturnValue({
|
|
filePath: '/abs/path/test.ts',
|
|
fileName: 'test.ts',
|
|
originalContent: 'OLD',
|
|
newContent: 'NEW',
|
|
isNewFile: false,
|
|
diffStat: {
|
|
model_added_lines: 0,
|
|
model_removed_lines: 0,
|
|
model_added_chars: 0,
|
|
model_removed_chars: 0,
|
|
user_added_lines: 0,
|
|
user_removed_lines: 0,
|
|
user_added_chars: 0,
|
|
user_removed_chars: 0,
|
|
},
|
|
fileDiff: 'diff',
|
|
});
|
|
|
|
const userMsg = {
|
|
type: 'user',
|
|
id: 'target',
|
|
} as unknown as MessageRecord;
|
|
const conversation = {
|
|
messages: [
|
|
userMsg,
|
|
{
|
|
type: 'gemini',
|
|
toolCalls: [{ resultDisplay: 'diff' } as unknown as ToolCallRecord],
|
|
} as unknown as MessageRecord,
|
|
],
|
|
};
|
|
|
|
// Current content is completely unrelated - diff won't apply
|
|
vi.mocked(fs.readFile).mockResolvedValue('UNRELATED');
|
|
|
|
await revertFileChanges(
|
|
conversation as unknown as ConversationRecord,
|
|
'target',
|
|
);
|
|
|
|
expect(fs.writeFile).not.toHaveBeenCalled();
|
|
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
|
'warning',
|
|
expect.stringContaining('Smart revert for test.ts failed'),
|
|
);
|
|
});
|
|
|
|
it('emits error if fs.readFile fails with a generic error', async () => {
|
|
const { getFileDiffFromResultDisplay } = await import(
|
|
'@google/gemini-cli-core'
|
|
);
|
|
vi.mocked(getFileDiffFromResultDisplay).mockReturnValue({
|
|
filePath: '/abs/path/test.ts',
|
|
fileName: 'test.ts',
|
|
originalContent: 'OLD',
|
|
newContent: 'NEW',
|
|
isNewFile: false,
|
|
diffStat: {
|
|
model_added_lines: 0,
|
|
model_removed_lines: 0,
|
|
model_added_chars: 0,
|
|
model_removed_chars: 0,
|
|
user_added_lines: 0,
|
|
user_removed_lines: 0,
|
|
user_added_chars: 0,
|
|
user_removed_chars: 0,
|
|
},
|
|
fileDiff: 'diff',
|
|
});
|
|
|
|
const userMsg = {
|
|
type: 'user',
|
|
id: 'target',
|
|
} as unknown as MessageRecord;
|
|
const conversation = {
|
|
messages: [
|
|
userMsg,
|
|
{
|
|
type: 'gemini',
|
|
toolCalls: [{ resultDisplay: 'diff' } as unknown as ToolCallRecord],
|
|
} as unknown as MessageRecord,
|
|
],
|
|
};
|
|
|
|
vi.mocked(fs.readFile).mockRejectedValue(new Error('disk failure'));
|
|
|
|
await revertFileChanges(
|
|
conversation as unknown as ConversationRecord,
|
|
'target',
|
|
);
|
|
|
|
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
|
'error',
|
|
expect.stringContaining(
|
|
'Error reading test.ts during revert: disk failure',
|
|
),
|
|
expect.any(Error),
|
|
);
|
|
});
|
|
});
|
|
});
|