Я привык оставлять в конце Markdown-файлов видимую в редакторе пустую строку. Это было скорее визуальным соглашением: курсор можно поставить ниже последнего абзаца, и документ выглядит завершённым.

Однажды я заметил, что Neovim считает файл корректно завершённым, хотя отдельной строки для курсора после текста нет. Завершающий newline присутствовал, но редактор не показывал его как ещё одну строку.

Это стало поводом проверить, чем на уровне байтов отличаются text, text\n и text\n\n. Разница в один невидимый байт обычно не влияет на работу программы, но заметно проявляется в git diff и даже в результате git blame.

Newline — это не пустая строка

Возьмём три файла, которые отличаются только концом:

text
text\n
text\n\n

В первом файле после text ничего нет. Последняя строка не завершена.

Во втором после текста находится один байт LF, который обычно записывают как \n. Он завершает строку, но не создаёт после неё ещё одну пустую строку.

В третьем файле находятся два последовательных LF. Первый завершает строку с текстом, второй завершает следующую, уже пустую строку.

Эту разницу легко увидеть не в редакторе, а в байтах:

printf 'text' | od -An -t x1
printf 'text\n' | od -An -t x1
printf 'text\n\n' | od -An -t x1

Результат:

74 65 78 74
74 65 78 74 0a
74 65 78 74 0a 0a

Последнее значение 0a — это LF.

В терминах POSIX строка заканчивается символом newline. Фрагмент текста в конце файла, после которого newline отсутствует, называется незавершённой строкой — incomplete line.

Поэтому нормальный конец текстового файла выглядит так:

text\n

А не так:

text\n\n

Второй вариант тоже допустим, но в нём уже есть отдельная пустая строка.

Почему Neovim не показывает newline отдельной строкой

Neovim работает с буфером как с набором строк. Завершающий символ <EOL> не отображается как самостоятельная редактируемая строка: редактор отдельно хранит информацию о том, заканчивается ли последняя строка файла символом конца строки.

За это отвечают параметры endofline и fixendofline. По умолчанию fixendofline включён, поэтому при сохранении Neovim восстанавливает отсутствующий <EOL> в конце обычного текстового файла.

Именно поэтому такой файл:

Последняя строка\n

в Neovim выглядит примерно так:

Последняя строка
~
~

Курсор нельзя поставить между последней строкой и первым символом ~, потому что отдельной пустой строки в файле нет. Есть только символ, завершающий строку с текстом.

Чтобы получить доступную курсору пустую строку, нужно записать ещё один newline:

Последняя строка\n\n

Но это уже другое содержимое файла.

Почему Git пишет No newline at end of file

Создадим файл без завершающего newline:

printf 'alpha\nbeta' > example.txt

После этого закоммитим его, добавим новую строку и заодно нормальный newline в конец:

printf 'alpha\nbeta\ngamma\n' > example.txt
git diff

Git покажет такой diff:

 alpha
-beta
\ No newline at end of file
+beta
+gamma

На первый взгляд кажется, что Git зачем-то удалил и заново добавил строку beta, хотя её текст не изменился.

Но для Git старое и новое содержимое этой строки различается:

Было: beta
Стало: beta\n

В старом файле после beta сразу заканчивались данные. В новом после beta появился байт 0a.

Git формирует строчный diff, поэтому ему недостаточно просто показать добавление gamma. Сначала он должен отметить, что прежняя последняя строка была незавершённой, а затем показать новую завершённую строку beta.

Сообщение:

\ No newline at end of file

не означает, что Git отказался работать с файлом. Такой файл можно добавить в индекс и закоммитить. Эта строка нужна, чтобы diff точно описывал различие между двумя наборами байтов.

Один байт может изменить git blame

У такого diff есть не только визуальное последствие.

Допустим, первый автор создал файл без завершающего newline:

alpha
beta

Затем второй автор добавил gamma и нормальный newline:

alpha
beta
gamma

До изменения обе строки принадлежат первому автору:

First Author   alpha
First Author   beta

После изменения обычный git blame показывает:

First Author    alpha
Second Author   beta
Second Author   gamma

Текст beta остался прежним, но её байтовое представление изменилось. Поэтому Git считает, что второй автор заменил незавершённую строку beta на завершённую строку beta\n.

На реальной разработке это редко становится серьёзной проблемой. Но лишние изменения авторства создают шум при расследовании истории файла, особенно если newline массово добавляется форматтером сразу во многих местах.

Требует ли Git завершающий newline

Сам Git не запрещает файлы без newline. Он способен хранить и коммитить такое содержимое, а предупреждающая пометка появляется только при построении diff.

Однако начиная с Git 2.53 отсутствие newline можно включить в набор проверяемых whitespace-ошибок. Для этого появилась отдельная категория incomplete-line. По умолчанию она выключена.

Включить проверку в репозитории можно так:

git config \
  core.whitespace \
  trailing-space,space-before-tab,incomplete-line

После этого команда:

git diff --check

сможет обнаруживать добавленные или изменённые файлы с незавершённой последней строкой.

Это полезно именно как защита репозитория, а не как попытка заставить Git хранить файлы определённым образом.

Какое правило использовать на практике

Для обычных исходников, конфигураций и Markdown-файлов разумное правило выглядит так:

Последняя строка\n

В файле должен быть один завершающий newline, но не обязательная пустая строка после последнего содержимого.

На уровне редакторов это можно зафиксировать в .editorconfig:

[*]
end_of_line = lf
insert_final_newline = true

Свойство insert_final_newline означает именно наличие newline после последней строки. Оно не требует создавать дополнительную пустую строку.

В проектах с Markdown ту же ошибку проверяет правило markdownlint MD047, которое называется single-trailing-newline.

Дополнительные пустые строки в конце — уже отдельная история. Git относит добавленные пустые строки в конце файла к ошибке blank-at-eof, причём эта проверка включена по умолчанию.

Например, изменение:

 Последний абзац.
+

будет отмечено командой:

git diff --check

как новая пустая строка в конце файла.

А что насчёт Markdown

Я долгое время оставлял после последнего абзаца Markdown-файла видимую в редакторе пустую строку. В Neovim это выглядело аккуратно: курсор можно было поставить ниже текста, и файл казался визуально завершённым.

Технически такой файл заканчивается не одним, а двумя newline:

Последний абзац.\n\n

Markdown обычно отрендерится точно так же, поэтому явной проблемы я не замечал. Но это не специальное правило Markdown и не общепринятый способ обозначить корректный конец файла.

Я считал, что файл должен заканчиваться видимой в редакторе пустой строкой. Оказалось, это не техническое требование, а моя редакторская привычка. Завершающий newline корректно заканчивает последнюю строку, но не создаёт новую пустую.

Полезное напоминание: даже привычное поведение инструментов иногда стоит проверять на уровне фактического содержимого файла.