Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ff93cfe
Preserve EOL in existing files
Thomas-Shephard Jan 7, 2026
f50e7cf
Use OS EOL in new files
Thomas-Shephard Jan 7, 2026
3414bb7
Refactor CRLF detection code
Thomas-Shephard Jan 7, 2026
d7e47f2
Merge branch 'main' into feature/eol-preservation
Thomas-Shephard Jan 7, 2026
0a163ea
Update packages/core/src/utils/editCorrector.ts
Thomas-Shephard Jan 7, 2026
80815fc
Merge branch 'main' into feature/eol-preservation
Thomas-Shephard Jan 8, 2026
83e7f88
Merge branch 'main' into feature/eol-preservation
Thomas-Shephard Jan 13, 2026
79a59a6
Merge branch 'main' into feature/eol-preservation
Thomas-Shephard Jan 14, 2026
e5910c4
Update based on suggestions
Thomas-Shephard Jan 14, 2026
2a212af
Merge branch 'main' into feature/eol-preservation
Thomas-Shephard Jan 19, 2026
6a162ee
Merge branch 'main' into feature/eol-preservation
Thomas-Shephard Jan 21, 2026
a63c258
Merge branch 'main' into feature/eol-preservation
Thomas-Shephard Jan 23, 2026
399a11e
Merge branch 'main' into feature/eol-preservation
Thomas-Shephard Jan 23, 2026
7ac4b79
Merge branch 'main' into feature/eol-preservation
Thomas-Shephard Jan 26, 2026
769ea88
Merge branch 'main' into feature/eol-preservation
Adib234 Jan 28, 2026
409b11b
Merge branch 'main' into feature/eol-preservation
Thomas-Shephard Jan 29, 2026
3545e90
fix eol tests
Thomas-Shephard Jan 29, 2026
5126e7a
Merge branch 'main' into feature/eol-preservation
jackwotherspoon Jan 29, 2026
2a6eb2c
fix(core): refactor trimPreservingTrailingNewline to avoid ReDoS
Thomas-Shephard Jan 29, 2026
c03a809
Merge branch 'main' into feature/eol-preservation
Thomas-Shephard Jan 29, 2026
dc6c5d3
Merge branch 'main' into feature/eol-preservation
jackwotherspoon Jan 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 9 additions & 15 deletions packages/core/src/tools/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import * as crypto from 'node:crypto';
import * as Diff from 'diff';
import {
Expand Down Expand Up @@ -34,7 +35,7 @@ import {
} from './modifiable-tool.js';
import { IdeClient } from '../ide/ide-client.js';
import { FixLLMEditWithInstruction } from '../utils/llm-edit-fixer.js';
import { safeLiteralReplace } from '../utils/textUtils.js';
import { safeLiteralReplace, detectLineEnding } from '../utils/textUtils.js';
import { EditStrategyEvent } from '../telemetry/types.js';
import { logEditStrategy } from '../telemetry/loggers.js';
import { EditCorrectionEvent } from '../telemetry/types.js';
Expand Down Expand Up @@ -258,17 +259,6 @@ async function calculateRegexReplacement(
};
}

/**
* Detects the line ending style of a string.
* @param content The string content to analyze.
* @returns '\r\n' for Windows-style, '\n' for Unix-style.
*/
function detectLineEnding(content: string): '\r\n' | '\n' {
// If a Carriage Return is found, assume Windows-style endings.
// This is a simple but effective heuristic.
return content.includes('\r\n') ? '\r\n' : '\n';
}

