Технический английский для разработчиков · Глава 6 из 12

Глава 6. Commit messages: как писать историю проекта

Прогресс сохранится в этом браузере (войдите, чтобы синхронизировать).

Введение

Сообщение коммита — самый недооценённый текст в проекте. Его пишут в спешке, одной строкой, последним действием перед тем, как закрыть ноутбук. И именно он остаётся навсегда: код перепишут, задачу в трекере заархивируют, чат утонет, а строка fix bug будет висеть в истории и через пять лет.

Дело не в красоте. История проекта — это инструмент отладки, и работает он ровно настолько, насколько внятно написан. Когда через год в проде всплывает странное поведение, ты идёшь не в документацию. Ты идёшь в git log и git blame. И там либо есть ответ на вопрос «почему это сделали именно так», либо нет.

Однажды я потратил день на строку if order.currency == "RUB": amount = round(amount). Выглядела она как чей-то временный костыль. git blame дал хеш, git show — сообщение из одного слова: fix. Автор давно ушёл из компании. Округление я убрал — и через неделю получил расхождение с отчётом банка на копейки, которые не сходились месяцами: у эквайринга по рублёвым платежам не было дробной части. Одна строка в теле коммита сэкономила бы неделю и один откат в проде.

Дальше по порядку: зачем сообщения нужны технически, почему в них императив и как его проверять, какая у сообщения форма и что писать в теле. Потом Conventional Commits, её связь с SemVer, пары «плохо → хорошо» и нарезка работы на коммиты так, чтобы историю можно было читать как рассказ.

Зачем сообщения коммитов нужны технически

«Дисциплина ради дисциплины» — обычное возражение, и оно разбивается о четыре инструмента, которые читают именно текст сообщения.

Первый — git log как обзор. Однострочный вид показывает только заголовки. На них смотрят при подготовке релиза, при ревью ветки, при разборе инцидента — то есть всегда в спешке.

git log --oneline -8
git log --since="2 weeks ago" --author=tim --oneline
git log --grep="refund" --oneline

Внятные заголовки отвечают за десять секунд на вопрос «что вообще произошло с сервисом платежей за две недели». Заголовки wip, fix, update и final fix 2 не отвечают ни на что, и вместо десяти секунд получается вечер за чтением диффов.

Второй — git blame как археология. Видишь непонятную строку, спрашиваешь, кто и зачем её написал, получаешь хеш и читаешь сообщение. Если автора уже нет в компании, это единственный источник причины. Другого не существует — ни чата, ни памяти.

git blame -L 40,60 blog/services/popularity.py
git show 7d21b4a

Третий — git bisect как поиск виноватого. Механизм делит историю пополам, пока не найдёт изменение, которое сломало поведение. Здесь и выясняется настоящая цена гигантских коммитов. Bisect указал на «переписал модуль заказов» в две тысячи строк — ты не узнал ничего, поиск начинается заново. Bisect указал на аккуратные тридцать строк с внятным описанием — ты узнал всё за минуту.

git bisect start
git bisect bad HEAD
git bisect good v3.1.0
# git проверяет середину истории, ты запускаешь тест и отмечаешь результат
git bisect good   # или git bisect bad

Четвёртый — автогенерация release notes. Semantic-release и подобные инструменты собирают changelog прямо из сообщений. Твоя строка, написанная в 19:40 пятницы, дословно уезжает в документ, который прочитают чужие пользователи. Лучшего аргумента писать её для человека я не знаю.

Императив и правило проверки

В сообщениях коммитов принят императив — форма приказа, а не описания: Add, Fix, Remove, Refactor. Не Added (прошедшее), не Adds (третье лицо), не Adding (герундий).

Причина не в эстетике. Git сам пишет свои сообщения в императиве: Merge branch 'develop', Revert "Add refund endpoint". Коммит описывает не то, что ты сделал, а инструкцию, которую применяют к кодовой базе. Отсюда и правило проверки — подставь заголовок в шаблон:

If applied, this commit will _______________

If applied, this commit will add a refund endpoint          -> OK
If applied, this commit will fix the race in the webhook    -> OK
If applied, this commit will added a refund endpoint        -> неграмотно
If applied, this commit will adds a refund endpoint         -> неграмотно
If applied, this commit will fixing the race                -> неграмотно

