CRLF Line Endings on Windows — Why grep Says Your File Has None, Why Multi-Line Replacements Silently Fail, and a Node Pattern That Handles Both
Git Bash tools on Windows read files in text mode and hide carriage returns, so grep reports zero CRLFs in a CRLF file and string replacements that span lines never match. How to detect line endings reliably with Node, and a read-normalise-replace-restore pattern that keeps files consistent.
Half a day of our lives went to this one. A multi-line search-and-replace in a Node script refused to match text that was visibly identical in the editor. The cause was carriage returns that every tool we used to inspect the file was quietly hiding.
The symptom
On a Windows project where files are saved with CRLF endings:
grep -c $'\r' app.js # prints 0
cat -A app.js | head # shows $ at line ends, no ^M
Both say "no carriage returns." Yet a Node replacement like this never matches:
s = s.replace("const a = 1;\nconst b = 2;", "…");
Why
Git Bash's grep, cat, sed and friends on Windows open files in text mode and translate \r\n to \n before you see anything. The file on disk still has \r\n, so your \n-only pattern is one byte short on every line boundary.
Detect line endings with Node, not the shell
const fs = require('fs');
const s = fs.readFileSync(file, 'utf8');
console.log('crlf', (s.match(/\r\n/g) || []).length, 'lf-only', (s.match(/[^\r]\n/g) || []).length);
Trust this over anything a Git Bash tool prints.
The pattern that works on both kinds of file
Read, normalise to \n in memory, do your replacement, then restore the original convention when writing:
const raw = fs.readFileSync(file, 'utf8');
const crlf = /\r\n/.test(raw);
let s = raw.replace(/\r\n/g, '\n');
s = s.replace('old\ntext', 'new\ntext');
if (crlf) s = s.replace(/\n/g, '\r\n');
fs.writeFileSync(file, s);
Skipping the last step is how projects end up with files that contain both endings, which then confuses diffs and some tools.
Keep the repo consistent
Decide on one convention per project and enforce it with .gitattributes, for example * text=auto eol=crlf for a Windows-first project or eol=lf for a Unix-first one. Editors then stop fighting Git, and scripts can assume one ending after the normalisation step above.
Heredocs and escaping in Bash on Windows
A related trap: writing a Node script through a Bash heredoc can mangle backslashes, so a regex like /\\/g arrives as /\/g and throws a syntax error. For anything with escapes, write the script to a file with an editor or a file-writing tool rather than through the shell.
Common mistakes
- Believing
grep -c $'\r'. It cannot see them. - Writing back with
\nafter reading a CRLF file. Now the file is mixed. - Passing regex through a shell heredoc. Escapes get eaten.
Summary
On Windows, check line endings with Node, normalise in memory, replace, and restore the original ending on write. Pin the convention in .gitattributes so the question stops coming up.