export async function calculateReplacement(
config: Config,
context: ReplacementContext,
Expand Down Expand Up @@ -785,9 +775,13 @@ class EditToolInvocation
this.ensureParentDirectoriesExist(this.params.file_path);
let finalContent = editData.newContent;

// Restore original line endings if they were CRLF
if (!editData.isNewFile && editData.originalLineEnding === '\r\n') {
finalContent = finalContent.replace(/\n/g, '\r\n');
// Restore original line endings if they were CRLF, or use OS default for new files
const useCRLF =
(!editData.isNewFile && editData.originalLineEnding === '\r\n') ||
(editData.isNewFile && os.EOL === '\r\n');

if (useCRLF) {
finalContent = finalContent.replace(/\r?\n/g, '\r\n');
}
await this.config
.getFileSystemService()
Expand Down
287 changes: 287 additions & 0 deletions packages/core/src/tools/line-endings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {
describe,
it,
expect,
beforeEach,
afterEach,
vi,
type Mocked,
} from 'vitest';
import { detectLineEnding } from '../utils/textUtils.js';
import { WriteFileTool } from './write-file.js';
import { EditTool } from './edit.js';
import type { Config } from '../config/config.js';
import { ApprovalMode } from '../policy/types.js';
import { ToolConfirmationOutcome } from './tools.js';
import type { ToolRegistry } from './tool-registry.js';
import path from 'node:path';
import fs from 'node:fs';
import os from 'node:os';
import { GeminiClient } from '../core/client.js';
import type { BaseLlmClient } from '../core/baseLlmClient.js';
import {
ensureCorrectEdit,
ensureCorrectFileContent,
} from '../utils/editCorrector.js';
import { StandardFileSystemService } from '../services/fileSystemService.js';
import { WorkspaceContext } from '../utils/workspaceContext.js';
import {
createMockMessageBus,
getMockMessageBusInstance,
} from '../test-utils/mock-message-bus.js';

const rootDir = path.resolve(os.tmpdir(), 'gemini-cli-line-ending-test-root');

// --- MOCKS ---
vi.mock('../core/client.js');
vi.mock('../utils/editCorrector.js');
vi.mock('../ide/ide-client.js', () => ({
IdeClient: {
getInstance: vi.fn().mockResolvedValue({
openDiff: vi.fn(),
isDiffingEnabled: vi.fn().mockReturnValue(false),
}),
},
}));

let mockGeminiClientInstance: Mocked<GeminiClient>;
let mockBaseLlmClientInstance: Mocked<BaseLlmClient>;
const mockEnsureCorrectEdit = vi.fn<typeof ensureCorrectEdit>();
const mockEnsureCorrectFileContent = vi.fn<typeof ensureCorrectFileContent>();

// Mock Config
const fsService = new StandardFileSystemService();
const mockConfigInternal = {
getTargetDir: () => rootDir,
getApprovalMode: vi.fn(() => ApprovalMode.DEFAULT),
setApprovalMode: vi.fn(),
getGeminiClient: vi.fn(),
getBaseLlmClient: vi.fn(),
getFileSystemService: () => fsService,
getIdeMode: vi.fn(() => false),
getWorkspaceContext: () => new WorkspaceContext(rootDir),
getApiKey: () => 'test-key',
getModel: () => 'test-model',
getSandbox: () => false,
getDebugMode: () => false,
getQuestion: () => undefined,
getToolDiscoveryCommand: () => undefined,
getToolCallCommand: () => undefined,
getMcpServerCommand: () => undefined,
getMcpServers: () => undefined,
getUserAgent: () => 'test-agent',
getUserMemory: () => '',
setUserMemory: vi.fn(),
getGeminiMdFileCount: () => 0,
setGeminiMdFileCount: vi.fn(),
getToolRegistry: () =>
({
registerTool: vi.fn(),
discoverTools: vi.fn(),
}) as unknown as ToolRegistry,
isInteractive: () => false,
};
const mockConfig = mockConfigInternal as unknown as Config;

vi.mock('../telemetry/loggers.js', () => ({
logFileOperation: vi.fn(),
logEditStrategy: vi.fn(),
logEditCorrectionEvent: vi.fn(),
}));

// --- END MOCKS ---

describe('Line Ending Preservation', () => {
let tempDir: string;

beforeEach(() => {
vi.clearAllMocks();
tempDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'line-ending-test-external-'),
);
if (!fs.existsSync(rootDir)) {
fs.mkdirSync(rootDir, { recursive: true });
}

mockGeminiClientInstance = new (vi.mocked(GeminiClient))(
mockConfig,
) as Mocked<GeminiClient>;
vi.mocked(GeminiClient).mockImplementation(() => mockGeminiClientInstance);

mockBaseLlmClientInstance = {
generateJson: vi.fn(),
} as unknown as Mocked<BaseLlmClient>;

vi.mocked(ensureCorrectEdit).mockImplementation(mockEnsureCorrectEdit);
vi.mocked(ensureCorrectFileContent).mockImplementation(
mockEnsureCorrectFileContent,
);

mockConfigInternal.getGeminiClient.mockReturnValue(
mockGeminiClientInstance,
);
mockConfigInternal.getBaseLlmClient.mockReturnValue(
mockBaseLlmClientInstance,
);
});

afterEach(() => {
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
if (fs.existsSync(rootDir)) {
fs.rmSync(rootDir, { recursive: true, force: true });
}
vi.clearAllMocks();
});

describe('detectLineEnding', () => {
it('should detect CRLF', () => {
expect(detectLineEnding('line1\r\nline2')).toBe('\r\n');
expect(detectLineEnding('line1\r\n')).toBe('\r\n');
});

it('should detect LF', () => {
expect(detectLineEnding('line1\nline2')).toBe('\n');
expect(detectLineEnding('line1\n')).toBe('\n');
expect(detectLineEnding('line1')).toBe('\n'); // Default to LF if no newline
});
});

describe('WriteFileTool', () => {
let tool: WriteFileTool;
const abortSignal = new AbortController().signal;

beforeEach(() => {
const bus = createMockMessageBus();
getMockMessageBusInstance(bus).defaultToolDecision = 'ask_user';
tool = new WriteFileTool(mockConfig, bus);
});

it('should preserve CRLF when overwriting an existing file', async () => {
const filePath = path.join(rootDir, 'crlf_file.txt');
const originalContent = 'line1\r\nline2\r\n';
fs.writeFileSync(filePath, originalContent); // Write with CRLF (or however Node writes binary buffer)
// Ensure strictly CRLF
fs.writeFileSync(filePath, Buffer.from('line1\r\nline2\r\n'));

// Proposed content from LLM (usually LF)
const proposedContent = 'line1\nline2\nline3\n';

// Mock corrections to return proposed content as-is (but usually normalized)
mockEnsureCorrectEdit.mockResolvedValue({
params: {
file_path: filePath,
old_string: originalContent,
new_string: proposedContent,
},
occurrences: 1,
});

const params = { file_path: filePath, content: proposedContent };
const invocation = tool.build(params);

// Force approval
const confirmDetails = await invocation.shouldConfirmExecute(abortSignal);
if (
confirmDetails &&
typeof confirmDetails === 'object' &&
'onConfirm' in confirmDetails
) {
await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce);
}

await invocation.execute(abortSignal);

const writtenContent = fs.readFileSync(filePath, 'utf8');
// Expect all newlines to be CRLF
expect(writtenContent).toBe('line1\r\nline2\r\nline3\r\n');
});

it('should use OS EOL for new files', async () => {
const filePath = path.join(rootDir, 'new_os_eol_file.txt');
const proposedContent = 'line1\nline2\n';

mockEnsureCorrectFileContent.mockResolvedValue(proposedContent);

const params = { file_path: filePath, content: proposedContent };
const invocation = tool.build(params);

const confirmDetails = await invocation.shouldConfirmExecute(abortSignal);
if (
confirmDetails &&
typeof confirmDetails === 'object' &&
'onConfirm' in confirmDetails
) {
await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce);
}

await invocation.execute(abortSignal);

const writtenContent = fs.readFileSync(filePath, 'utf8');

if (os.EOL === '\r\n') {
expect(writtenContent).toBe('line1\r\nline2\r\n');
} else {
expect(writtenContent).toBe('line1\nline2\n');
}
});
});