Для русскоязычного автора это идеальный тест: правило помнить не надо, надо один раз произнести фразу целиком. Звучит грамматически — форма верная. Режет ухо — переписывай.

Заодно понятно, откуда берутся обе привычные ошибки. Added — калька с отчёта о проделанной работе («я добавил»). Но история проекта не отчёт, и то, что изменение сделано, в ней и так видно. Adds — калька с описания («этот коммит добавляет»); формально осмысленно, встречается в приличных проектах, но конвенция большинства и самого Git — императив.

Рабочий словарь глаголов для заголовков: add (добавить новое), remove / drop (убрать), fix (починить дефект), update (обновить существующее, чаще всего зависимости или тексты), refactor (изменить структуру без изменения поведения), rename, move, extract (выделить), introduce (ввести новую абстракцию), replace X with Y, allow / prevent (разрешить/запретить поведение), handle (обработать случай), bump (поднять версию зависимости), revert (откатить).

Форма: 50 и 72

У сообщения есть каноническая форма. Цифры в ней взялись не из любви к круглым числам, а из ширины терминала и того, как Git показывает текст.

Заголовок до ~50 символов, императив, с заглавной буквы, без точки

Тело через пустую строку. Строки переносятся примерно по 72 символа,
потому что Git добавляет отступ при выводе и длинные строки начинают
некрасиво ломаться в узком терминале.

Абзацы разделяются пустой строкой. Списки допустимы:

- пункт первый
- пункт второй

Refs: PAY-482

Теперь правила по одному. Пустая строка между заголовком и телом обязательна. Это не стилистика, а синтаксис: по ней Git отличает subject от body. Забудешь — и всё сообщение целиком станет заголовком, развалив вывод --oneline и заодно все автоматические инструменты, которые на него смотрят.

Пятьдесят символов — ориентир, а не закон. Число взято из ширины колонки в интерфейсах со списком коммитов; после ~72 символов GitHub режет заголовок многоточием. Но смысл ограничения глубже формата: если мысль не влезает в пятьдесят символов, дело обычно не в формулировке. Дело в том, что в коммите слишком много всего.

Заглавная первая буква, точки в конце нет. Точка не нужна: заголовок — это название, а не предложение, и каждый символ на счету. С заглавной буквой есть нюанс. В Conventional Commits (см. ниже) заголовок начинается с типа в нижнем регистре — fix(payments): ... — и правило про заглавную к первому слову не применяется. Главное, чтобы внутри проекта вариант был один.

Язык — английский. Не потому, что русский хуже, а потому, что история — такой же общий технический артефакт, как код: её читают инструменты, её цитируют в issue, она уезжает в changelog и в веб-интерфейсы. Хуже английского и хуже русского только смесь: по такой истории не работает поиск, потому что искать надо оба варианта каждого слова.

Что писать в теле

Заголовок отвечает на вопрос «что». Тело отвечает на вопросы, ответов на которые в диффе нет. Их четыре.

  • Причина. Почему это вообще понадобилось: какой симптом, какой отчёт об ошибке, какая жалоба. The webhook handler acknowledged every event, so a transient 502 silently lost the payment update.
  • Контекст. Что важно знать о среде: версии, ограничения провайдера, особенности продакшена. Stripe retries for up to three days, but only if we return a non-2xx.
  • Последствия. Что изменится для других: производительность, поведение API, необходимость миграции или перезапуска. Existing rows keep the old status casing until the backfill command is run.
  • Отвергнутые варианты. Самое ценное и самое редкое. Через год кто-то предложит «очевидное» решение, которое вы уже рассматривали. Storing the raw payload was considered, but it duplicates data Stripe already keeps and complicates deletion requests.

Тело нужно не всегда. Для docs: fix a typo in the installation guide оно только шум. Критерий простой: если у кого-то может возникнуть вопрос «почему так?», тело обязательно. Если изменение очевидно из диффа, хватит заголовка.

