Переводы строк CRLF и LF: что ломается на самом деле
Разница между CRLF и LF — один байт. LF — это одиночный \n (0x0A), которым заканчиваются строки в Linux и macOS. CRLF — два байта, \r\n (0x0D 0x0A), и так строки заканчиваются в Windows.
Теперь то, в чем ошибается большинство статей. Скрипт с переводами строк CRLF обычно не падает. Он выполняется, печатает ровно то, что от него ждали, и выходит с кодом 0. Ломается значение, которое лежит в переменной: присваивание сохранило хвостовой \r, и никто на это не пожаловался.
Замеры дали четыре сценария. Ниже они расставлены от самого незаметного к самому шумному:
- Тихий успех — вывод выглядит правильным, в переменной сидит невидимый
\r, код выхода 0. - Ошибка, которая ничего не меняет —
: command not foundна пустой строке, скрипт доходит до конца, код выхода 0. - Синтаксическая ошибка — ломаются
if,forи определения функций, код выхода 2. bad interpreter—\rпопал в строку shebang, код выхода 126.
Начинать стоит с file yourfile: одной строки вывода хватает, чтобы понять, CRLF перед вами или LF.
Всё, что описано ниже, измерено на macOS (Darwin arm64) с bash 3.2.57(1)-release, zsh 5.9, dash, git 2.55.0, node v26.7.0 и Python 3.14.6 и перепроверено на Linux через
docker run --rm bash:5, то есть на GNU bash 5.3.15(1)-release (aarch64-unknown-linux-musl).
1. CRLF, LF и CR: какие байты за этим стоят
CRLF расшифровывается как carriage return line feed, и это буквально два склеенных символа:
| Название | Escape | Байт |
|---|---|---|
| Возврат каретки | \r | 0x0D |
| Перевод строки | \n | 0x0A |
| CRLF | \r\n | 0x0D 0x0A |
Что пишет каждая платформа:
| Платформа | Перевод строки |
|---|---|
| Windows | CRLF (\r\n) |
| Linux, современные macOS | LF (\n) |
| Mac Classic, OS 9 и раньше | одиночный CR (\r) |
Названия механические. На телетайпе возврат каретки отводил печатающую головку обратно к левому полю, а перевод строки прокручивал бумагу на одну строку вверх. Windows сохранила оба движения в виде двух байт; Unix решила, что хватит и одного. Одиночный CR до сих пор встречается в старых выгрузках, и файл, где все переводы строк такие, для большинства инструментов Unix выглядит одной гигантской строкой.
Одна команда показывает, какой вариант перед вами
file читает байты и называет терминатор:
$ file d1.txt
d1.txt: ASCII text, with CRLF line terminators
$ file d2.txt
d2.txt: ASCII text ← no suffix means pure LF
$ file d3.txt
d3.txt: ASCII text, with CRLF, LF line terminators ← mixed, file lists both
Третий случай стоит запомнить. Смешанные переводы строк означают, что в файл по очереди писали два инструмента с разными настройками: редактор сохранил LF, а скрипт потом дописал CRLF. Слияние двух версий дает тот же результат. od -c показывает разрыв побайтово:
$ od -c d3.txt
0000000 a \r \n b \n
0000005
Переводы строк живут на уровне байтов, по соседству с кодировкой символов; руководство по кодировкам UTF-8 и UTF-16 разбирает, что происходит уровнем выше.
2. Четыре сценария сбоя: от тихого успеха до кода 126
Один и тот же файл с \r\n — пять разных исходов в зависимости от того, что стоит в строке:
| Содержимое скрипта | Что происходит на самом деле | Код |
|---|---|---|
echo hello и другие простые команды | Тихий успех, вывод корректный | 0 |
присваивание X=abc | Тихий успех, но значение заканчивается на \r | 0 |
Пустые строки, продолжения через \ в конце строки | : command not found, скрипт доходит до конца | 0 |
if/fi, for/do/done, f() { | syntax error near unexpected token | 2 |
Строка shebang с \r | bad interpreter: No such file or directory | 126 |
Первые две строки таблицы и есть повод для этой статьи. Утверждение «CRLF вызывает command not found» повторяют повсюду, но происходит совсем другое: простые команды вообще не жалуются.
Самый опасный случай — тихий успех
$ printf 'echo hello\r\necho world\r\n' > win.sh && bash win.sh
hello
world
[exit=0]
Две строки на входе, две на выходе, код выхода 0. Отлаживать нечего, и в логе нечего искать через grep. Дайте тому же скрипту что-нибудь сравнить — и картина меняется:
$ printf 'V=1.2.3\r\ntest "$V" = "1.2.3" && echo MATCH || echo NO-MATCH\r\n' > v.sh
$ bash v.sh
NO-MATCH
[exit=0]
В $V лежит 1.2.3\r, а не 1.2.3. На macOS и Linux результат одинаковый. Проверка версии, if [ "$ENV" = "prod" ], любое сравнение со значением из файла — всё это тихо уходит не в ту ветку с кодом выхода 0. Это самая трудноуловимая ошибка с переводами строк в CI: сборка зеленая, лог чистый.
Ошибка, которая ничего не останавливает
Пустая строка внутри файла с CRLF — это строка из одного \r, и оболочка честно принимает ее за команду. Команда не находится, печатается сообщение, скрипт переходит к следующей строке и доходит до конца с кодом 0. Точная формулировка зависит от оболочки, о ней речь в следующем разделе.
Продолжения строк через обратный слэш ломаются так же. \r оказывается между обратным слэшем и переводом строки, поэтому продолжение перестает быть продолжением, и следующая строка выполняется сама по себе.
Синтаксические ошибки и почему fi — это не fi
c_if.sh: line 5: syntax error: unexpected end of file
[exit=2]
Linux bash 5.3.15 говорит о том же файле больше:
i.sh: line 5: syntax error: unexpected end of file from `if' command on line 2
Циклы и определения функций падают не на последней строке, а на первой:
c_for.sh: line 1: syntax error near unexpected token `do'
c_for.sh: line 1: `for i in 1 2; do'
c_func.sh: line 1: syntax error near unexpected token `{'
c_func.sh: line 1: `f() {'
Механизм один на весь класс ошибок. Bash читает закрывающее слово как fi\r, а fi\r — не ключевое слово fi. Блок if так и не закрывается, bash читает дальше, пока файл не кончится, и в итоге ругается на последнюю строку вместо сломанной.
bad interpreter и код выхода 126
$ ./s.sh
bash: ./s.sh: /bin/bash^M: bad interpreter: No such file or directory
[exit=126]
macOS и Linux печатают один и тот же текст и оба выходят с кодом 126. Прочитайте путь в сообщении: /bin/bash^M. Ядро считает путем к интерпретатору всё, что стоит после #! до перевода строки, и \r попадает внутрь. Такого файла не существует, поэтому exec падает раньше, чем выполнится хоть одна строка скрипта.
Код 126 покрывает еще и случай «найден, но не исполняемый», так что не всякий отказ стартовать упирается в переводы строк: права доступа к файлам дают отказы из того же семейства. Отличает их ^M внутри пути.
Почему \r никогда не видно
Читать вывод бесполезно: разглядеть или выделить этот байт не получится. Его приходится вытаскивать принудительно:
$ bash d.sh # script: HOST=example.com / echo "connecting to $HOST:8080"
connecting to example.com:8080 ← what you get
$ bash d.sh | cat -v
connecting to example.com^M:8080^M ← what is actually there
$ bash d.sh | od -c
0000000 c o n n e c t i n g t o e x
0000020 a m p l e . c o m \r : 8 0 8 0 \r
0000040 \n
Два байта \r, невидимые в обычном выводе, и оба внутри строки, которая вот-вот пойдет в дело как имя хоста. Иногда байт уезжает в аргумент, и вину берет на себя посторонний инструмент:
$ bash v.sh # the script pipes through: ... | head -2
head: illegal line count -- 2\r
head ведет себя правильно. Ему передали 2\r. Любое сообщение об ошибке, внутри которого затесался лишний \r, — это та же самая поломка под чужим именем.
3. Почему сообщение об ошибке не похоже на то, что пишут в интернете
Один файл — printf 'echo a\r\n\r\necho b\r\n', где строка 2 пустая и содержит только \r, — запущен под четырьмя оболочками:
| Оболочка | Версия | Точное сообщение |
|---|---|---|
| bash (в комплекте с macOS) | 3.2.57(1)-release | s_blank.sh: line 2: : command not found |
| bash (массовые дистрибутивы Linux) | 5.3.15(1)-release | t.sh: line 2: $'\r': command not found |
| zsh | 5.9 | s_blank.sh:2: command not found: ^M |
| dash | — | s_blank.sh: 2: : not found |
Почти каждый результат поиска цитирует вторую строку. Bash 4 и 5 печатают непечатаемые символы через ANSI-C quoting, и возврат каретки превращается в $'\r'. Идущий с macOS bash 3.2 так не умеет, поэтому на месте символа остается пустота, а видно только двоеточие. zsh печатает ^M. dash вовсе опускает слово «command».
Одна поломка — четыре сообщения. Если поиск по точному тексту ошибки ничего не дал, причина именно в этом. Голое : command not found — та же поломка, что и растиражированный вариант с $'\r'.
4. Git: что core.autocrlf на самом деле кладёт в репозиторий
Большинство объяснений про git autocrlf останавливаются на определениях. На практике важнее другое: что оказывается в commit и что получают коллеги при checkout? Измерено через git cat-file -p HEAD:f.txt для сохраненного blob и rm f.txt && git checkout -- f.txt для рабочей копии:
core.autocrlf | Исходный файл | Blob в репозитории | Рабочая копия после checkout |
|---|---|---|---|
true | CRLF | LF | CRLF |
true | LF | LF | CRLF |
input | CRLF | LF | LF |
input | LF | LF | LF |
false | CRLF | CRLF | CRLF |
false | LF | LF | LF |
Что из нее следует:
trueиinputодинаково гарантируют LF в репозитории. Разница только в checkout:trueконвертирует обратно в CRLF,inputоставляет файл как есть.- Только
falseкладет CRLF в commit. Когда кто-то спрашивает, откуда в истории взялись возвраты каретки, ответ в этой строке. - Вторая строка неочевидна: при
trueфайл, лежавший на диске как LF, после checkout возвращается уже CRLF.
Git предупреждает о перезаписи заранее, в одной из двух формулировок:
warning: in the working copy of 'f.txt', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'f.txt', CRLF will be replaced by LF the next time Git touches it
«Я ничего не менял, а Git пишет, что файл изменился целиком»
Снова вторая строка. При git config core.autocrlf true checkout переписывает LF-файлы в CRLF прямо в рабочем каталоге. Каждая строка теперь отличается от blob на один байт, поэтому git diff подсвечивает их все, а в pull request файл, которого никто не трогал, выглядит переписанным целиком. Это сделал фильтр checkout.
Зеркальная ситуация дает тот же шум: коллега на false кладет в репозиторий CRLF, вы сидите на input, и в вашем diff всплывают файлы, которые вы никогда не открывали.
5. .gitattributes — ответ на уровне команды
core.autocrlf — настройка на одной машине, невидимая для всех остальных. .gitattributes — файл внутри репозитория, поэтому он путешествует с каждым клоном. Когда они расходятся, побеждают атрибуты. Проверены все четыре формы:
.gitattributes | core.autocrlf | Источник | Blob | После checkout | Победитель |
|---|---|---|---|---|---|
* text=auto | false | CRLF | LF | LF | атрибуты |
* text eol=crlf | input | LF | LF | CRLF | атрибуты |
* -text | true | CRLF | CRLF | CRLF | атрибуты |
* text eol=lf | true | CRLF | LF | LF | атрибуты |
Третью строку стоит запомнить: -text полностью отключает конвертацию и всё равно обыгрывает core.autocrlf=true. Именно так защищают файлы, байты которых обязаны остаться нетронутыми.
Готовый .gitattributes
* text=auto
*.sh text eol=lf
*.bash text eol=lf
Makefile text eol=lf
*.bat text eol=crlf
*.cmd text eol=crlf
*.ps1 text eol=crlf
*.png -text
*.jpg -text
*.pdf -text
*.zip -text
text=auto приводит к LF в репозитории всё, что Git считает текстом. Явные строки с eol=lf покрывают файлы, обязанные быть LF независимо от того, кто их написал: .sh с CRLF — это заранее заготовленный код выхода 126. Скриптам Windows eol=crlf ставят по зеркальной причине, а бинарным шаблонам — -text, чтобы не конвертировалось вообще ничего.
core.safecrlf и core.eol
Две настройки, которые попадаются рядом с core.autocrlf и делают совсем другое:
core.safecrlf— сторож, а не конвертер. Если преобразование необратимо (смешанный файл, при нормализации которого теряется информация), значениеtrueотклоняет операцию, аwarnпропускает ее с предупреждением. На сами байты в commit настройка не влияет: она лишь не дает молча выполнить операцию с потерями.core.eolвыбирает, какой перевод строки Git пишет в рабочем каталоге для файлов, помеченных какtext, когдаcore.autocrlfравенfalse. Значения:lf,crlfиnative.core.autocrlfперекрывает эту настройку, поэтомуcore.eolна машине с включенным autocrlf выглядит так, будто ничего не сделал.
Правка .gitattributes не исправляет то, что уже в репозитории
Атрибуты срабатывают в момент, когда Git пишет или читает файл, поэтому уже лежащее в репозитории содержимое не изменится, пока что-нибудь его не перезапишет. Такой проход делают руками:
$ git add --renormalize .
$ git commit -m "Normalize line endings"
Ждите один гигантский diff, в этом и смысл. Делайте это в отдельной ветке, вливайте одним commit и предупредите команду заранее.
6. Преобразование CRLF в LF и обратно
Четыре способа, и каждый проверен на Darwin — \r убирают все:
| Команда | Результат |
|---|---|
tr -d '\r' < f > f.out | работает |
perl -pi -e 's/\r\n/\n/g' f | работает |
sed -i '' -e 's/\r$//' f (форма BSD) | работает |
sed -i -e 's/\r$//' f (форма GNU, запущенная на macOS) | работает, но оставляет мусорный файл |
Ловушка GNU sed -i на macOS
BSD-версия sed требует, чтобы за -i шел суффикс резервной копии. Скопируйте руководство для Linux дословно — и BSD sed проглотит -e как этот суффикс. Правка при этом всё равно произойдет, а вместе с ней вот что:
a5.txt
a5.txt-e
a5.txt-e — резервная копия: sed прочитал -e как запрошенный суффикс. Правильная форма для macOS передает явную пустую строку: sed -i '' -e 's/\r$//' f. Если в репозитории попадаются файлы с хвостом -e, значит, кто-то запускал однострочник для GNU на Mac.
В macOS нет dos2unix
command -v dos2unix на чистой macOS не возвращает ничего; бинарник приезжает из brew install dos2unix. Поэтому самый копируемый ответ в интернете не работает на той самой машине, за которой сидит изрядная часть разработчиков. tr -d '\r' не требует установки и делает ту же работу.
У unix2dos в обратную сторону та же проблема, а заменой служит sed -e 's/$/\r/'.
Как только файл снова стал чистым LF, его опять можно считать списком строк. Это важно для всего, что сравнивает строки целиком: сортировка строк текста и удаление повторяющихся строк читают value\r и value как две разные строки, поэтому один случайный возврат каретки тихо сводит на нет дедупликацию.
В редакторе
VS Code показывает перевод строки текущего файла как CRLF или LF в строке состояния справа внизу; щелчок по этой надписи переключает файл. Настройка files.eol задает значение по умолчанию для новых файлов, а прописанная на уровне рабочей области, она держит всю команду в одних настройках. В других редакторах те же два переключателя называются иначе. Упускают обычно вот что: индикатор конкретного файла и значение по умолчанию — разные вещи, и правка одного не трогает другое.
7. Переводы строк в коде
Один файл, line1\r\nline2\r\n, прочитанный семью разными способами:
| Способ чтения | Что получается | \r |
|---|---|---|
Node fs.readFileSync(f,"utf8") | "line1\r\nline2\r\n" | сохранен |
Node, то же значение + .split("\n") | ["line1\r","line2\r",""] | сохранен в каждой строке |
Node readline с crlfDelay:Infinity | ["line1","line2"] | убран |
Python open(f), режим по умолчанию | 'line1\nline2\n' | преобразован |
Python open(f).readlines() | ['line1\n','line2\n'] | преобразован |
Python open(f, newline="") | 'line1\r\nline2\r\n' | сохранен |
Python open(f,"rb") | b'line1\r\nline2\r\n' | сохранен |
Эта таблица закрывает знакомый баг-репорт: «в Python работает, в Node ломается». Текстовый режим Python по умолчанию применяет универсальные переводы строк и превращает \r\n в \n еще до того, как значение дойдет до кода. Node отдает байты как есть. Оба поведения корректны, но на одном и том же файле результат получается разный.
rstrip("\n") оставляет \r на месте
original: 'line1\r\n' | rstrip("\n"): 'line1\r' | strip(): 'line1'
rstrip("\n") удаляет ровно те символы, которые перечислены, а \r в этом списке не было. Дальше значение не совпадает с тем, с чем должно, — вот и весь ответ на жалобу «я всё обрезал, а равенства всё равно нет». Помогает strip() или rstrip() без аргумента: уйдут все хвостовые пробельные символы, включая возврат каретки.
Безопасное разбиение на строки на обеих платформах
В JavaScript разбивайте по шаблону, который терпит оба варианта: text.split(/\r?\n/). В Python либо оставайтесь в текстовом режиме по умолчанию и дайте универсальным переводам строк сделать свое дело, либо вызывайте splitlines(), который справляется и с \r\n, и с \n, и с одиночным \r.
Запись — вторая половина задачи. Node пишет те байты, которые ему дали, поэтому собирайте строки через \n, а решение о том, что ляжет на диск, оставьте за .gitattributes. Python в open(path, "w") переводит \n в платформенный вариант, если не передать newline="" — тот самый флаг, который по этой же причине требуют модули записи CSV.
Возврат каретки в конце строки и метка порядка байтов в начале файла — ошибки одной категории с разных концов: невидимый байт, который переживает копирование и ломает проверку на равенство. Про второй конец — руководство по устранению ошибок с UTF-8 BOM.
8. Где ещё переводы строк доставляют неприятности
CSV и Excel. RFC 4180 задает CRLF как разделитель записей, и это одно из немногих мест, где CRLF — норма, а не дефект. Парсеры, написанные под вход только с LF, оставляют \r в последнем поле каждой строки, так что если значения после конвертации CSV в JSON выглядят правильно, но сравниваются неправильно, проверяйте сначала этот байт.
Docker. Файл .sh с CRLF, скопированный в образ, — это четвертый сценарий сбоя. COPY переносит байты как есть, shebang сохраняет свой \r, и контейнер выходит с кодом 126. Одна строка *.sh text eol=lf в .gitattributes предотвращает весь класс проблем.
Diff и pull request. Файл, помеченный как полностью изменившийся, без единой видимой правки — это механизм из раздела 4, доехавший до code review. Сравнение двух версий через инструмент сравнения текста подтверждает это за секунды, а руководство по сравнению текста разбирает, как читать результат.
Смешанные файлы. ASCII text, with CRLF, LF line terminators означает двух писавших с разными настройками. Нормализуйте файл целиком, а не те строки, которые случайно попали под правку, иначе следующий diff будет таким же шумным.
FAQ
Чем CRLF отличается от LF?
CRLF — два байта, \r\n (0x0D 0x0A). LF — один байт, \n (0x0A). Windows пишет CRLF, Linux и macOS пишут LF, и оба варианта отмечают конец строки. В редакторе текст выглядит одинаково; разница проявляется только в байтах, сравнениях строк и diff.
В скрипте переводы строк CRLF. Почему нет ошибки?
Потому что простые команды переживают лишний байт. echo hello с хвостовым \r выполняется и выходит с кодом 0. Ошибки появляются только там, где это важно парсеру: пустая строка, ключевое слово вроде fi или shebang. Опасный случай — присваивания: они срабатывают успешно и сохраняют \r в переменной.
Что означает $'\r': command not found и почему этого сообщения не видно?
Это значит, что оболочка попыталась выполнить строку, состоящую из одного возврата каретки. Bash 4 и 5 показывают этот символ через ANSI-C quoting, получается $'\r'. Bash 3.2 с macOS не выводит между двоеточиями ничего, а zsh вместо символа ставит ^M. Одна поломка — три разных сообщения.
Каким должен быть core.autocrlf: true, input или false?
На Linux и macOS используйте input, на Windows — true, а лучше обоих вариантов — .gitattributes. По измерениям true и input одинаково хранят LF в репозитории, и только false пропускает CRLF в commit. Кроме того, true при checkout переписывает LF-файлы в CRLF в вашей рабочей копии.
Кто победит, если .gitattributes и core.autocrlf противоречат друг другу?
Побеждает .gitattributes. Проверены все четыре формы — * text=auto, * text eol=crlf, * -text и * text eol=lf — и каждая перекрыла локальное значение core.autocrlf. В этом и аргумент в пользу атрибутов: они лежат в репозитории и действуют на всех, тогда как core.autocrlf — настройка на конкретной машине, которую не видно и невозможно проконтролировать.
Как проверить, что в файле — CRLF или LF?
Запустите file yourfile. Для CRLF печатается ASCII text, with CRLF line terminators, для чистого LF — ASCII text вообще без суффикса, а для смешанного файла — with CRLF, LF line terminators. Для уверенности на уровне байтов посмотрите od -c и поищите \r перед каждым \n.
Когда CRLF действительно нужен?
Когда его требует формат или протокол. RFC 4180 определяет CRLF как разделитель записей для CSV, а заголовки HTTP и SMTP делают то же самое на проводе. Файлам Windows batch и PowerShell с CRLF тоже безопаснее. Всё остальное — исходный код, shell-скрипты, конфигурация — LF.