From 5c651091dcf1f0634145571dc2ec9c77009d1ee7 Mon Sep 17 00:00:00 2001 From: samuel mbabhazi <111171386+samuelmbabhazi@users.noreply.github.com> Date: Sun, 25 Oct 2026 07:02:16 +0200 Subject: [PATCH] fix(cli): write the AGENTS.md/CLAUDE.md block in the file's own line ending (#3708) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): write the AGENTS.md/CLAUDE.md block in the file's own line ending The GitNexus block is generated with LF. In a checkout where text files are CRLF (Windows, `core.autocrlf=true`, `* text=auto`), upserting it into an existing CLAUDE.md wrote the block in LF inside CRLF prose (#3688). `git ls-files --eol` then reported `w/mixed`, and `git status` listed the file as modified after every reindex although `git diff` was empty once normalised. The count-only guard from #2907 never engaged there either: the existing section still had its CRLF, the fresh block had not, so the two never compared equal and the file was rewritten on every run. `upsertGitNexusSection` now reads the file, notes the ending of its first line break, normalises the content to LF for the marker search and the comparisons, and writes the result back in the file's ending, on the replace and append paths alike. A new file is still written in LF. `refreshBaseRefLine` edits in place and already kept the ending. Four tests: a section replaced in a CRLF file comes back all-CRLF with the hand-written prose untouched; a block appended to a CRLF file is CRLF; a count-only reindex leaves a CRLF file byte-identical; an LF file stays LF. The first three fail on the previous writer. * fix(cli): repair a context file the previous writer left with mixed endings A CLAUDE.md with CRLF prose and an LF GitNexus block, as the previous writer left it, compared equal to the generated content once normalised and took the preserved path, so its mixed bytes stayed and git kept reporting it modified. Before any path that leaves the file as it is, the original bytes are compared with the content in the file's own ending; when they differ, the file is written back once in that ending with its text, counts and custom sections untouched. Test: a file built that way is uniform after one run, with the hand-written prose and the block intact, and byte-identical after the next. * test(cli): cover CRLF context preservation paths --------- Co-authored-by: Gergő Magyar Co-authored-by: Gergo Magyar --- gitnexus/src/cli/ai-context.ts | 50 ++++- gitnexus/test/unit/ai-context.test.ts | 269 +++++++++++++++++++++++--- 2 files changed, 284 insertions(+), 35 deletions(-) diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index 4f93e13dc..7440829b5 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -347,7 +347,24 @@ async function upsertGitNexusSection( return 'created'; } - const existingContent = await fs.readFile(filePath, 'utf-8'); + // The block is generated with LF. A file checked out with CRLF (Windows, + // `core.autocrlf`) used to get the block written in LF inside its CRLF + // prose, so git saw mixed endings and a dirty tree after every reindex + // (#3688). The file's own ending is kept: the content is normalised to LF + // for the marker search and the comparisons below, and written back in the + // ending it had. + const rawContent = await fs.readFile(filePath, 'utf-8'); + const eol = detectLineEnding(rawContent); + const existingContent = rawContent.replace(/\r\n/g, '\n'); + // A file the previous writer left with mixed endings (CRLF prose, LF block) + // compares equal once normalised, so the paths below that keep the file as + // it is would leave the mixed bytes in place. Such a file is written back + // once, in its own ending, with its text untouched. + const repairLineEndings = async (): Promise<'updated' | 'preserved'> => { + if (rawContent === toLineEnding(existingContent, eol)) return 'preserved'; + await writeWithLineEnding(filePath, existingContent, eol); + return 'updated'; + }; // Check if GitNexus section already exists. Matching is restricted // to markers that occupy their own line so that inline prose @@ -407,37 +424,56 @@ async function upsertGitNexusSection( // Count-only delta — leave the committed lean block alone (#2907). A // project rename, or --no-stats dropping the parenthetical, still writes. if (stripVolatileCounts(updatedSection) === stripVolatileCounts(existingSection)) { - return 'preserved'; + return repairLineEndings(); } const before = existingContent.substring(0, startIdx); const after = existingContent.substring(endIdx + GITNEXUS_END_MARKER.length); - await fs.writeFile(filePath, (before + updatedSection + after).trim() + '\n', 'utf-8'); + await writeWithLineEnding(filePath, (before + updatedSection + after).trim() + '\n', eol); return 'updated'; } // Keep marker present but no stats line matched. Section is preserved // unchanged on disk; return a distinct status so callers/CLI output // don't mis-report this as 'updated' (which would imply a write). - return 'preserved'; + return repairLineEndings(); } // No keep marker — replace existing section with full verbose content, // unless the counts are the only thing that moved (#2907). if (stripVolatileCounts(existingSection) === stripVolatileCounts(content)) { - return 'preserved'; + return repairLineEndings(); } const before = existingContent.substring(0, startIdx); const after = existingContent.substring(endIdx + GITNEXUS_END_MARKER.length); const newContent = before + content + after; - await fs.writeFile(filePath, newContent.trim() + '\n', 'utf-8'); + await writeWithLineEnding(filePath, newContent.trim() + '\n', eol); return 'updated'; } // Append new section const newContent = existingContent.trim() + '\n\n' + content + '\n'; - await fs.writeFile(filePath, newContent, 'utf-8'); + await writeWithLineEnding(filePath, newContent, eol); return 'appended'; } +/** The line ending of the first line break in `content`; LF when there is none. */ +function detectLineEnding(content: string): '\n' | '\r\n' { + const firstBreak = content.indexOf('\n'); + return firstBreak > 0 && content[firstBreak - 1] === '\r' ? '\r\n' : '\n'; +} + +function toLineEnding(lfContent: string, eol: '\n' | '\r\n'): string { + return eol === '\n' ? lfContent : lfContent.replace(/\n/g, eol); +} + +/** Write LF text in the file's own line ending. */ +async function writeWithLineEnding( + filePath: string, + lfContent: string, + eol: '\n' | '\r\n', +): Promise { + await fs.writeFile(filePath, toLineEnding(lfContent, eol), 'utf-8'); +} + /** * Some agents read skills from a repo-local `.agents/skills/` directory and * prefer it over the global `~/.agents/skills/` install. When the repo contains diff --git a/gitnexus/test/unit/ai-context.test.ts b/gitnexus/test/unit/ai-context.test.ts index 8a4a836d2..6030e9eca 100644 --- a/gitnexus/test/unit/ai-context.test.ts +++ b/gitnexus/test/unit/ai-context.test.ts @@ -406,6 +406,208 @@ describe('generateAIContextFiles', () => { expect(starts).toBe(1); }); + describe('line endings (#3688)', () => { + const crlf = (lines: string[]) => lines.join('\r\n') + '\r\n'; + const stats = { nodes: 10, edges: 20, processes: 3 }; + + async function withRepo(run: (dir: string, storage: string) => Promise) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-crlf-')); + const storage = path.join(dir, '.gitnexus'); + await fs.mkdir(storage, { recursive: true }); + try { + await run(dir, storage); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } + } + + const loneLfCount = (content: string) => content.replace(/\r\n/g, '').split('\n').length - 1; + + it('writes the block in CRLF when it replaces a section in a CRLF file', async () => { + await withRepo(async (dir, storage) => { + const file = path.join(dir, 'CLAUDE.md'); + await fs.writeFile( + file, + crlf([ + '# Hand-written guide', + '', + 'Keep this paragraph.', + '', + '', + 'old block', + '', + '', + 'And this one.', + ]), + 'utf-8', + ); + + await generateAIContextFiles(dir, storage, 'CrlfProject', stats); + + const content = await fs.readFile(file, 'utf-8'); + expect(content).toContain('Keep this paragraph.\r\n'); + expect(content).toContain('And this one.'); + expect(content).toContain('\r\n'); + expect(content).toContain('CrlfProject'); + expect(content).not.toContain('old block'); + expect(loneLfCount(content)).toBe(0); + }); + }); + + it('appends the block in CRLF to a CRLF file that has no section yet', async () => { + await withRepo(async (dir, storage) => { + const file = path.join(dir, 'AGENTS.md'); + await fs.writeFile(file, crlf(['# Agents', '', 'Hand-written.']), 'utf-8'); + + await generateAIContextFiles(dir, storage, 'CrlfProject', stats); + + const content = await fs.readFile(file, 'utf-8'); + expect(content.startsWith('# Agents\r\n')).toBe(true); + expect(content).toContain(''); + expect(loneLfCount(content)).toBe(0); + }); + }); + + it('leaves a CRLF file alone when only the counts moved', async () => { + await withRepo(async (dir, storage) => { + const file = path.join(dir, 'CLAUDE.md'); + await fs.writeFile(file, crlf(['# Guide', '', 'Hand-written.']), 'utf-8'); + await generateAIContextFiles(dir, storage, 'CrlfProject', stats); + // As git hands the file back on a CRLF checkout: block included. + const committed = (await fs.readFile(file, 'utf-8')).replace(/\r?\n/g, '\r\n'); + await fs.writeFile(file, committed, 'utf-8'); + + await generateAIContextFiles(dir, storage, 'CrlfProject', { + nodes: 11, + edges: 21, + processes: 4, + }); + + expect(await fs.readFile(file, 'utf-8')).toBe(committed); + }); + }); + + it.each([false, true])('repairs mixed endings once with noStats=%s', async (noStats) => { + await withRepo(async (dir, storage) => { + // Build the block as the previous writer wrote it: LF, inside CRLF prose. + const file = path.join(dir, 'CLAUDE.md'); + await fs.writeFile(file, '# Guide\n', 'utf-8'); + const options = { noStats, skipSkills: true }; + await generateAIContextFiles(dir, storage, 'CrlfProject', stats, undefined, options); + const lfFile = await fs.readFile(file, 'utf-8'); + const block = lfFile.slice(lfFile.indexOf('')); + const mixed = crlf(['# Guide', '', 'Hand-written, kept as is.', '']) + block; + await fs.writeFile(file, mixed, 'utf-8'); + expect(mixed).toContain('\r\n'); + expect(mixed.replace(/\r\n/g, '')).toContain('\n'); + + // Counts moved, so the block itself is left alone: the file is still + // written once, uniformly in its own ending. + const first = await generateAIContextFiles( + dir, + storage, + 'CrlfProject', + { nodes: 11, edges: 21, processes: 4 }, + undefined, + options, + ); + const repaired = await fs.readFile(file, 'utf-8'); + expect(first.files).toContain('CLAUDE.md (updated)'); + expect(loneLfCount(repaired)).toBe(0); + expect(repaired).toContain('Hand-written, kept as is.\r\n'); + expect(repaired.replace(/\r\n/g, '\n')).toBe(mixed.replace(/\r\n/g, '\n')); + + // A fixed old mtime proves the repeat does not rewrite identical bytes. + const oldTime = new Date('2000-01-01T00:00:00Z'); + await fs.utimes(file, oldTime, oldTime); + const beforeRepeat = await fs.stat(file); + const second = await generateAIContextFiles( + dir, + storage, + 'CrlfProject', + { nodes: 12, edges: 22, processes: 5 }, + undefined, + options, + ); + expect(second.files).toContain('CLAUDE.md (preserved)'); + expect(await fs.readFile(file, 'utf-8')).toBe(repaired); + expect((await fs.stat(file)).mtimeMs).toBe(beforeRepeat.mtimeMs); + }); + }); + + it.each([ + { + name: 'count-only change', + line: 'Indexed as **CrlfProject** (1 symbols, 2 relationships, 3 execution flows). Custom.', + noStats: false, + }, + { + name: 'no stats line', + line: 'A custom block without a generated stats line.', + noStats: false, + }, + { name: 'count-free stats line', line: 'Indexed as **CrlfProject**. Custom.', noStats: true }, + ])('repairs a mixed keep block once: $name', async ({ line, noStats }) => { + await withRepo(async (dir, storage) => { + const block = [ + '', + '', + line, + '| query | Find flows |', + '', + '', + ].join('\n'); + const mixed = + crlf(['# Guide', '', 'Keep this prose.', '']) + block + crlf(['', 'Keep this footer.']); + const expected = mixed.replace(/\r?\n/g, '\r\n'); + const files = ['AGENTS.md', 'CLAUDE.md']; + for (const name of files) await fs.writeFile(path.join(dir, name), mixed, 'utf-8'); + const options = { noStats, skipSkills: true }; + const first = await generateAIContextFiles( + dir, + storage, + 'CrlfProject', + stats, + undefined, + options, + ); + for (const name of files) { + expect(first.files).toContain(`${name} (updated)`); + expect(await fs.readFile(path.join(dir, name), 'utf-8')).toBe(expected); + } + const oldTime = new Date('2000-01-01T00:00:00Z'); + for (const name of files) await fs.utimes(path.join(dir, name), oldTime, oldTime); + const mtimes = await Promise.all( + files.map(async (name) => (await fs.stat(path.join(dir, name))).mtimeMs), + ); + const second = await generateAIContextFiles( + dir, + storage, + 'CrlfProject', + { nodes: 11, edges: 21, processes: 4 }, + undefined, + options, + ); + for (const [i, name] of files.entries()) { + expect(second.files).toContain(`${name} (preserved)`); + expect(await fs.readFile(path.join(dir, name), 'utf-8')).toBe(expected); + expect((await fs.stat(path.join(dir, name))).mtimeMs).toBe(mtimes[i]); + } + }); + }); + + it('keeps an LF file in LF', async () => { + await withRepo(async (dir, storage) => { + const file = path.join(dir, 'CLAUDE.md'); + await fs.writeFile(file, '# Guide\n\nHand-written.\n', 'utf-8'); + + await generateAIContextFiles(dir, storage, 'LfProject', stats); + + expect(await fs.readFile(file, 'utf-8')).not.toContain('\r'); + }); + }); + }); + it('preserves custom section when gitnexus:keep is present', async () => { const claudeMdPath = path.join(tmpDir, 'CLAUDE.md'); @@ -1270,36 +1472,47 @@ Indexed as **Idem** (1 symbols, 2 relationships, 3 execution flows). Custom. } }); - it('CRLF file with keep marker: stats line updates without corrupting content (#1508 review F5)', async () => { - // upsertGitNexusSection writes with .trim() + '\n', so the saved file uses LF - // line endings throughout — CRLF in the seed input is not preserved. - const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-keep-crlf-')); - try { - const claudePath = path.join(dir, 'CLAUDE.md'); - const crlfContent = - '# Project\r\n' + - '\r\n' + - '\r\n' + - '\r\n' + - 'Indexed as **CRLFOldName** (5 symbols, 6 relationships, 7 execution flows). Custom CRLF.\r\n' + - '\r\n'; - await fs.writeFile(claudePath, crlfContent, 'utf-8'); + it.each([false, true])( + 'CRLF keep block updates preserve endings with noStats=%s (#1508)', + async (noStats) => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-keep-crlf-')); + try { + const claudePath = path.join(dir, 'CLAUDE.md'); + const crlfContent = + '# Project\r\n' + + '\r\n' + + '\r\n' + + '\r\n' + + 'Indexed as **CRLFOldName** (5 symbols, 6 relationships, 7 execution flows). Custom CRLF.\r\n' + + '\r\n'; + await fs.writeFile(claudePath, crlfContent, 'utf-8'); - const stats = { nodes: 50, edges: 60, processes: 7 }; - await generateAIContextFiles(dir, path.join(dir, '.gitnexus'), 'CRLFTest', stats); + const stats = { nodes: 50, edges: 60, processes: 7 }; + const generated = await generateAIContextFiles( + dir, + path.join(dir, '.gitnexus'), + 'CRLFTest', + stats, + undefined, + { noStats, skipSkills: true }, + ); - const result = await fs.readFile(claudePath, 'utf-8'); - // Stats updated correctly - expect(result).toContain('50 symbols'); - expect(result).toContain('60 relationships'); - // Custom prose preserved - expect(result).toContain('Custom CRLF'); - // No verbose template injected - expect(result).not.toContain('## Always Do'); - } finally { - await fs.rm(dir, { recursive: true, force: true }); - } - }); + const result = await fs.readFile(claudePath, 'utf-8'); + const statsLine = noStats + ? 'Indexed as **CRLFTest**' + : 'Indexed as **CRLFTest** (50 symbols, 60 relationships, 7 execution flows)'; + expect(generated.files).toContain('CLAUDE.md (updated)'); + expect(result).toBe( + crlfContent.replace( + 'Indexed as **CRLFOldName** (5 symbols, 6 relationships, 7 execution flows)', + statsLine, + ), + ); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } + }, + ); it('noStats + keep marker: stats line drops the volatile counts (#1706)', async () => { // #1706: --no-stats must win in the keep-marker path too. A lean block