Оборотов для тела нужно немного, и они закрывают почти всё: This fixes ..., The root cause is ..., As a result, ..., This is a stopgap until ... (временное решение, пока не...), No behaviour change is expected, This requires a database migration, Follow-up work is tracked in .... Фразу про отсутствие изменений в поведении выучи первой: она экономит рецензенту рефакторинга полчаса недоверчивого чтения.

Conventional Commits

Свободная форма хороша для людей и бесполезна для машин. Conventional Commits добавляет к заголовку структуру, которую разбирает парсер:

<type>(<scope>): <subject>

[optional body]

[optional footer(s)]

Типы стандартизованы. Выучи их наизусть — их одиннадцать, и они одинаковы во всех проектах, где эта конвенция принята:

ТипКогда используется
featновая функциональность для пользователя
fixисправление дефекта
docsтолько документация
styleформатирование, пробелы, точки с запятой; без изменения смысла
refactorизменение структуры кода без нового поведения и без исправления бага
perfизменение ради производительности
testдобавление или правка тестов
buildсистема сборки, зависимости, упаковка
ciконфигурация пайплайнов
choreрутина, не попадающая в остальные категории
revertоткат предыдущего коммита

Scope в скобках — область изменения: модуль, приложение, компонент. В нашем проекте это blog, accounts, tutorials, gamification. Формально скоуп необязателен, но он резко повышает читаемость лога: fix(accounts): ... сразу говорит, куда смотреть, ещё до того, как ты дочитал строку. Subject после двоеточия — тот же императив, обычно со строчной буквы и без точки.

feat(orders): add partial refunds via POST /orders/{id}/refunds
fix(accounts): reset the login throttle counter on a successful login
perf(blog): cache the popularity score for 15 minutes
refactor(tutorials): extract chapter task syncing into a service
build(deps): bump django from 5.1.4 to 5.1.6
ci: run ruff format --check as a blocking step

Ломающие изменения помечают двумя способами, и они равноправны. Первый — восклицательный знак перед двоеточием. Второй — футер BREAKING CHANGE: с объяснением. Второй лучше: в нём помещается инструкция по миграции, а именно её будет искать тот, у кого всё сломалось. Чаще всего используют оба сразу.

feat(api)!: return order status in lowercase

The `status` field of `GET /orders/{id}` used to be returned in upper
case (`PAID`). It is now lower case (`paid`) so that it matches the
values accepted by the filter parameter.

BREAKING CHANGE: clients that compare `status` to `PAID` must be updated.
A compatibility shim is available behind the `legacy_status` query
parameter until version 4.0.

Refs: API-118
Closes #1913
Co-authored-by: Anna Petrova <anna@example.com>

Футеры — отдельная маленькая конвенция, и она полностью механическая. Refs: — ссылка на задачу без изменения статуса. Closes #123, Fixes #123, Resolves #123 — GitHub и GitLab закроют issue сами при слиянии в основную ветку. Co-authored-by: Имя <email> — соавторство при парном программировании, платформа засчитает вклад обоим. Reviewed-by: и Signed-off-by: живут в крупных проектах, в том числе в ядре Linux, где без второго патч просто не примут.

Как типы коммитов связаны с версией

Главная практическая ценность конвенции: из типов коммитов автоматически выводится следующий номер версии по правилам SemVer.

Что в коммитахКак меняется версияПример
только fix, perf, docs, chorePATCH3.2.0 → 3.2.1
есть хотя бы один featMINOR3.2.1 → 3.3.0
есть ! или BREAKING CHANGE:MAJOR3.3.0 → 4.0.0

Отсюда дисциплина: тип коммита — не украшение, а решение с последствиями. Пометил ломающее изменение как fix — инструмент выпустит патч-версию, ночью её подтянет автообновление у всех, кто доверился SemVer, и утром у них не встанет прод. Причём виноват формально ты, а разбираться будут они. Обратная ошибка дешевле, но тоже вредна: помечая мелкую починку как feat, ты раздуваешь minor-версии и обесцениваешь сигнал — через полгода на него перестанут смотреть.

Пары «плохо → хорошо»

