Четыре команды помогут понять, является ли история ваших коммитов документацией или просто шумом. На выполнение всех четырёх уйдёт меньше минуты. Я запустил их на собственном репозитории, и результаты оказались неутешительными. Ни один из 24 коммитов не содержал тела, 11 тем превысили рекомендуемые 72 символа, а самая длинная растянулась на 2550 символов. По сути, это было объяснение на пять абзацев, втиснутое в поле, которое Git считает однострочной сводкой. Этот пост - и есть тот самый аудит. В нём я покажу сами команды, результаты их выполнения на моём репозитории, разберу, какие правила были нарушены, честно оценю Conventional Commits и собственный префикс `000-`, а также поделюсь шаблоном коммита, на который в итоге перешёл.
TL;DR
- Четыре команды Git проверяют любой репозиторий меньше чем за минуту. Они покажут, сколько коммитов содержат тело, насколько длина тем соответствует правилу 50/72, какая тема оказалась самой длинной и с какого глагола обычно начинаются ваши сообщения.
- Главное правило - не ограничение в 50 символов, а разделение темы и тела пустой строкой. Тема кратко сообщает, что изменилось, а тело объясняет почему. Почти все представления, которые показывает Git, отображают только тему, поэтому именно она должна оставаться короткой и понятной.
- Префикс с нумерацией, например 000-, действительно может быть полезным как быстрый идентификатор. Но это всего лишь ярлык, а не структура сообщения. Ни один номер не способен объяснить, зачем появился этот коммит и какую задачу он решает.
Аудит истории коммитов за четыре команды
Четыре команды расскажут почти всё о состоянии истории репозитория: сколько коммитов несут тело, как темы ложатся на соглашение 50/72, насколько плоха худшая из них и с какого слова начинаются ваши темы. Запустите их в том репозитории, за который вам меньше всего не стыдно.
1. Сколько коммитов вообще имеют тело?
echo "$(git log -z --format='%b' | tr -d '\n' | tr '\0' '\n' | grep -c .) of $(git rev-list --count HEAD)"2. Как темы ложатся на 50/72?
git log --format='%s' | awk '{n=length}
n<=50 {a++} n>50 && n<=72 {b++} n>72 {c++}
END {printf " <=50 : %d\n 51-72: %d\n >72 : %d\n", a, b, c}'3. Насколько плоха худшая?
git log --format='%s' | awk '{ if (length > m) m = length } END { print m " characters" }'4. С какого слова начинаются ваши темы?
git log --format='%s' | awk '{print $1}' | sort | uniq -c | sort -rn | head -5Вот что эти четыре команды напечатали для репозитория этого сайта на глубине 24 коммитов:
0 of 24
<=50 : 13
51-72: 0
>72 : 11
2550 characters
4 Updated
3 Integrated
2 Made
2 Fixed
2 CreatedЧетыре вывода, по возрастанию неприятности.
Ни один коммит не имеет тела. Ни короткого, ни плохого. Ноль. Каждое объяснение, которое я когда-либо писал об этом проекте, живёт в строке темы - в поле, которое git отводит под сводку.
В середине пусто. Тринадцать тем укладываются в 50 символов, одиннадцать перевалили за 72. Диапазон от 51 до 72, куда обычно попадает продуманная сводка, пуст. Этот провал и есть признак: пустая середина означает, что никто не правит сообщение, подгоняя его под размер. Каждый коммит - либо заглушка, написанная за две секунды, либо эссе, написанное вместо тела.
Худшая тема - 2550 символов. Для масштаба: git log --oneline печатает по одной строке на коммит. Двадцать четыре коммита должны занимать двадцать четыре строки. В терминале шириной 120 колонок мой занимает 129, и на один коммит приходится 22 из них:
git log --oneline | awk '{ rows += int(length/120)+1 } END { print rows " rows for " NR " commits" }'
# 129 rows for 24 commitsПредставление «одна строка на коммит», которое уезжает за пределы экрана, перестало быть представлением.
Три наклонения глагола на 24 коммита. Девятнадцать тем в прошедшем времени (Updated, Fixed, Integrated), две в повелительном (Create projects page) и две в настоящем третьего лица (Replaces, Moves). Я не выбирал три; я трижды сползал и ни разу этого не заметил, потому что ничто в моём рабочем процессе никогда не показывало мне этот список.
Последний пункт - и есть честная первопричина всего остального. Ничего из этого не произошло потому, что я не знал правил. Это произошло потому, что я ни разу не посмотрел на собственную историю как на список. Аудит занимает сорок секунд, а я не запускал его двадцать четыре коммита подряд. Это тот же самый провал, с которым я столкнулся, когда журнал работы агента на 407 строк перестал читаться: запись, которую никто не перечитывает, тихо перестаёт быть правдой.
Ещё две команды, которые стоит запустить
Если в репозитории больше одного участника, добавьте эти:
# темы, которые ничего не говорят - классические глаголы-затычки, поодиночке или почти
git log --format='%s' | grep -icE '^(wip|update|fix|misc|stuff|changes|minor|cleanup)s?\.?$'
# кто пишет тела, а кто нет
git log --format='%an' | sort | uniq -c | sort -rnГлавное правило - пустая строка.
Сообщение коммита в git состоит из двух частей, разделённых пустой строкой: темы, которая кратко излагает изменение, и тела, которое его объясняет. Всё остальное - цель в 50 символов, лимит в 72, повелительное наклонение - существует, чтобы обслуживать это разделение. Сделайте разделение правильно, и остальное в основном приложится.
Причина, по которой так мало коммитов имеют тело, - не лень. Дело в том, что git commit -m "..." физически не может его написать. Флаг -m принимает сообщение и кладёт его целиком в тему, так что привычка к -m - это привычка никогда не писать тело. Документация git commit говорит об этом прямо, и выходов два:
# открыть редактор: тема, пустая строка, затем тело
git commit
# или остаться в одной команде: второй -m начинает тело
git commit -m "Open content images full size in an overlay" \
-m "Galleries rendered at layout width, so a dashboard screenshot was unreadable on a phone."Почему тема несёт такой вес
Почти каждое представление, которое даёт вам git, показывает тему и отбрасывает тело. git log --oneline, git shortlog, список задач в git rebase -i, однострочные сводки в git log --graph, список коммитов на GitHub и GitLab. Тело написано для одного человека, который запустит git show; тема написана навсегда и для всех остальных.
Отсюда и берутся 50 и 72. Пару обычно возводят к заметке Тима Поупа о сообщениях коммитов 2008 года, а до большинства людей она дошла через руководство Криса Бимса о том, как писать сообщение коммита: целиться в 50 символов, считать 72 потолком. За обоими числами стоит конкретная причина.
- 72 - там, где сдаётся GitHub. Более длинные заголовки обрезаются в списке коммитов, а хвост прячется за многоточием, по которому надо кликнуть.
- 72 ещё и помещается в терминал.
git logотступает от сообщения ровно на четыре пробела. Запуститеgit log -1 | cat -Aи посчитайте их. Строка в 72 символа вместе с этим отступом всё ещё укладывается в 80 колонок с запасом. - 50 - не лимит, а вынуждающая функция. Если сводка не влезает в 50 символов, это обычно сигнал, что коммит делает два дела сразу.
Повелительное наклонение и фраза, которая закрывает спор
Пишите тему как команду: Add, Fix, Move, Remove. Не Added и не Adds. Спор закрывает одна фраза:
If applied, this commit will _____.
If applied, this commit will add the contact form читается верно. If applied, this commit will added the contact form - нет. Git и сам пишет свои сообщения так - Merge branch 'main', Revert "Add the contact form" - поэтому темы в повелительном наклонении ровно ложатся рядом с теми, что git генерирует за вас.
Девятнадцать из моих двадцати четырёх тем эту проверку не проходят.
Объясняйте зачем, а не что
Diff и так покажет любому, что изменилось. Он не покажет, зачем, что вы попробовали сначала и что намеренно не тронули. Это работа тела, и это та часть, которую ни один инструмент потом не восстановит.
Тело, которое стоит писать, обычно отвечает на три вещи: как было до, что этот коммит с этим делает и от чего вы отказались. Если коммит чинит баг, тело должно сказать, что этот баг реально делал с пользователем - «парсер предполагал хотя бы один токен, поэтому пустой файл ронял сборку» - потому что через полтора года эта фраза будет единственным, что отделяет следующего читателя от повторного вывода всей проблемы с нуля.
Conventional Commits: что дают и чего стоят
Conventional Commits - самое распространённое соглашение о коммитах, и версия 1.0.0 спецификации умещается в один шаблон:
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]Обязательных типов всего два: fix для исправления и feat для новой возможности, они ложатся на уровни PATCH и MINOR семантического versioning. Спецификация разрешает и другие, а расхожий набор - это build, chore, ci, docs, style, refactor, perf, test и revert. Ломающее изменение помечается либо футером BREAKING CHANGE:, либо восклицательным знаком перед двоеточием, как в feat(api)!: drop the v1 endpoint. Футеры следуют формату трейлеров git - токен через дефис, разделитель, значение - так что Reviewed-by: Ada и Refs: #219 разбираются так же, как собственные трейлеры git.
На практике:
fix(auth): reject expired refresh tokens
The refresh endpoint compared the expiry to the token issue time
instead of the current time, so an expired token minted a fresh
access token indefinitely.
Refs: #412
BREAKING CHANGE: refresh responses now return 401 instead of 200
with an error body.Что вы на самом деле получаете
Смысл формата в том, что он машиночитаем. Это даёт три конкретные вещи:
- Changelog, который вы не пишете. Инструменты группируют коммиты по типу и собирают release notes из самой истории.
- Повышение версии, которое вы не решаете.
fixподнимает патч,featподнимает минор, ломающее изменение поднимает мажор - автоматически. - История, которую можно фильтровать.
git log --grep '^feat'- это реальный вопрос с реальным ответом.
Чего это стоит
Честный противовес в том, что все три выгоды требуют инструмента, который потребляет формат. Если ничто не генерирует ваш changelog и ничто не поднимает версию, Conventional Commits - это церемония именования без отдачи. Три сценария провала встречаются постоянно:
chore:превращается в свалку. Всё, что не очевидно фича и не очевидно фикс, отправляется туда, и тип перестаёт нести информацию.- Скоупы, придуманные на ходу, - это шум. Скоуп полезен только тогда, когда берётся из фиксированного согласованного списка. Свободные скоупы дают
feat(ui),feat(frontend)иfeat(components)для одной и той же папки. - Тип - это не причина.
fix(auth):сообщает категорию. Он всё равно не говорит, почему баг вообще возник. Это делает тело, а префикс может незаметно создать ощущение, что объяснение уже дано.
Как выбрать
Conventional Commits | Порядковый префикс | Без соглашения | |
|---|---|---|---|
Автоматический changelog | Да | Нет | Нет |
Автоматическое повышение версии | Да | Нет | Нет |
Фильтрация по намерению | Да, по типу | Нет | Нет |
Переносимый ярлык вне репозитория | Слабо | Сильно | Нет |
Стоимость настройки | Инструменты и хук | Нулевая | Нулевая |
Что будет, если игнорировать | Громко, CI не пропустит | Тихо, появится пропуск | Незаметно |
Для большинства решает первая строка. Берите Conventional Commits, когда что-то ниже по течению их потребляет. Иначе выберите соглашение, которого вы будете держаться, а усилия потратьте на тело.
Соглашение 000 -, честная оценка
Моё собственное соглашение - это порядковый номер с ведущими нулями: 000 - Initialized app, затем 001, 002, сегодня уже 023. Оно не входит ни в один стандарт. Его нет ни в Conventional Commits, ни в собственном SubmittingPatches от git, и я нигде не нашёл его описанным как именованное соглашение. Это домашний стиль, и стоит ясно понимать, что домашний стиль может, а чего не может.
Что оно мне действительно даёт
Ярлык, который переживает переписывание истории. Короткие хеши не переживают. Сделайте rebase - и все хеши изменятся; сожмите два коммита - и один из них перестанет существовать. 013 по-прежнему означает то же изменение в сообщении клиенту, в описании задачи и в заметке, написанной три месяца назад. Эта переносимость за пределы репозитория - самый сильный аргумент, и он не маленький.
Пропуск, который видно. Если номера не хватает, значит что-то было сжато, выброшено или так и не отправлено. Отсутствующий хеш невидим; отсутствующий номер - дыра в последовательности.
Способ пометить разрезанное изменение. Два моих коммита - это 012 и 013, одна и та же интеграция Sanity, разрезанная пополам, причём вторая тема начинается с Part 2:. Именно эта интеграция сегодня отрисовывает кейсы на этом сайте. Номера сделали связку очевидной с первого взгляда. У Conventional Commits эквивалента нет - два коммита feat(sanity): выглядят в списке несвязанными.
Немного трения в нужном месте. Выбрать следующий номер - значит посмотреть на предыдущий, а это значит посмотреть в лог. Это больше внимания, чем большинство уделяет своей истории.
Чего оно мне не даёт
Это указатель, а не структура. Он сортирует и называет; он не может удержать причину. И это не нейтральное ограничение - я думаю, что именно оно прямо привело к результату аудита в начале поста.
Нумерованный префикс превращает строку темы в поле формы. Есть номер, есть разделитель и есть место, куда идёт описание. У этого места нет дна, поэтому когда мне нужно было сказать пять абзацев о работе с оверлеем для картинок, я вписал в это место все пять. Коммит вышел на 2550 символов и читается в git log --oneline как двадцать две строки прозы с номером впереди.
Conventional Commits, к слову, меня бы от этого не спасли. feat(images): ... и следом 2500 символов сломаны ровно так же. Соглашение в начале темы никогда не было проблемой. Проблемой была отсутствующая пустая строка.
Так что номер остаётся. Он заслужил своё место, и менять его на feat: значило бы потерять ярлык и купить автоматизацию, которой я не пользуюсь. Меняется то, что идёт после него.
Шаблон, на который я перехожу
Исправление сохраняет номер, ограничивает тему 72 символами и переносит каждое объяснение под пустую строку. Вот худший коммит в моей истории, до и после.
До - все 2550 символов в строке темы:
016 - Made content images on the project and blog detail routes open full size in an overlay, because a screenshot of a dashboard rendered at card width is unreadable on a phone, and the previous hand-rolled overlay had no focus trap, so it was replaced with Base UI's modal Dialog which supplies Escape, focus movement, the trap and focus return, and the thumbnail stays at the call site so it is still server-rendered and present in the initial HTML, and images inside a Link are excluded because a button inside an anchor is invalid...После:
016 - Open content images full size in an overlay
Case study galleries and in-post figures rendered at their layout
width, so a screenshot of a dashboard was unreadable on a phone.
Wraps content images in ImageZoom, which resolves its labels on the
server and hands them to a Base UI modal Dialog. That supplies
Escape, focus movement, the focus trap and focus return, which the
previous hand-rolled overlay did not have.
The thumbnail stays at the call site and stays server-rendered, so
it is still in the initial HTML.
Excludes images inside a Link: a button inside an anchor is invalid
HTML, and listing covers are teasers rather than content.Информация та же. Первая версия нечитаема в любом инструменте, который показывает список коммитов. Вторая - это сводка в 48 символов, которую можно просмотреть глазами, а всё рассуждение лежит в одном нажатии клавиши в git show.
Как сделать это поведением по умолчанию
Три изменения, и по-настоящему важно только первое.
Перестаньте тянуться к -m. Укажите git шаблон и запускайте голый git commit, чтобы редактор открывался с уже готовой формой:
git config --global commit.template ~/.gitmessage ~/.gitmessage, где каждая строка - комментарий, который git вырежет перед сохранением:
# NNN - Subject in the imperative, 72 characters maximum
#
# Before: what the situation was
# Now: what this commit does about it
# Not: what was deliberately left alone
#
# Refs: #issueПусть длинные ловит хук. Хук commit-msg - это восемь строк, и он падает закрытым, в чём весь смысл: соглашение, которое ничто не принуждает, - это предпочтение. Документация git по хукам перечисляет остальные:
#!/bin/sh
# .git/hooks/commit-msg - reject an over-long subject
subject=$(head -n 1 "$1")
if [ ${#subject} -gt 72 ]; then
echo "Subject is ${#subject} characters. The limit is 72." >&2
echo "Move the explanation below a blank line." >&2
exit 1
fiСделайте его исполняемым через chmod +x .git/hooks/commit-msg. Хуки по умолчанию не коммитятся, так что либо держите его в репозитории и направьте на эту папку core.hooksPath, либо будьте готовы переустанавливать его после каждого клона.
Почините то, что ещё можно починить. Самое свежее сообщение - в одной команде:
git commit --amendБолее старым нужен интерактивный rebase, где pick меняется на reword в нужных строках:
git rebase -i HEAD~5Оба переписывают историю и меняют hash, поэтому в общей ветке это требует согласования до force-push. Свои двадцать четыре я переписывать не буду - аудит полезнее в том виде, в каком он есть. Запустить его на кодовой базе, которую вы унаследовали, а не написали, - это другая задача, и я берусь за неё в рамках технического консалтинга.
Что вы получаете
- Историю, которую можно читать списком. Двадцать четыре коммита должны занимать двадцать четыре строки, и после этого они занимают.
- Причины, которые сохраняются. Тело переживает pull-request, чат и саму платформу, где лежит код.
- Сводку, которая помещается везде. Меньше 72 символов - значит без многоточия на GitHub и без переносов в терминале.
- Правило, которое соблюдает себя само. Хук роняет коммит. Рекомендация в README - нет.
Ничего из этого не потребовало нового инструмента, и дело тут не в нумерации против feat:. И то и другое - префиксы, а префикс никогда не был недостающей деталью. Недоставало одной пустой строки и абзаца под ней.
Число показывает, где находится коммит. Только тело объясняет, зачем он существует.
Октай Искендеров
Дизайнер и фулстек-разработчик, веду klauzzdcode - студию одного человека в Баку. На фрилансе с 2023 года: выкатываю продукты от Figma до деплоя и записываю то, что переживает контакт с продакшеном.