describe('EditTool', () => {
let tool: EditTool;
const abortSignal = new AbortController().signal;

beforeEach(() => {
const bus = createMockMessageBus();
getMockMessageBusInstance(bus).defaultToolDecision = 'ask_user';
tool = new EditTool(mockConfig, bus);
});

it('should preserve CRLF when editing a file', async () => {
const filePath = path.join(rootDir, 'edit_crlf.txt');
const originalContent = 'line1\r\nline2\r\nline3\r\n';
fs.writeFileSync(filePath, Buffer.from(originalContent));

const oldString = 'line2';
const newString = 'modified';

const params = {
file_path: filePath,
old_string: oldString,
new_string: newString,
instruction: 'Change line2 to modified',
};
const invocation = tool.build(params);

// Force approval
const confirmDetails = await invocation.shouldConfirmExecute(abortSignal);
if (
confirmDetails &&
typeof confirmDetails === 'object' &&
'onConfirm' in confirmDetails
) {
await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce);
}

await invocation.execute(abortSignal);

const writtenContent = fs.readFileSync(filePath, 'utf8');
// Expect 'line2' to be replaced by 'modified', and all endings to be CRLF
// Logic:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove this comment block

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed

// 1. Read: line1\r\nline2\r\nline3\r\n
// 2. Detect: CRLF
// 3. Normalize: line1\nline2\nline3\n
// 4. Replace: line1\nmodified\nline3\n (assuming safeLiteralReplace works on normalized strings)
// 5. Restore: line1\r\nmodified\r\nline3\r\n

expect(writtenContent).toBe('line1\r\nmodified\r\nline3\r\n');
});
});
});
14 changes: 13 additions & 1 deletion packages/core/src/tools/write-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import * as Diff from 'diff';
import { WRITE_FILE_TOOL_NAME } from './tool-names.js';
import type { Config } from '../config/config.js';
Expand All @@ -32,6 +33,7 @@ import {
ensureCorrectEdit,
ensureCorrectFileContent,
} from '../utils/editCorrector.js';
import { detectLineEnding } from '../utils/textUtils.js';
import { DEFAULT_DIFF_OPTIONS, getDiffStat } from './diffOptions.js';
import type {
ModifiableDeclarativeTool,
Expand Down Expand Up @@ -284,9 +286,19 @@ class WriteFileToolInvocation extends BaseToolInvocation<
fs.mkdirSync(dirName, { recursive: true });
}

let finalContent = fileContent;
const useCRLF =
!isNewFile && originalContent
? detectLineEnding(originalContent) === '\r\n'
: os.EOL === '\r\n';

if (useCRLF) {
finalContent = finalContent.replace(/\r?\n/g, '\r\n');
}
Comment on lines 306 to 314
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This logic for handling line endings is correct, but it contains duplicated code which can make it harder to maintain. You can simplify this block by combining the conditions for converting to CRLF, which improves readability and maintainability.

      let finalContent = fileContent;
      const preserveCRLF = !isNewFile && originalContent && detectLineEnding(originalContent) === '\r\n';
      // The `else` branch in the original logic covers new files or existing empty files.
      const useOSEOLForNewFile = (isNewFile || !originalContent) && os.EOL === '\r\n';

      if (preserveCRLF || useOSEOLForNewFile) {
        finalContent = finalContent.replace(/\r?\n/g, '\r\n');
      }

Copy link
Contributor Author

@Thomas-Shephard Thomas-Shephard Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed :)


await this.config
.getFileSystemService()
.writeTextFile(this.resolvedPath, fileContent);
.writeTextFile(this.resolvedPath, finalContent);

// Generate diff for display result
const fileName = path.basename(this.resolvedPath);
Expand Down
Loading