ПлохоХорошоЧто было не так
fix bugfix(search): escape quotes in the raw query stringНе сказано ни где, ни какой баг. В логе такая строка неотличима от сотни таких же.
updatebuild(deps): bump psycopg from 3.1.18 to 3.2.1«Обновить» — что именно и с чего на что?
wip(не коммитить в основную ветку или переписать при слиянии)WIP — про состояние работы, а не про изменение. Такому коммиту не место в истории.
итоговый фиксfix(payments): retry the webhook on 5xx instead of dropping itРусский язык в истории плюс слово «итоговый», которое верно только в момент написания.
Added new field to user modelfeat(accounts): add a timezone field to the user profileПрошедшее время; «новое поле» — какое?
refactor.refactor(blog): split the Post model file into a packageТочка в конце и полное отсутствие содержания.
fix teststest(tutorials): freeze time so the streak test stops flaking«Починил тесты» не говорит, что было сломано — тест или код.

Отдельно про fix tests. За этой формулировкой прячется самое интересное. Тест мог падать, потому что нашёл настоящий баг, — тогда это fix, а не test, и в теле обязано быть написано, что именно было сломано в боевом коде. Заголовок fix tests в такой ситуации фактически скрывает от команды найденный дефект.

Один коммит — одна мысль

Хорошее сообщение к плохому коммиту не пишется. Если в изменении разом новая фича, переименование в трёх файлах и случайно уехавший туда автоформат, честного заголовка не существует. Любой будет либо враньём, либо списком через запятую.

Первый признак, что пора делить: в заголовке появилось and. feat(orders): add refunds and fix the status filter — это два коммита, и оба потеряются. Второй признак — трудно выбрать тип. Изменение одновременно feat и refactor? Значит, внутри перемешаны новое поведение и перестановка старого. Ревьюить такой дифф мучительно: непонятно, где смотреть внимательно, а где просто пробежать глазами, — и в итоге не смотрят нигде.

Приём один: коммитить частями, а не всё разом. Флаг -p (patch) позволяет отбирать отдельные куски внутри файла:

git add -p blog/services/popularity.py
git status
git commit -m "refactor(blog): extract weight lookup into a helper"

И ориентир для рефакторингов: то, что не меняет поведение, выноси в отдельный коммит перед содержательным. Тогда рецензент увидит маленький осмысленный дифф с логикой, а не сто строк переименований, среди которых спряталась одна важная строка. Именно так важные строки и проезжают ревью. В теле такого коммита уместно No behaviour change.

Кейс из реального проекта: пять коммитов для одной фичи

Задача: добавить в сервис заказов частичные возвраты. Два дня работы, пять коммитов. Сначала посмотрим на историю целиком, потом развернём самые содержательные.

$ git log --oneline --reverse feature/order-refunds
a1c9f0e feat(orders): add the Refund model and its migration
7d21b4a feat(orders): calculate refundable amount for an order
b93ee12 feat(api): expose POST /orders/{id}/refunds
2f0ac77 test(orders): cover partial, full and repeated refunds
5ce8d31 docs(orders): describe the refund flow in the API guide

История читается как оглавление: модель, расчёт, эндпоинт, тесты, документация. Рецензент идёт по ним по очереди вместо одного диффа на восемьсот строк — и на каждом шаге понимает, что именно проверяет. Развернём два самых содержательных.

feat(orders): calculate refundable amount for an order

An order can be refunded more than once, so the refundable amount is the
paid total minus the sum of all refunds that already succeeded. Refunds
in the `pending` state are counted as well: the provider may still
confirm them, and double-refunding is far worse than refusing one.

Shipping is excluded from the refundable amount because the carrier
charges us regardless of the return. Product-wise this matches what the
support team already does manually.

Refs: PAY-482

Обрати внимание, чего здесь нет: пересказа кода. Ни слова о том, какие функции добавлены и в каком файле — это и так видно в диффе. Зато есть три вещи, которых в диффе нет никогда: правило подсчёта, причина учитывать pending (риск вернуть деньги дважды) и исключение доставки с обоснованием от бизнеса. Именно эти три вопроса и зададут через полгода, когда кто-то захочет «упростить логику».

feat(api): expose POST /orders/{id}/refunds

The endpoint is modelled as creating a refund resource rather than as an
action on the order, so that a refund gets its own id, status and audit
trail. `GET /orders/{id}/refunds` will follow in a separate change.

