From 837860e189f8ebec82426f1efafdbae6c23910ec Mon Sep 17 00:00:00 2001 From: wuwei Date: Sat, 19 Sep 2026 01:16:44 +0800 Subject: [PATCH 1/2] fix(tools): create new files with the platform-native line ending A newly created file used LF whenever the model's content carried no CRLF, so files written on Windows ended up with LF endings while native tooling uses CRLF. Existing files were unaffected: their recorded line endings already win. platformLineEnding() (file-utils) now supplies the default for created files, mirroring the existing detectLineEndings() helper for reads. On LF platforms this is a no-op. Tests cover the CRLF/LF branches of the helper, a created file matching the platform ending, and an existing CRLF file keeping its endings. --- packages/core/src/common/file-utils.ts | 15 ++++ .../tests/write-handler-line-endings.test.ts | 70 +++++++++++++++++++ packages/core/src/tools/write-handler.ts | 4 +- 3 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/tests/write-handler-line-endings.test.ts diff --git a/packages/core/src/common/file-utils.ts b/packages/core/src/common/file-utils.ts index 6656172e..0a9dfaf9 100644 --- a/packages/core/src/common/file-utils.ts +++ b/packages/core/src/common/file-utils.ts @@ -1,4 +1,5 @@ import * as fs from "fs"; +import * as os from "os"; import * as path from "path"; import type { FileState, FileLineEnding } from "./state"; @@ -17,6 +18,20 @@ export function detectLineEndings(value: string): FileLineEnding { return value.includes("\r\n") ? "CRLF" : "LF"; } +/** + * Line ending a newly created file should use: the platform-native one. + * + * Created files have no existing EOL to preserve, and models emit LF-only text. + * Writing that verbatim produces LF files on Windows, where native tooling (and + * the files the user's editor creates) use CRLF. Existing files are unaffected: + * their recorded line endings still win in the write handler. + * + * @param eol platform line ending, injectable for tests + */ +export function platformLineEnding(eol: string = os.EOL): FileLineEnding { + return eol === "\r\n" ? "CRLF" : "LF"; +} + export function detectEncoding(buffer: Buffer): BufferEncoding { if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) { return "utf16le"; diff --git a/packages/core/src/tests/write-handler-line-endings.test.ts b/packages/core/src/tests/write-handler-line-endings.test.ts new file mode 100644 index 00000000..3ff6724c --- /dev/null +++ b/packages/core/src/tests/write-handler-line-endings.test.ts @@ -0,0 +1,70 @@ +import { afterEach, test } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import type { ToolExecutionContext } from "../tools/executor"; +import { handleReadTool } from "../tools/read-handler"; +import { handleWriteTool } from "../tools/write-handler"; +import { platformLineEnding } from "../common/file-utils"; + +const tempDirs: string[] = []; + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } +}); + +function createTempWorkspace(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-line-endings-")); + tempDirs.push(dir); + return dir; +} + +function createContext(sessionId: string, projectRoot: string): ToolExecutionContext { + return { + sessionId, + projectRoot, + toolCall: { + id: "test-tool-call", + type: "function", + function: { + name: "write", + arguments: "{}", + }, + }, + }; +} + +test("platformLineEnding reports CRLF only on Windows-style platforms", () => { + assert.equal(platformLineEnding("\r\n"), "CRLF"); + assert.equal(platformLineEnding("\n"), "LF"); +}); + +test("a newly created file uses the platform-native line ending", async () => { + // Models emit LF-only text. A created file has no existing EOL to preserve, so it + // should follow the platform: CRLF on Windows, matching what native tooling writes. + const workspace = createTempWorkspace(); + const filePath = path.join(workspace, "created.txt"); + + await handleWriteTool({ file_path: filePath, content: "one\ntwo" }, createContext("create-eol", workspace)); + + const expected = platformLineEnding() === "CRLF" ? "one\r\ntwo" : "one\ntwo"; + assert.equal(fs.readFileSync(filePath, "utf8"), expected); +}); + +test("an existing CRLF file keeps its line endings when rewritten with LF content", async () => { + const workspace = createTempWorkspace(); + const filePath = path.join(workspace, "existing.txt"); + fs.writeFileSync(filePath, "one\r\ntwo\r\n", "utf8"); + const context = createContext("keep-eol", workspace); + + await handleReadTool({ file_path: filePath }, context); + await handleWriteTool({ file_path: filePath, content: "one\ntwo\n" }, context); + + assert.equal(fs.readFileSync(filePath, "utf8"), "one\r\ntwo\r\n"); +}); diff --git a/packages/core/src/tools/write-handler.ts b/packages/core/src/tools/write-handler.ts index ede0d75a..7d212f95 100644 --- a/packages/core/src/tools/write-handler.ts +++ b/packages/core/src/tools/write-handler.ts @@ -6,6 +6,7 @@ import { ensureParentDirectory, hasFileChangedSinceState, normalizeContent, + platformLineEnding, readTextFileWithMetadata, writeTextFile, } from "../common/file-utils"; @@ -96,7 +97,8 @@ export async function handleWriteTool( const existingMetadata = existingFile ? readTextFileWithMetadata(filePath) : null; const encoding = existingMetadata?.encoding ?? "utf8"; - const lineEndings = existingMetadata?.lineEndings ?? (input.content.includes("\r\n") ? "CRLF" : "LF"); + const lineEndings = + existingMetadata?.lineEndings ?? (input.content.includes("\r\n") ? "CRLF" : platformLineEnding()); const diffPreview = buildDiffPreview(filePath, existingMetadata?.content ?? null, normalizedContent); context.signal?.throwIfAborted(); context.onBeforeFileMutation?.(filePath); From bbc0045a52cfef7774363aa97c55ad893a7cbbe0 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Thu, 24 Sep 2026 14:34:15 +0800 Subject: [PATCH 2/2] fix(tools): honor Git attributes for new file line endings --- packages/core/src/common/file-utils.ts | 40 +++-- packages/core/src/tests/session.test.ts | 4 +- packages/core/src/tests/tool-handlers.test.ts | 6 +- .../tests/write-handler-line-endings.test.ts | 151 ++++++++++++++++-- packages/core/src/tools/write-handler.ts | 5 +- 5 files changed, 173 insertions(+), 33 deletions(-) diff --git a/packages/core/src/common/file-utils.ts b/packages/core/src/common/file-utils.ts index 0a9dfaf9..21b5d147 100644 --- a/packages/core/src/common/file-utils.ts +++ b/packages/core/src/common/file-utils.ts @@ -1,3 +1,4 @@ +import childProcess from "node:child_process"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; @@ -18,20 +19,39 @@ export function detectLineEndings(value: string): FileLineEnding { return value.includes("\r\n") ? "CRLF" : "LF"; } -/** - * Line ending a newly created file should use: the platform-native one. - * - * Created files have no existing EOL to preserve, and models emit LF-only text. - * Writing that verbatim produces LF files on Windows, where native tooling (and - * the files the user's editor creates) use CRLF. Existing files are unaffected: - * their recorded line endings still win in the write handler. - * - * @param eol platform line ending, injectable for tests - */ export function platformLineEnding(eol: string = os.EOL): FileLineEnding { return eol === "\r\n" ? "CRLF" : "LF"; } +/** Resolve Git's eol attribute for a new file, falling back to the platform default. */ +export function newFileLineEnding(filePath: string, eol: string = os.EOL): FileLineEnding { + try { + // The caller creates the parent directory first. Resolve from the target's + // directory so nested attributes, worktrees, and files outside the session's + // project root use the correct repository and Git's own matching rules. + const output = childProcess.execFileSync( + "git", + ["check-attr", "-z", "text", "eol", "--", path.basename(filePath)], + { + cwd: path.dirname(filePath), + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 5000, + windowsHide: true, + } + ); + const [, , textAttribute, , , eolAttribute] = output.split("\0"); + // Git ignores eol when text conversion is explicitly disabled (-text/binary). + if (textAttribute !== "unset") { + if (eolAttribute === "lf") return "LF"; + if (eolAttribute === "crlf") return "CRLF"; + } + } catch { + // Missing Git, non-repository paths, or failed lookups must not block writes. + } + return platformLineEnding(eol); +} + export function detectEncoding(buffer: Buffer): BufferEncoding { if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) { return "utf16le"; diff --git a/packages/core/src/tests/session.test.ts b/packages/core/src/tests/session.test.ts index 5c017961..3d2d4d07 100644 --- a/packages/core/src/tests/session.test.ts +++ b/packages/core/src/tests/session.test.ts @@ -2192,7 +2192,7 @@ test("Write checkpoints restore tool-touched files outside the workspace and lea const sessionId = await manager.createSession({ text: "create an outside file" }); const userMessage = manager.listSessionMessages(sessionId).find((message) => message.role === "user"); assert.ok(userMessage?.checkpointHash); - assert.equal(fs.readFileSync(outsideFilePath, "utf8"), "outside\n"); + assert.equal(fs.readFileSync(outsideFilePath, "utf8"), `outside${os.EOL}`); fs.writeFileSync(unrelatedWorkspaceFilePath, "keep\n", "utf8"); manager.restoreSessionCode(sessionId, userMessage.id); @@ -2236,7 +2236,7 @@ test("missing git executable does not block sessions or Write tool calls", async const sessionId = await manager.createSession({ text: "create an index page" }); const userMessage = manager.listSessionMessages(sessionId).find((message) => message.role === "user"); - assert.equal(fs.readFileSync(filePath, "utf8"), "

No Git

\n"); + assert.equal(fs.readFileSync(filePath, "utf8"), `

No Git

${os.EOL}`); assert.equal(userMessage?.checkpointHash, undefined); assert.equal(manager.getSession(sessionId)?.status, "completed"); } finally { diff --git a/packages/core/src/tests/tool-handlers.test.ts b/packages/core/src/tests/tool-handlers.test.ts index 2e84e662..de3b7a2d 100644 --- a/packages/core/src/tests/tool-handlers.test.ts +++ b/packages/core/src/tests/tool-handlers.test.ts @@ -1196,10 +1196,10 @@ test("Write repairs JSON object content for .json files", async () => { assert.equal(writeResult.metadata?.type, "create"); assert.equal(writeResult.metadata?.file_path, filePath); assert.equal(writeResult.metadata?.cache_refreshed, true); - assert.equal(writeResult.metadata?.line_endings, "LF"); + assert.equal(writeResult.metadata?.line_endings, os.EOL === "\r\n" ? "CRLF" : "LF"); assert.equal(writeResult.metadata?.input_repaired, true); assert.match(String(writeResult.metadata?.diff_preview ?? ""), /\+\s*"name": "demo"|^\+\{/m); - assert.equal(fs.readFileSync(filePath, "utf8"), '{\n "name": "demo",\n "private": true\n}'); + assert.equal(fs.readFileSync(filePath, "utf8"), ["{", ' "name": "demo",', ' "private": true', "}"].join(os.EOL)); }); test("Edit requires snippet_id even after Write refreshes file state", async () => { @@ -1229,7 +1229,7 @@ test("Edit requires snippet_id even after Write refreshes file state", async () assert.equal(editResult.ok, false); assert.match(editResult.error ?? "", /snippet_id/); - assert.equal(fs.readFileSync(filePath, "utf8"), "alpha\nbeta\n"); + assert.equal(fs.readFileSync(filePath, "utf8"), `alpha${os.EOL}beta${os.EOL}`); }); test("Edit allows empty old_string when the file is empty", async () => { diff --git a/packages/core/src/tests/write-handler-line-endings.test.ts b/packages/core/src/tests/write-handler-line-endings.test.ts index 3ff6724c..6ae6b835 100644 --- a/packages/core/src/tests/write-handler-line-endings.test.ts +++ b/packages/core/src/tests/write-handler-line-endings.test.ts @@ -1,12 +1,13 @@ import { afterEach, test } from "node:test"; import assert from "node:assert/strict"; +import childProcess from "node:child_process"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import type { ToolExecutionContext } from "../tools/executor"; import { handleReadTool } from "../tools/read-handler"; import { handleWriteTool } from "../tools/write-handler"; -import { platformLineEnding } from "../common/file-utils"; +import { newFileLineEnding, platformLineEnding } from "../common/file-utils"; const tempDirs: string[] = []; @@ -25,6 +26,15 @@ function createTempWorkspace(): string { return dir; } +function createRepository(attributes: string): string { + const workspace = createTempWorkspace(); + childProcess.execFileSync("git", ["init", "--quiet", workspace]); + // Keep the fixture independent of the developer's global Git attributes. + childProcess.execFileSync("git", ["config", "core.attributesFile", os.devNull], { cwd: workspace }); + fs.writeFileSync(path.join(workspace, ".gitattributes"), attributes); + return workspace; +} + function createContext(sessionId: string, projectRoot: string): ToolExecutionContext { return { sessionId, @@ -45,26 +55,137 @@ test("platformLineEnding reports CRLF only on Windows-style platforms", () => { assert.equal(platformLineEnding("\n"), "LF"); }); -test("a newly created file uses the platform-native line ending", async () => { - // Models emit LF-only text. A created file has no existing EOL to preserve, so it - // should follow the platform: CRLF on Windows, matching what native tooling writes. +test("a new file without Git attributes uses the platform ending regardless of model content", async () => { const workspace = createTempWorkspace(); - const filePath = path.join(workspace, "created.txt"); + fs.writeFileSync(path.join(workspace, ".editorconfig"), "root = true\n[*]\nend_of_line = crlf\n"); - await handleWriteTool({ file_path: filePath, content: "one\ntwo" }, createContext("create-eol", workspace)); + for (const [index, content] of ["one\ntwo", "one\r\ntwo"].entries()) { + const filePath = path.join(workspace, `created-${index}.txt`); + const result = await handleWriteTool({ file_path: filePath, content }, createContext("create-eol", workspace)); - const expected = platformLineEnding() === "CRLF" ? "one\r\ntwo" : "one\ntwo"; - assert.equal(fs.readFileSync(filePath, "utf8"), expected); + assert.equal(result.ok, true, result.error); + assert.equal(fs.readFileSync(filePath, "utf8"), `one${os.EOL}two`); + assert.equal(result.metadata?.bytes, Buffer.byteLength(`one${os.EOL}two`)); + assert.equal(result.metadata?.line_endings, os.EOL === "\r\n" ? "CRLF" : "LF"); + } }); -test("an existing CRLF file keeps its line endings when rewritten with LF content", async () => { - const workspace = createTempWorkspace(); - const filePath = path.join(workspace, "existing.txt"); - fs.writeFileSync(filePath, "one\r\ntwo\r\n", "utf8"); - const context = createContext("keep-eol", workspace); +for (const eol of ["lf", "crlf"] as const) { + test(`new files honor eol=${eol} over both platform defaults and model content`, async () => { + const workspace = createRepository(`* text=auto eol=${eol}\n`); + const filePath = path.join(workspace, "new directory", "你好 file.txt"); + // The handler must create missing parent directories before asking Git. + const content = eol === "lf" ? "one\r\ntwo\r\n" : "one\ntwo\n"; + const result = await handleWriteTool({ file_path: filePath, content }, createContext(`create-${eol}`, workspace)); + const ending = eol === "lf" ? "\n" : "\r\n"; + const expected = `one${ending}two${ending}`; + + assert.equal(result.ok, true, result.error); + assert.equal(fs.readFileSync(filePath, "utf8"), expected); + assert.equal(result.metadata?.bytes, Buffer.byteLength(expected)); + assert.equal(result.metadata?.line_endings, eol.toUpperCase()); + for (const platformEol of ["\n", "\r\n"]) { + assert.equal(newFileLineEnding(filePath, platformEol), eol.toUpperCase()); + } + }); + + test(`existing ${eol} files retain their encoding and endings despite conflicting attributes`, async () => { + const workspace = createRepository(`* text eol=${eol === "lf" ? "crlf" : "lf"}\n`); + for (const encoding of ["utf8", "utf16le"] as const) { + const filePath = path.join(workspace, `existing-${encoding}.txt`); + const ending = eol === "lf" ? "\n" : "\r\n"; + const bom = encoding === "utf16le" ? "\uFEFF" : ""; + fs.writeFileSync(filePath, `${bom}one${ending}two${ending}`, encoding); + const context = createContext(`keep-${eol}-${encoding}`, workspace); + const readResult = await handleReadTool({ file_path: filePath }, context); + assert.equal(readResult.ok, true, readResult.error); + const content = eol === "lf" ? `${bom}one\r\nchanged` : `${bom}one\nchanged`; + const result = await handleWriteTool({ file_path: filePath, content }, context); + + assert.equal(result.ok, true, result.error); + assert.deepEqual(fs.readFileSync(filePath), Buffer.from(`${bom}one${ending}changed`, encoding)); + assert.equal(result.metadata?.encoding, encoding); + assert.equal(result.metadata?.line_endings, eol.toUpperCase()); + } + }); +} - await handleReadTool({ file_path: filePath }, context); - await handleWriteTool({ file_path: filePath, content: "one\ntwo\n" }, context); +test("nested attributes and later matching rules follow Git precedence", async () => { + const workspace = createRepository("* text eol=lf\n*.cmd eol=crlf\n"); + const nested = path.join(workspace, "nested"); + fs.mkdirSync(nested); + fs.writeFileSync(path.join(nested, ".gitattributes"), '* eol=crlf\n*.txt eol=lf\n"space name.txt" eol=crlf\n'); + + for (const [relativePath, ending] of [ + ["root.txt", "\n"], + ["root.cmd", "\r\n"], + ["nested/file.ts", "\r\n"], + ["nested/file.txt", "\n"], + ["nested/space name.txt", "\r\n"], + ]) { + const filePath = path.join(workspace, relativePath); + const result = await handleWriteTool( + { file_path: filePath, content: "one\ntwo\n" }, + createContext("nested-attributes", workspace) + ); + assert.equal(result.ok, true, result.error); + assert.equal(fs.readFileSync(filePath, "utf8"), `one${ending}two${ending}`, relativePath); + } +}); + +test("unspecified, unset, invalid, and binary attributes fall back to the platform", () => { + const workspace = createRepository( + [ + "*.txt text eol=crlf", + "unspecified.txt !eol", + "unset.txt -eol", + "invalid.txt eol=native", + "binary.txt binary", + "no-text.txt -text", + "auto.txt text=auto !eol", + ].join("\n") + "\n" + ); + childProcess.execFileSync("git", ["config", "core.eol", "crlf"], { cwd: workspace }); + + for (const fileName of [ + "unmatched.ts", + "unspecified.txt", + "unset.txt", + "invalid.txt", + "binary.txt", + "no-text.txt", + "auto.txt", + ]) { + const filePath = path.join(workspace, fileName); + assert.equal(newFileLineEnding(filePath, "\n"), "LF", fileName); + assert.equal(newFileLineEnding(filePath, "\r\n"), "CRLF", fileName); + } +}); + +test("Git lookup failures fall back without blocking file creation", async (t) => { + const workspace = createTempWorkspace(); + t.mock.method(childProcess, "execFileSync", () => { + throw Object.assign(new Error("spawnSync git ENOENT"), { code: "ENOENT" }); + }); + const filePath = path.join(workspace, "created.txt"); + assert.equal(newFileLineEnding(filePath, "\n"), "LF"); + assert.equal(newFileLineEnding(filePath, "\r\n"), "CRLF"); + const result = await handleWriteTool( + { file_path: filePath, content: "one\ntwo\n" }, + createContext("no-git", workspace) + ); + assert.equal(result.ok, true, result.error); + assert.equal(fs.readFileSync(filePath, "utf8"), `one${os.EOL}two${os.EOL}`); +}); +test("new files outside the session project use their own repository attributes", async () => { + const projectRoot = createRepository("* text eol=lf\n"); + const targetRoot = createRepository("* text eol=crlf\n"); + const filePath = path.join(targetRoot, "outside.txt"); + const result = await handleWriteTool( + { file_path: filePath, content: "one\ntwo\n" }, + createContext("outside-project", projectRoot) + ); + assert.equal(result.ok, true, result.error); assert.equal(fs.readFileSync(filePath, "utf8"), "one\r\ntwo\r\n"); }); diff --git a/packages/core/src/tools/write-handler.ts b/packages/core/src/tools/write-handler.ts index 7d212f95..3ce780f0 100644 --- a/packages/core/src/tools/write-handler.ts +++ b/packages/core/src/tools/write-handler.ts @@ -6,7 +6,7 @@ import { ensureParentDirectory, hasFileChangedSinceState, normalizeContent, - platformLineEnding, + newFileLineEnding, readTextFileWithMetadata, writeTextFile, } from "../common/file-utils"; @@ -97,8 +97,7 @@ export async function handleWriteTool( const existingMetadata = existingFile ? readTextFileWithMetadata(filePath) : null; const encoding = existingMetadata?.encoding ?? "utf8"; - const lineEndings = - existingMetadata?.lineEndings ?? (input.content.includes("\r\n") ? "CRLF" : platformLineEnding()); + const lineEndings = existingMetadata?.lineEndings ?? newFileLineEnding(filePath); const diffPreview = buildDiffPreview(filePath, existingMetadata?.content ?? null, normalizedContent); context.signal?.throwIfAborted(); context.onBeforeFileMutation?.(filePath);