11import { afterEach , test } from "node:test" ;
22import assert from "node:assert/strict" ;
3+ import childProcess from "node:child_process" ;
34import * as fs from "fs" ;
45import * as os from "os" ;
56import * as path from "path" ;
67import type { ToolExecutionContext } from "../tools/executor" ;
78import { handleReadTool } from "../tools/read-handler" ;
89import { handleWriteTool } from "../tools/write-handler" ;
9- import { platformLineEnding } from "../common/file-utils" ;
10+ import { newFileLineEnding , platformLineEnding } from "../common/file-utils" ;
1011
1112const tempDirs : string [ ] = [ ] ;
1213
@@ -25,6 +26,15 @@ function createTempWorkspace(): string {
2526 return dir ;
2627}
2728
29+ function createRepository ( attributes : string ) : string {
30+ const workspace = createTempWorkspace ( ) ;
31+ childProcess . execFileSync ( "git" , [ "init" , "--quiet" , workspace ] ) ;
32+ // Keep the fixture independent of the developer's global Git attributes.
33+ childProcess . execFileSync ( "git" , [ "config" , "core.attributesFile" , os . devNull ] , { cwd : workspace } ) ;
34+ fs . writeFileSync ( path . join ( workspace , ".gitattributes" ) , attributes ) ;
35+ return workspace ;
36+ }
37+
2838function createContext ( sessionId : string , projectRoot : string ) : ToolExecutionContext {
2939 return {
3040 sessionId,
@@ -45,26 +55,137 @@ test("platformLineEnding reports CRLF only on Windows-style platforms", () => {
4555 assert . equal ( platformLineEnding ( "\n" ) , "LF" ) ;
4656} ) ;
4757
48- test ( "a newly created file uses the platform-native line ending" , async ( ) => {
49- // Models emit LF-only text. A created file has no existing EOL to preserve, so it
50- // should follow the platform: CRLF on Windows, matching what native tooling writes.
58+ test ( "a new file without Git attributes uses the platform ending regardless of model content" , async ( ) => {
5159 const workspace = createTempWorkspace ( ) ;
52- const filePath = path . join ( workspace , "created.txt " ) ;
60+ fs . writeFileSync ( path . join ( workspace , ".editorconfig" ) , "root = true\n[*]\nend_of_line = crlf\n ") ;
5361
54- await handleWriteTool ( { file_path : filePath , content : "one\ntwo" } , createContext ( "create-eol" , workspace ) ) ;
62+ for ( const [ index , content ] of [ "one\ntwo" , "one\r\ntwo" ] . entries ( ) ) {
63+ const filePath = path . join ( workspace , `created-${ index } .txt` ) ;
64+ const result = await handleWriteTool ( { file_path : filePath , content } , createContext ( "create-eol" , workspace ) ) ;
5565
56- const expected = platformLineEnding ( ) === "CRLF" ? "one\r\ntwo" : "one\ntwo" ;
57- assert . equal ( fs . readFileSync ( filePath , "utf8" ) , expected ) ;
66+ assert . equal ( result . ok , true , result . error ) ;
67+ assert . equal ( fs . readFileSync ( filePath , "utf8" ) , `one${ os . EOL } two` ) ;
68+ assert . equal ( result . metadata ?. bytes , Buffer . byteLength ( `one${ os . EOL } two` ) ) ;
69+ assert . equal ( result . metadata ?. line_endings , os . EOL === "\r\n" ? "CRLF" : "LF" ) ;
70+ }
5871} ) ;
5972
60- test ( "an existing CRLF file keeps its line endings when rewritten with LF content" , async ( ) => {
61- const workspace = createTempWorkspace ( ) ;
62- const filePath = path . join ( workspace , "existing.txt" ) ;
63- fs . writeFileSync ( filePath , "one\r\ntwo\r\n" , "utf8" ) ;
64- const context = createContext ( "keep-eol" , workspace ) ;
73+ for ( const eol of [ "lf" , "crlf" ] as const ) {
74+ test ( `new files honor eol=${ eol } over both platform defaults and model content` , async ( ) => {
75+ const workspace = createRepository ( `* text=auto eol=${ eol } \n` ) ;
76+ const filePath = path . join ( workspace , "new directory" , "你好 file.txt" ) ;
77+ // The handler must create missing parent directories before asking Git.
78+ const content = eol === "lf" ? "one\r\ntwo\r\n" : "one\ntwo\n" ;
79+ const result = await handleWriteTool ( { file_path : filePath , content } , createContext ( `create-${ eol } ` , workspace ) ) ;
80+ const ending = eol === "lf" ? "\n" : "\r\n" ;
81+ const expected = `one${ ending } two${ ending } ` ;
82+
83+ assert . equal ( result . ok , true , result . error ) ;
84+ assert . equal ( fs . readFileSync ( filePath , "utf8" ) , expected ) ;
85+ assert . equal ( result . metadata ?. bytes , Buffer . byteLength ( expected ) ) ;
86+ assert . equal ( result . metadata ?. line_endings , eol . toUpperCase ( ) ) ;
87+ for ( const platformEol of [ "\n" , "\r\n" ] ) {
88+ assert . equal ( newFileLineEnding ( filePath , platformEol ) , eol . toUpperCase ( ) ) ;
89+ }
90+ } ) ;
91+
92+ test ( `existing ${ eol } files retain their encoding and endings despite conflicting attributes` , async ( ) => {
93+ const workspace = createRepository ( `* text eol=${ eol === "lf" ? "crlf" : "lf" } \n` ) ;
94+ for ( const encoding of [ "utf8" , "utf16le" ] as const ) {
95+ const filePath = path . join ( workspace , `existing-${ encoding } .txt` ) ;
96+ const ending = eol === "lf" ? "\n" : "\r\n" ;
97+ const bom = encoding === "utf16le" ? "\uFEFF" : "" ;
98+ fs . writeFileSync ( filePath , `${ bom } one${ ending } two${ ending } ` , encoding ) ;
99+ const context = createContext ( `keep-${ eol } -${ encoding } ` , workspace ) ;
100+ const readResult = await handleReadTool ( { file_path : filePath } , context ) ;
101+ assert . equal ( readResult . ok , true , readResult . error ) ;
102+ const content = eol === "lf" ? `${ bom } one\r\nchanged` : `${ bom } one\nchanged` ;
103+ const result = await handleWriteTool ( { file_path : filePath , content } , context ) ;
104+
105+ assert . equal ( result . ok , true , result . error ) ;
106+ assert . deepEqual ( fs . readFileSync ( filePath ) , Buffer . from ( `${ bom } one${ ending } changed` , encoding ) ) ;
107+ assert . equal ( result . metadata ?. encoding , encoding ) ;
108+ assert . equal ( result . metadata ?. line_endings , eol . toUpperCase ( ) ) ;
109+ }
110+ } ) ;
111+ }
65112
66- await handleReadTool ( { file_path : filePath } , context ) ;
67- await handleWriteTool ( { file_path : filePath , content : "one\ntwo\n" } , context ) ;
113+ test ( "nested attributes and later matching rules follow Git precedence" , async ( ) => {
114+ const workspace = createRepository ( "* text eol=lf\n*.cmd eol=crlf\n" ) ;
115+ const nested = path . join ( workspace , "nested" ) ;
116+ fs . mkdirSync ( nested ) ;
117+ fs . writeFileSync ( path . join ( nested , ".gitattributes" ) , '* eol=crlf\n*.txt eol=lf\n"space name.txt" eol=crlf\n' ) ;
118+
119+ for ( const [ relativePath , ending ] of [
120+ [ "root.txt" , "\n" ] ,
121+ [ "root.cmd" , "\r\n" ] ,
122+ [ "nested/file.ts" , "\r\n" ] ,
123+ [ "nested/file.txt" , "\n" ] ,
124+ [ "nested/space name.txt" , "\r\n" ] ,
125+ ] ) {
126+ const filePath = path . join ( workspace , relativePath ) ;
127+ const result = await handleWriteTool (
128+ { file_path : filePath , content : "one\ntwo\n" } ,
129+ createContext ( "nested-attributes" , workspace )
130+ ) ;
131+ assert . equal ( result . ok , true , result . error ) ;
132+ assert . equal ( fs . readFileSync ( filePath , "utf8" ) , `one${ ending } two${ ending } ` , relativePath ) ;
133+ }
134+ } ) ;
135+
136+ test ( "unspecified, unset, invalid, and binary attributes fall back to the platform" , ( ) => {
137+ const workspace = createRepository (
138+ [
139+ "*.txt text eol=crlf" ,
140+ "unspecified.txt !eol" ,
141+ "unset.txt -eol" ,
142+ "invalid.txt eol=native" ,
143+ "binary.txt binary" ,
144+ "no-text.txt -text" ,
145+ "auto.txt text=auto !eol" ,
146+ ] . join ( "\n" ) + "\n"
147+ ) ;
148+ childProcess . execFileSync ( "git" , [ "config" , "core.eol" , "crlf" ] , { cwd : workspace } ) ;
149+
150+ for ( const fileName of [
151+ "unmatched.ts" ,
152+ "unspecified.txt" ,
153+ "unset.txt" ,
154+ "invalid.txt" ,
155+ "binary.txt" ,
156+ "no-text.txt" ,
157+ "auto.txt" ,
158+ ] ) {
159+ const filePath = path . join ( workspace , fileName ) ;
160+ assert . equal ( newFileLineEnding ( filePath , "\n" ) , "LF" , fileName ) ;
161+ assert . equal ( newFileLineEnding ( filePath , "\r\n" ) , "CRLF" , fileName ) ;
162+ }
163+ } ) ;
164+
165+ test ( "Git lookup failures fall back without blocking file creation" , async ( t ) => {
166+ const workspace = createTempWorkspace ( ) ;
167+ t . mock . method ( childProcess , "execFileSync" , ( ) => {
168+ throw Object . assign ( new Error ( "spawnSync git ENOENT" ) , { code : "ENOENT" } ) ;
169+ } ) ;
170+ const filePath = path . join ( workspace , "created.txt" ) ;
171+ assert . equal ( newFileLineEnding ( filePath , "\n" ) , "LF" ) ;
172+ assert . equal ( newFileLineEnding ( filePath , "\r\n" ) , "CRLF" ) ;
173+ const result = await handleWriteTool (
174+ { file_path : filePath , content : "one\ntwo\n" } ,
175+ createContext ( "no-git" , workspace )
176+ ) ;
177+ assert . equal ( result . ok , true , result . error ) ;
178+ assert . equal ( fs . readFileSync ( filePath , "utf8" ) , `one${ os . EOL } two${ os . EOL } ` ) ;
179+ } ) ;
68180
181+ test ( "new files outside the session project use their own repository attributes" , async ( ) => {
182+ const projectRoot = createRepository ( "* text eol=lf\n" ) ;
183+ const targetRoot = createRepository ( "* text eol=crlf\n" ) ;
184+ const filePath = path . join ( targetRoot , "outside.txt" ) ;
185+ const result = await handleWriteTool (
186+ { file_path : filePath , content : "one\ntwo\n" } ,
187+ createContext ( "outside-project" , projectRoot )
188+ ) ;
189+ assert . equal ( result . ok , true , result . error ) ;
69190 assert . equal ( fs . readFileSync ( filePath , "utf8" ) , "one\r\ntwo\r\n" ) ;
70191} ) ;
0 commit comments