The request requires an `Idempotency-Key` header: the support UI retries
on network errors, and without a key a retry would create a second
refund. Keys are stored for 24 hours.

Returns 409 when the requested amount exceeds the refundable amount.

Refs: PAY-482
Closes #1927

Тело здесь делает ещё одну работу — фиксирует отвергнутую альтернативу: сделано как создание ресурса, а не как действие над заказом, и сказано почему. Через год кто-нибудь предложит «очевидное» решение, и вместо часового спора в чате его ждёт готовый ответ из истории. Плюс два внешних факта названы прямо: требование идемпотентности и код ответа при превышении суммы.

Два «скучных» коммита тоже работают. test(orders): cover partial, full and repeated refunds перечисляет сценарии — сразу видно, что повторный возврат покрыт, файл открывать не нужно. А docs(orders): describe the refund flow in the API guide отделён от кода намеренно: правки документации часто черри-пикают в релизную ветку отдельно от логики, и склеенный с кодом коммит это ломает.

Теперь альтернатива, которая встречается сплошь и рядом: один коммит refunds на восемьсот строк, тело — пусто. Функционально результат идентичен, тесты те же, фича работает. А через год разница между двумя историями оказывается разницей между «разобрался за пятнадцать минут» и «переписал, потому что понять было невозможно».

Типичные ошибки

Прошедшее время и точка в конце

Две мелочи, которые выдают автора мгновенно. Added refund endpoint. — тут и калька с отчёта о работе («я добавил»), и лишняя точка. Прошедшее время берётся из естественной логики: человек описывает то, что уже сделал, это честно и по-человечески. Но история проекта — не дневник, а последовательность инструкций: если применить этот коммит, он добавит эндпоинт. Подставь заголовок в If applied, this commit will ... — неправильная форма зазвучит фальшиво с первого раза. Точка не нужна, потому что заголовок это название, а не предложение, и вдобавок она съедает символ из полусотни. А если точка появилась в середине subject, значит, у тебя там два предложения, и второе должно было уехать в тело.

fix без объяснения

Самая частая строка в плохих историях — fix bug, fix issue, hotfix, fix #123. Бесполезна она дважды. Во-первых, в логе она неотличима от десятков соседних, и git log --grep по ней ничего не найдёт: искать нечего. Во-вторых, номер задачи описание не заменяет. Трекер однажды сменят, задачу заархивируют, у внешнего читателя доступа к нему не будет вовсе, — а история останется. Минимально приличный вариант называет, что сломалось и где: fix(search): escape quotes in the raw query string. Хороший добавляет в тело симптом и корневую причину: пользователь получал 500 при поиске фразы в кавычках, потому что строка уходила в бэкенд без экранирования. Привычка, которая всё это чинит: описывай в заголовке устранённое поведение, а не факт починки.

Русский язык и смешанная история

Коммит поправил падение при импорте выглядит безобидно, пока история однородна. Беда начинается, когда половина сообщений на русском, а половина на английском. Поиск грепом перестаёт работать — надо помнить оба варианта каждого слова. Автогенерируемый changelog превращается в кашу. Внешнему участнику история недоступна целиком. Добавь сюда технические мелочи: кириллица до сих пор ломается в старых инструментах и веб-хуках с кривой кодировкой. Лечится это не вкусом каждого, а строчкой в CONTRIBUTING.md и договорённостью в команде. Если английский пока даётся тяжело, работай по шаблону: тип, скоуп, глагол из списка выше, объект. Даже безыскусное fix(blog): correct slug generation for cyrillic titles лучше любого русского варианта — просто потому, что оно единообразно с остальными.

Мусорные wip, asdf и «что» вместо «почему»

Коммиты wip, asdf, tmp, ..., ещё раз рождаются в локальной работе естественно. Это точки сохранения, а не описания изменений, и ничего плохого в них нет — пока они остаются локальными. Плохо, когда они уезжают в основную ветку. Сохраняйся у себя как угодно; перед слиянием историю причёсывают интерактивным перебазированием или сжимают в один осмысленный коммит. Отдельный подвид — final, final fix, final fix 2. Слово «итоговый» верно ровно до следующего коммита, и final fix 2 — исчерпывающее тому доказательство.

