feat: implement file system reversion utilities for rewind (#15715)

This commit is contained in:
Adib234
2026-01-08 17:21:15 -05:00
committed by GitHub
parent 9062a943e7
commit e5f7a9c424
6 changed files with 885 additions and 2 deletions

View File

@@ -0,0 +1,473 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
calculateTurnStats,
calculateRewindImpact,
revertFileChanges,
} from './rewindFileOps.js';
import type {
ConversationRecord,
MessageRecord,
ToolCallRecord,
} from '@google/gemini-cli-core';
import { coreEvents } from '@google/gemini-cli-core';
import fs from 'node:fs/promises';
import path from 'node:path';
vi.mock('node:fs/promises');
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
coreEvents: {
emitFeedback: vi.fn(),
},
};
});
describe('rewindFileOps', () => {
const mockConversation: ConversationRecord = {
sessionId: 'test-session',
projectHash: 'hash',
startTime: 'time',
lastUpdated: 'time',
messages: [],
};
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('calculateTurnStats', () => {
it('returns null if no edits found after user message', () => {
const userMsg: MessageRecord = {
type: 'user',
content: 'hello',
id: '1',
timestamp: '1',
};
const geminiMsg: MessageRecord = {
type: 'gemini',
content: 'hi',
id: '2',
timestamp: '2',
};
mockConversation.messages = [userMsg, geminiMsg];
const stats = calculateTurnStats(mockConversation, userMsg);
expect(stats).toBeNull();
});
it('calculates stats for single turn correctly', () => {
const userMsg: MessageRecord = {
type: 'user',
content: 'hello',
id: '1',
timestamp: '1',
};
const toolMsg: MessageRecord = {
type: 'gemini',
id: '2',
timestamp: '2',
content: '',
toolCalls: [
{
name: 'replace',
id: 'tool-call-1',
status: 'success',
timestamp: '2',
args: {},
resultDisplay: {
fileName: 'file1.ts',
filePath: '/file1.ts',
originalContent: 'old',
newContent: 'new',
isNewFile: false,
diffStat: {
model_added_lines: 5,
model_removed_lines: 2,
user_added_lines: 0,
user_removed_lines: 0,
model_added_chars: 100,
model_removed_chars: 20,
user_added_chars: 0,
user_removed_chars: 0,
},
},
},
] as unknown as ToolCallRecord[],
};
const userMsg2: MessageRecord = {
type: 'user',
content: 'next',
id: '3',
timestamp: '3',
};
mockConversation.messages = [userMsg, toolMsg, userMsg2];
const stats = calculateTurnStats(mockConversation, userMsg);
expect(stats).toEqual({
addedLines: 5,
removedLines: 2,
fileCount: 1,
});
});
});
describe('calculateRewindImpact', () => {
it('calculates cumulative stats across multiple turns', () => {
const userMsg1: MessageRecord = {
type: 'user',
content: 'start',
id: '1',
timestamp: '1',
};
const toolMsg1: MessageRecord = {
type: 'gemini',
id: '2',
timestamp: '2',
content: '',
toolCalls: [
{
name: 'replace',
id: 'tool-call-1',
status: 'success',
timestamp: '2',
args: {},
resultDisplay: {
fileName: 'file1.ts',
filePath: '/file1.ts',
fileDiff: 'diff1',
originalContent: 'old',
newContent: 'new',
isNewFile: false,
diffStat: {
model_added_lines: 5,
model_removed_lines: 2,
user_added_lines: 0,
user_removed_lines: 0,
model_added_chars: 0,
model_removed_chars: 0,
user_added_chars: 0,
user_removed_chars: 0,
},
},
},
] as unknown as ToolCallRecord[],
};
const userMsg2: MessageRecord = {
type: 'user',
content: 'next',
id: '3',
timestamp: '3',
};
const toolMsg2: MessageRecord = {
type: 'gemini',
id: '4',
timestamp: '4',
content: '',
toolCalls: [
{
name: 'replace',
id: 'tool-call-2',
status: 'success',
timestamp: '4',
args: {},
resultDisplay: {
fileName: 'file2.ts',
filePath: '/file2.ts',
fileDiff: 'diff2',
originalContent: 'old',
newContent: 'new',
isNewFile: false,
diffStat: {
model_added_lines: 3,
model_removed_lines: 1,
user_added_lines: 0,
user_removed_lines: 0,
model_added_chars: 0,
model_removed_chars: 0,
user_added_chars: 0,
user_removed_chars: 0,
},
},
},
] as unknown as ToolCallRecord[],
};
mockConversation.messages = [userMsg1, toolMsg1, userMsg2, toolMsg2];
const stats = calculateRewindImpact(mockConversation, userMsg1);
expect(stats).toEqual({
addedLines: 8, // 5 + 3
removedLines: 3, // 2 + 1
fileCount: 2,
details: [
{ fileName: 'file1.ts', diff: 'diff1' },
{ fileName: 'file2.ts', diff: 'diff2' },
],
});
});
});
describe('revertFileChanges', () => {
const mockDiffStat = {
model_added_lines: 1,
model_removed_lines: 1,
user_added_lines: 0,
user_removed_lines: 0,
model_added_chars: 1,
model_removed_chars: 1,
user_added_chars: 0,
user_removed_chars: 0,
};
it('does nothing if message not found', async () => {
mockConversation.messages = [];
await revertFileChanges(mockConversation, 'missing-id');
expect(fs.writeFile).not.toHaveBeenCalled();
});
it('reverts exact match', async () => {
const userMsg: MessageRecord = {
type: 'user',
content: 'start',
id: '1',
timestamp: '1',
};
const toolMsg: MessageRecord = {
type: 'gemini',
id: '2',
timestamp: '2',
content: '',
toolCalls: [
{
name: 'replace',
id: 'tool-call-1',
status: 'success',
timestamp: '2',
args: {},
resultDisplay: {
fileName: 'file.txt',
filePath: path.resolve('/root/file.txt'),
originalContent: 'old',
newContent: 'new',
isNewFile: false,
diffStat: mockDiffStat,
},
},
] as unknown as ToolCallRecord[],
};
mockConversation.messages = [userMsg, toolMsg];
vi.mocked(fs.readFile).mockResolvedValue('new');
await revertFileChanges(mockConversation, '1');
expect(fs.writeFile).toHaveBeenCalledWith(
path.resolve('/root/file.txt'),
'old',
);
});
it('deletes new file on revert', async () => {
const userMsg: MessageRecord = {
type: 'user',
content: 'start',
id: '1',
timestamp: '1',
};
const toolMsg: MessageRecord = {
type: 'gemini',
id: '2',
timestamp: '2',
content: '',
toolCalls: [
{
name: 'write_file',
id: 'tool-call-2',
status: 'success',
timestamp: '2',
args: {},
resultDisplay: {
fileName: 'file.txt',
filePath: path.resolve('/root/file.txt'),
originalContent: null,
newContent: 'content',
isNewFile: true,
diffStat: mockDiffStat,
},
},
] as unknown as ToolCallRecord[],
};
mockConversation.messages = [userMsg, toolMsg];
vi.mocked(fs.readFile).mockResolvedValue('content');
await revertFileChanges(mockConversation, '1');
expect(fs.unlink).toHaveBeenCalledWith(path.resolve('/root/file.txt'));
});
it('handles smart revert (patching) successfully', async () => {
const original = Array.from(
{ length: 20 },
(_, i) => `line${i + 1}`,
).join('\n');
// Agent changes line 2
const agentModifiedLines = original.split('\n');
agentModifiedLines[1] = 'line2-modified';
const agentModified = agentModifiedLines.join('\n');
// User changes line 18 (far away from line 2)
const userModifiedLines = [...agentModifiedLines];
userModifiedLines[17] = 'line18-modified';
const userModified = userModifiedLines.join('\n');
const toolMsg: MessageRecord = {
type: 'gemini',
id: '2',
timestamp: '2',
content: '',
toolCalls: [
{
name: 'replace',
id: 'tool-call-1',
status: 'success',
timestamp: '2',
args: {},
resultDisplay: {
fileName: 'file.txt',
filePath: path.resolve('/root/file.txt'),
originalContent: original,
newContent: agentModified,
isNewFile: false,
diffStat: mockDiffStat,
},
},
] as unknown as ToolCallRecord[],
};
mockConversation.messages = [
{ type: 'user', content: 'start', id: '1', timestamp: '1' },
toolMsg,
];
vi.mocked(fs.readFile).mockResolvedValue(userModified);
await revertFileChanges(mockConversation, '1');
// Expect line 2 to be reverted to original, but line 18 to keep user modification
const expectedLines = original.split('\n');
expectedLines[17] = 'line18-modified';
const expectedContent = expectedLines.join('\n');
expect(fs.writeFile).toHaveBeenCalledWith(
path.resolve('/root/file.txt'),
expectedContent,
);
});
it('emits warning on smart revert failure', async () => {
const original = 'line1\nline2\nline3';
const agentModified = 'line1\nline2-modified\nline3';
// User modification conflicts with the agent's change.
const userModified = 'line1\nline2-usermodified\nline3';
const toolMsg: MessageRecord = {
type: 'gemini',
id: '2',
timestamp: '2',
content: '',
toolCalls: [
{
name: 'replace',
id: 'tool-call-1',
status: 'success',
timestamp: '2',
args: {},
resultDisplay: {
fileName: 'file.txt',
filePath: path.resolve('/root/file.txt'),
originalContent: original,
newContent: agentModified,
isNewFile: false,
diffStat: mockDiffStat,
},
},
] as unknown as ToolCallRecord[],
};
mockConversation.messages = [
{ type: 'user', content: 'start', id: '1', timestamp: '1' },
toolMsg,
];
vi.mocked(fs.readFile).mockResolvedValue(userModified);
await revertFileChanges(mockConversation, '1');
expect(fs.writeFile).not.toHaveBeenCalled();
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'warning',
expect.stringContaining('Smart revert for file.txt failed'),
);
});
it('emits error if fs.readFile fails with a generic error', async () => {
const toolMsg: MessageRecord = {
type: 'gemini',
id: '2',
timestamp: '2',
content: '',
toolCalls: [
{
name: 'replace',
id: 'tool-call-1',
status: 'success',
timestamp: '2',
args: {},
resultDisplay: {
fileName: 'file.txt',
filePath: path.resolve('/root/file.txt'),
originalContent: 'old',
newContent: 'new',
isNewFile: false,
diffStat: mockDiffStat,
},
},
] as unknown as ToolCallRecord[],
};
mockConversation.messages = [
{ type: 'user', content: 'start', id: '1', timestamp: '1' },
toolMsg,
];
// Simulate a generic file read error
vi.mocked(fs.readFile).mockRejectedValue(new Error('Permission denied'));
await revertFileChanges(mockConversation, '1');
expect(fs.writeFile).not.toHaveBeenCalled();
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'error',
'Error reading file.txt during revert: Permission denied',
expect.any(Error),
);
});
});
});

