fix(cli): write the AGENTS.md/CLAUDE.md block in the file's own line ending (#3708)
Some checks failed
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Devcontainer Smoke / Config-transform unit tests (push) Has been cancelled
Devcontainer Smoke / Build devcontainer image (default) (push) Has been cancelled
Devcontainer Smoke / Build devcontainer image (ui) (push) Has been cancelled
Publish Development Images / publish (core) (push) Has been cancelled
Publish Development Images / publish (ui) (push) Has been cancelled

* 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 <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
This commit is contained in:
samuel mbabhazi 2026-10-25 07:02:16 +02:00 • committed by GitHub
parent 146f549886
commit 5c651091dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 284 additions and 35 deletions

View file

@ -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<void> {
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

View file

@ -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<void>) {
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.',
'',
'<!-- gitnexus:start -->',
'old block',
'<!-- gitnexus:end -->',
'',
'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('<!-- gitnexus:start -->\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('<!-- gitnexus:end -->');
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('<!-- gitnexus:start -->'));
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 = [
'<!-- gitnexus:start -->',
'<!-- gitnexus:keep -->',
line,
'| query | Find flows |',
'<!-- gitnexus:end -->',
'',
].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' +
'<!-- gitnexus:start -->\r\n' +
'<!-- gitnexus:keep -->\r\n' +
'Indexed as **CRLFOldName** (5 symbols, 6 relationships, 7 execution flows). Custom CRLF.\r\n' +
'<!-- gitnexus:end -->\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' +
'<!-- gitnexus:start -->\r\n' +
'<!-- gitnexus:keep -->\r\n' +
'Indexed as **CRLFOldName** (5 symbols, 6 relationships, 7 execution flows). Custom CRLF.\r\n' +
'<!-- gitnexus:end -->\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