И последняя ошибка, самая содержательная: описывать «что» вместо «почему». Заголовок refactor(blog): move popularity calculation to services отвечает на «что», и это правильно — он для того и нужен. Но когда тело пересказывает то же самое другими словами («перенёс функцию из views в services и поправил импорты»), оно не добавляет ничего: дифф это уже показал, причём точнее. Ценность тела ровно в том, чего дифф показать не может — в причине, контексте, отвергнутых вариантах. Проверка перед отправкой занимает десять секунд: прочитай своё тело и спроси, узнал бы ты из него что-то, чего не видно в изменениях. Нет — переписывай или удаляй.

Итог

Сообщение коммита — технический английский в самой концентрированной форме: одна строка, которую прочитают сотни раз. Теперь ты знаешь, кому она нужна — log, blame, bisect, генераторам changelog — и почему форма здесь весит не меньше содержания. Умеешь проверять императив фразой If applied, this commit will..., держать заголовок в полусотне символов без точки, а тело — по 72. Знаешь, что в тело идут причина, контекст, последствия и отвергнутые варианты, а пересказ диффа не идёт. Разобрался в Conventional Commits: скоупы, !, футеры, связь типов с MAJOR, MINOR и PATCH. И главное — видишь связь между нарезкой работы и качеством истории. К плохо нарезанному коммиту хорошего сообщения не написать при всём желании.

Три главы складываются в одну картину. Сначала ты научился быстро понимать чужой английский: ошибки, логи, changelog, спецификации. Потом — писать английский, который читают ежедневно: имена, комментарии, докстринги. Теперь — тот, что останется в проекте навсегда. Дальше язык расширяется до людей: обсуждения в pull request, комментарии ревьюера, вежливое несогласие, разговор о сроках. Там к содержанию добавляется интонация, и цена неверного тона выше, чем цена ошибки в артикле.

Практика

Решено 0 из 3

Ответ на каждое задание можно получить, только написав и запустив программу. Глава засчитывается, когда решены все задания.

  1. Проставьте тип conventional-коммита по описанию изменения.

    Каждое слово из банка подходит ровно в один пропуск. Введите слова через запятую в порядке пропусков, регистр не важен.

    Банк слов: chore, ci, docs, feat, fix, perf, refactor, test

    1. ___ (1)(auth): add refresh token rotation — новая возможность.
    2. ___ (2)(api): reject expired tokens — исправление ошибки.
    3. ___ (3)(cache): extract the key builder — поведение то же, код чище.
    4. ___ (4)(readme): document the strict flag — правка документации.
    5. ___ (5)(db): add an index on the events table — ускорение запросов.
    6. ___ (6)(export): cover the empty dataset case — добавлены тесты.
    7. ___ (7)(deps): bump the http client — рутинное обновление зависимости.
    8. ___ (8): cache the pip directory in the workflow — правка пайплайна сборки.
  2. Сопоставьте заголовок коммита с тем, что с ним не так. Один заголовок из восьми написан правильно.

    Введите буквы через запятую в порядке пунктов 1, 2, 3… — например, e, a, …. Регистр не важен.

    #Выражение Значение
    1Added new endpoint for user exportaтри несвязанных изменения в одном коммите
    2fixbпрошедшее время вместо императива
    3Updated readme.cне сообщает ничего: что именно исправлено — неизвестно
    4wipdформа третьего лица вместо императива
    5Fixes the broken paginationeвсё в порядке: восклицательный знак помечает ломающее изменение
    6исправил пагинацию на поискеfмусорная отметка «в работе», которой не место в истории
    7feat: add export; fix login; bump depsgрусский язык в истории международного проекта
    8feat(export)!: switch to ndjsonhпрошедшее время и лишняя точка в конце
  3. Перепишите глагол в императиве — так, чтобы получилось «If applied, this commit will …». Ответ — один глагол в нижнем регистре, введите через запятую по порядку.

    1. Added the export endpoint
    2. Fixed the pagination
    3. Updated the readme
    4. Removes dead code
    5. Merged the feature branch
    6. Bumped the http client

Ответы проверяются на сервере, а решённые задания сохраняются в этом браузере. Войдите, чтобы прогресс синхронизировался между устройствами.

Содержание серии (12)