View File

@@ -0,0 +1,250 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {
ConversationRecord,
MessageRecord,
} from '@google/gemini-cli-core';
import fs from 'node:fs/promises';
import * as Diff from 'diff';
import {
coreEvents,
debugLogger,
getFileDiffFromResultDisplay,
computeAddedAndRemovedLines,
} from '@google/gemini-cli-core';
export interface FileChangeDetail {
fileName: string;
diff: string;
}
export interface FileChangeStats {
addedLines: number;
removedLines: number;
fileCount: number;
details?: FileChangeDetail[];
}
/**
* Calculates file change statistics for a single turn.
* A turn is defined as the sequence of messages starting after the given user message
* and continuing until the next user message or the end of the conversation.
*
* @param conversation The full conversation record.
* @param userMessage The starting user message for the turn.
* @returns Statistics about lines added/removed and files touched, or null if no edits occurred.
*/
export function calculateTurnStats(
conversation: ConversationRecord,
userMessage: MessageRecord,
): FileChangeStats | null {
const msgIndex = conversation.messages.indexOf(userMessage);
if (msgIndex === -1) return null;
let addedLines = 0;
let removedLines = 0;
const files = new Set<string>();
let hasEdits = false;
// Look ahead until the next user message (single turn)
for (let i = msgIndex + 1; i < conversation.messages.length; i++) {
const msg = conversation.messages[i];
if (msg.type === 'user') break; // Stop at next user message
if (msg.type === 'gemini' && msg.toolCalls) {
for (const toolCall of msg.toolCalls) {
const fileDiff = getFileDiffFromResultDisplay(toolCall.resultDisplay);
if (fileDiff) {
hasEdits = true;
const stats = fileDiff.diffStat;
const calculations = computeAddedAndRemovedLines(stats);
addedLines += calculations.addedLines;
removedLines += calculations.removedLines;
files.add(fileDiff.fileName);
}
}
}
}
if (!hasEdits) return null;
return {
addedLines,
removedLines,
fileCount: files.size,
};
}
/**
* Calculates the cumulative file change statistics from a specific message
* to the end of the conversation.
*
* @param conversation The full conversation record.
* @param userMessage The message to start calculating impact from (exclusive).
* @returns Aggregate statistics about lines added/removed and files touched, or null if no edits occurred.
*/
export function calculateRewindImpact(
conversation: ConversationRecord,
userMessage: MessageRecord,
): FileChangeStats | null {
const msgIndex = conversation.messages.indexOf(userMessage);
if (msgIndex === -1) return null;
let addedLines = 0;
let removedLines = 0;
const files = new Set<string>();
const details: FileChangeDetail[] = [];
let hasEdits = false;
// Look ahead to the end of conversation (cumulative)
for (let i = msgIndex + 1; i < conversation.messages.length; i++) {
const msg = conversation.messages[i];
// Do NOT break on user message - we want total impact
if (msg.type === 'gemini' && msg.toolCalls) {
for (const toolCall of msg.toolCalls) {
const fileDiff = getFileDiffFromResultDisplay(toolCall.resultDisplay);
if (fileDiff) {
hasEdits = true;
const stats = fileDiff.diffStat;
const calculations = computeAddedAndRemovedLines(stats);
addedLines += calculations.addedLines;
removedLines += calculations.removedLines;
files.add(fileDiff.fileName);
details.push({
fileName: fileDiff.fileName,
diff: fileDiff.fileDiff,
});
}
}
}
}
if (!hasEdits) return null;
return {
addedLines,
removedLines,
fileCount: files.size,
details,
};
}
/**
* Reverts file changes made by the model from the end of the conversation
* back to a specific target message.
*
* It iterates backwards through the conversation history and attempts to undo
* any file modifications. It handles cases where the user might have subsequently
* modified the file by attempting a smart patch (using the `diff` library).
*
* @param conversation The full conversation record.
* @param targetMessageId The ID of the message to revert back to. Changes *after* this message will be undone.
*/
export async function revertFileChanges(
conversation: ConversationRecord,
targetMessageId: string,
): Promise<void> {
const messageIndex = conversation.messages.findIndex(
(m) => m.id === targetMessageId,
);
if (messageIndex === -1) {
debugLogger.error('Requested message to rewind to was not found ');
return;
}
// Iterate backwards from the end to the message being rewound (exclusive of the messageId itself)
for (let i = conversation.messages.length - 1; i > messageIndex; i--) {
const msg = conversation.messages[i];
if (msg.type === 'gemini' && msg.toolCalls) {
for (let j = msg.toolCalls.length - 1; j >= 0; j--) {
const toolCall = msg.toolCalls[j];
const fileDiff = getFileDiffFromResultDisplay(toolCall.resultDisplay);
if (fileDiff) {
const { filePath, fileName, newContent, originalContent, isNewFile } =
fileDiff;
try {
let currentContent: string | null = null;
try {
currentContent = await fs.readFile(filePath, 'utf8');
} catch (e) {
const error = e as Error;
if ('code' in error && error.code === 'ENOENT') {
// File does not exist, which is fine in some revert scenarios.
debugLogger.debug(
`File ${fileName} not found during revert, proceeding as it may be a new file deletion.`,
);
} else {
// Other read errors are unexpected.
coreEvents.emitFeedback(
'error',
`Error reading ${fileName} during revert: ${error.message}`,
e,
);
// Continue to next tool call
return;
}
}
// 1. Exact Match: Safe to revert directly
if (currentContent === newContent) {
if (!isNewFile) {
await fs.writeFile(filePath, originalContent ?? '');
} else {
// Original content was null (new file), so we delete the file
await fs.unlink(filePath);
}
}
// 2. Mismatch: Attempt Smart Revert (Patch)
else if (currentContent !== null) {
const originalText = originalContent ?? '';
// Create a patch that transforms Agent -> Original
const undoPatch = Diff.createPatch(
fileName,
newContent,
originalText,
);
// Apply that patch to the Current content
const patchedContent = Diff.applyPatch(currentContent, undoPatch);
if (typeof patchedContent === 'string') {
if (patchedContent === '' && isNewFile) {
// If the result is empty and the file didn't exist originally, delete it
await fs.unlink(filePath);
} else {
await fs.writeFile(filePath, patchedContent);
}
} else {
// Patch failed
coreEvents.emitFeedback(
'warning',
`Smart revert for ${fileName} failed. The file may have been modified in a way that conflicts with the undo operation.`,
);
}
} else {
// File was deleted by the user, but we expected content.
// This can happen if a file created by the agent is deleted before rewind.
coreEvents.emitFeedback(
'warning',
`Cannot revert changes for ${fileName} because it was not found on disk. This is expected if a file created by the agent was deleted before rewind`,
);
}
} catch (e) {
coreEvents.emitFeedback(
'error',
`An unexpected error occurred while reverting ${fileName}.`,
e,
);
}
}
}
}
}
}

