From e5f7a9c4240c4655e4075863c06ae842ca81e369 Mon Sep 17 00:00:00 2001 From: Adib234 <30782825+Adib234@users.noreply.github.com> Date: Thu, 8 Jan 2026 17:21:15 -0500 Subject: [PATCH] feat: implement file system reversion utilities for rewind (#15715) --- .../cli/src/ui/utils/rewindFileOps.test.ts | 473 ++++++++++++++++++ packages/cli/src/ui/utils/rewindFileOps.ts | 250 +++++++++ packages/core/src/index.ts | 1 + packages/core/src/telemetry/types.ts | 9 +- packages/core/src/utils/fileDiffUtils.test.ts | 104 ++++ packages/core/src/utils/fileDiffUtils.ts | 50 ++ 6 files changed, 885 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/ui/utils/rewindFileOps.test.ts create mode 100644 packages/cli/src/ui/utils/rewindFileOps.ts create mode 100644 packages/core/src/utils/fileDiffUtils.test.ts create mode 100644 packages/core/src/utils/fileDiffUtils.ts diff --git a/packages/cli/src/ui/utils/rewindFileOps.test.ts b/packages/cli/src/ui/utils/rewindFileOps.test.ts new file mode 100644 index 0000000000..a70dd0337f --- /dev/null +++ b/packages/cli/src/ui/utils/rewindFileOps.test.ts @@ -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(); + 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), + ); + }); + }); +}); diff --git a/packages/cli/src/ui/utils/rewindFileOps.ts b/packages/cli/src/ui/utils/rewindFileOps.ts new file mode 100644 index 0000000000..89315c9f2d --- /dev/null +++ b/packages/cli/src/ui/utils/rewindFileOps.ts @@ -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(); + 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(); + 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 { + 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, + ); + } + } + } + } + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index be23fb2d27..a56d5ae813 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index 3ff143335f..0549ae3aa2 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -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, diff --git a/packages/core/src/utils/fileDiffUtils.test.ts b/packages/core/src/utils/fileDiffUtils.test.ts new file mode 100644 index 0000000000..3c4c4c7667 --- /dev/null +++ b/packages/core/src/utils/fileDiffUtils.test.ts @@ -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, + }); + }); + }); +}); diff --git a/packages/core/src/utils/fileDiffUtils.ts b/packages/core/src/utils/fileDiffUtils.ts new file mode 100644 index 0000000000..47916c1e8e --- /dev/null +++ b/packages/core/src/utils/fileDiffUtils.ts @@ -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, + }; +}