View File

@@ -65,6 +65,7 @@ export * from './utils/quotaErrorDetection.js';
export * from './utils/userAccountManager.js';
export * from './utils/googleQuotaErrors.js';
export * from './utils/fileUtils.js';
export * from './utils/fileDiffUtils.js';
export * from './utils/retry.js';
export * from './utils/shell-utils.js';
export { PolicyDecision, ApprovalMode } from './policy/types.js';

View File

@@ -39,6 +39,7 @@ import {
toSystemInstruction,
} from './semantic.js';
import { sanitizeHookName } from './sanitize.js';
import { getFileDiffFromResultDisplay } from '../utils/fileDiffUtils.js';
export interface BaseTelemetryEvent {
'event.name': string;
@@ -290,13 +291,17 @@ export class ToolCallEvent implements BaseTelemetryEvent {
this.tool_type = 'native';
}
const fileDiff = getFileDiffFromResultDisplay(
call.response.resultDisplay,
);
if (
call.status === 'success' &&
typeof call.response.resultDisplay === 'object' &&
call.response.resultDisplay !== null &&
'diffStat' in call.response.resultDisplay
fileDiff
) {
const diffStat = call.response.resultDisplay.diffStat;
const diffStat = fileDiff.diffStat;
if (diffStat) {
this.metadata = {
model_added_lines: diffStat.model_added_lines,

View File

@@ -0,0 +1,104 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
getFileDiffFromResultDisplay,
computeAddedAndRemovedLines,
} from './fileDiffUtils.js';
import type { FileDiff, ToolResultDisplay } from '../tools/tools.js';
describe('fileDiffUtils', () => {
describe('getFileDiffFromResultDisplay', () => {
it('returns undefined if resultDisplay is undefined', () => {
expect(getFileDiffFromResultDisplay(undefined)).toBeUndefined();
});
it('returns undefined if resultDisplay is not an object', () => {
expect(
getFileDiffFromResultDisplay('string' as ToolResultDisplay),
).toBeUndefined();
});
it('returns undefined if resultDisplay missing diffStat', () => {
const resultDisplay = {
fileName: 'file.txt',
};
expect(
getFileDiffFromResultDisplay(resultDisplay as ToolResultDisplay),
).toBeUndefined();
});
it('returns the FileDiff object if structure is valid', () => {
const validDiffStat = {
model_added_lines: 1,
model_removed_lines: 2,
user_added_lines: 3,
user_removed_lines: 4,
model_added_chars: 10,
model_removed_chars: 20,
user_added_chars: 30,
user_removed_chars: 40,
};
const resultDisplay = {
fileName: 'file.txt',
diffStat: validDiffStat,
};
const result = getFileDiffFromResultDisplay(
resultDisplay as ToolResultDisplay,
);
expect(result).toBe(resultDisplay);
});
});
describe('computeAddedAndRemovedLines', () => {
it('returns 0 added and 0 removed if stats is undefined', () => {
expect(computeAddedAndRemovedLines(undefined)).toEqual({
addedLines: 0,
removedLines: 0,
});
});
it('correctly sums added and removed lines from stats', () => {
const stats: FileDiff['diffStat'] = {
model_added_lines: 10,
model_removed_lines: 5,
user_added_lines: 2,
user_removed_lines: 1,
model_added_chars: 100,
model_removed_chars: 50,
user_added_chars: 20,
user_removed_chars: 10,
};
const result = computeAddedAndRemovedLines(stats);
expect(result).toEqual({
addedLines: 12, // 10 + 2
removedLines: 6, // 5 + 1
});
});
it('handles zero values correctly', () => {
const stats: FileDiff['diffStat'] = {
model_added_lines: 0,
model_removed_lines: 0,
user_added_lines: 0,
user_removed_lines: 0,
model_added_chars: 0,
model_removed_chars: 0,
user_added_chars: 0,
user_removed_chars: 0,
};
const result = computeAddedAndRemovedLines(stats);
expect(result).toEqual({
addedLines: 0,
removedLines: 0,
});
});
});
});

View File

@@ -0,0 +1,50 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { FileDiff } from '../tools/tools.js';
import type { ToolCallRecord } from '../services/chatRecordingService.js';
/**
* Safely extracts the FileDiff object from a tool call's resultDisplay.
* This helper performs runtime checks to ensure the object conforms to the FileDiff structure.
* @param resultDisplay The resultDisplay property of a ToolCallRecord.
* @returns The FileDiff object if found and valid, otherwise undefined.
*/
export function getFileDiffFromResultDisplay(
resultDisplay: ToolCallRecord['resultDisplay'],
): FileDiff | undefined {
if (
resultDisplay &&
typeof resultDisplay === 'object' &&
'diffStat' in resultDisplay &&
typeof resultDisplay.diffStat === 'object' &&
resultDisplay.diffStat !== null
) {
const diffStat = resultDisplay.diffStat as FileDiff['diffStat'];
if (diffStat) {
return resultDisplay;
}
}
return undefined;
}
export function computeAddedAndRemovedLines(
stats: FileDiff['diffStat'] | undefined,
): {
addedLines: number;
removedLines: number;
} {
if (!stats) {
return {
addedLines: 0,
removedLines: 0,
};
}
return {
addedLines: stats.model_added_lines + stats.user_added_lines,
removedLines: stats.model_removed_lines + stats.user_removed_lines,
};
}