Ты открыл pull request. Прошла неделя. Ревьюер написал "посмотрю позже". Потом написал 40 комментариев. Ты переписал половину, конфликты разрешал три раза подряд (одни и те же), история коммитов превратилась в "fix", "fix2", "ну теперь точно", "ревью", "ревью2". Релиз-менеджер вручную собирает changelog по этой каше и ставит версию пальцем в небо.
Так выглядит code review без культуры. И ни Git, ни ревьюер тут ни при чём - проблема в процессе. Хороший PR проходит ревью с первого-второго раза не потому, что автор гений, а потому что он выстроил поток: маленький diff, чистая история, понятное описание, дисциплинированный цикл ответа на правки. А поверх этого - Conventional Commits, формат сообщений, из которого робот сам собирает changelog и считает версию по SemVer.
Этот урок про то, как делать pull request так, чтобы его хотелось мёржить, и как оформление коммитов превращается из бюрократии в автоматизацию. Механику rebase --autosquash мы дожмём в уроке 24, валидацию формата через хук commit-msg - в уроке 41, здесь же выстроим картину целиком и пройдём её руками.

Анатомия PR, который проходит code review git с первого раза
Ревьюер - человек с ограниченным вниманием. Его пропускная способность падает нелинейно: diff на 50 строк он вычитает построчно и найдёт настоящие баги, diff на 1500 строк он пролистает и поставит "LGTM", потому что мозг сдаётся. Это доказанный эффект, а не лень. Значит, первое правило: маленький и сфокусированный diff. Один PR - одна логическая задача. Рефакторинг отдельно, фича отдельно, переименование файла отдельно. Если в одном PR ты и переименовал переменную в 200 местах, и поменял логику - настоящее изменение утонет в шуме.
Второе - осмысленная история. Ревьюер часто читает не итоговый diff, а коммиты по очереди, как главы. Если коммиты чистые ("добавил парсер", "подключил парсер к роутеру", "тесты на парсер") - ревью идёт по нарастающей и логика видна. Чистую историю делают через интерактивный rebase перед публикацией: склеиваешь "fix typo" в нужный коммит, переставляешь, переписываешь сообщения.
Код: Выделить всё
git log --oneline feature/parser ^main
a1b2c3d добавил json-парсер
e4f5a6b подключил парсер к роутеру
b7c8d9e тесты на парсер
Третье - описание. В теле PR отвечай на три вопроса: что меняем, зачем (какую проблему/тикет закрываем), как проверить. Не "исправил баг", а "форма теряла данные при двойном сабмите - блокирую кнопку до ответа сервера, проверять на /checkout с медленной сетью". Ревьюеру не надо реконструировать твой замысел.
Четвёртое и недооценённое - саморевью. Перед тем как звать людей, открой собственный diff и прочитай его глазами врага. Закомментированный код, отладочный console.log, случайно закоммиченный .env, TODO без тикета - всё это ты обязан выловить сам. Команда git diff --staged перед коммитом и просмотр финального diff в интерфейсе PR экономят половину раундов ревью.
Код: Выделить всё
git diff --stat origin/main...HEAD
src/parser/json.ts | 84 +++++++++++++++++++
src/router/index.ts | 6 +-
tests/parser.test.ts | 41 ++++++++++
3 files changed, 128 insertions(+), 3 deletions(-)
Цикл ответа на ревью: git fixup autosquash на практике
Ревью пришло. Десять комментариев. Дальше есть два пути, и один из них портит всё.
Плохой путь: правишь и делаешь git commit --amend поверх старого коммита либо переписываешь историю на лету. Ревьюер открывает PR и не понимает, что изменилось с прошлого раунда - старые коммиты переписаны, его прежние комментарии повисли на исчезнувших строках. Он перечитывает весь PR заново. Ты только что удвоил ему работу.
Хороший путь: на каждую правку - отдельный коммит, адресно привязанный к тому коммиту, который ты чинишь. Для этого есть commit --fixup:
Код: Выделить всё
git add src/parser/json.ts
git commit --fixup a1b2c3d
Когда ревью одобрено и пора мёржить - схлопываешь fixup-ы обратно в их родительские коммиты одной командой:
Код: Выделить всё
git rebase -i --autosquash main
Код: Выделить всё
pick a1b2c3d добавил json-парсер
fixup 9f8e7d6 fixup! добавил json-парсер
pick e4f5a6b подключил парсер к роутеру
fixup 1a2b3c4 fixup! подключил парсер к роутеру
rerere: не разрешай один конфликт дважды
Длинный PR, который ты несколько раз ребейзишь на свежий main, упирается в одну и ту же боль: при каждом ребейзе всплывает один и тот же конфликт в одном и том же месте, и ты разрешаешь его руками снова и снова. Git умеет это запомнить. Включи:
Код: Выделить всё
git config --global rerere.enabled true
Код: Выделить всё
Resolved 'src/router/index.ts' using previous resolution.
Conventional Commits: формат, из которого рождаются changelog и версия
Теперь про оформление коммитов. Conventional Commits - это соглашение о структуре заголовка коммита:
Код: Выделить всё
<тип>[область]: краткое описание
[тело]
[футер]
Код: Выделить всё
feat(parser): поддержка вложенных массивов в json
fix(router): не терять query при редиректе
docs: описать переменные окружения в readme
refactor(auth): вынести проверку токена в middleware
Код: Выделить всё
feat(api)!: убрать поле legacy_id из ответа
BREAKING CHANGE: клиенты, читавшие legacy_id, должны перейти на id.
Автоматизация релизов: semantic release и release please
Поверх Conventional Commits работают два популярных инструмента, и важно не путать их модели.
semantic release делает релиз сразу при пуше в релизную ветку: анализирует новые коммиты, вычисляет версию, проставляет git-тег, генерирует changelog и публикует пакет (npm, и т.д.) - всё в одном прогоне CI, без участия человека. Подходит командам, которым нужен непрерывный автоматический выпуск: смёржил feat - через минуту вышла новая минорная версия.
release please (от Google) работает иначе и часто удобнее для команд, которым нужен контроль момента релиза. Он не релизит сразу, а держит постоянно обновляемый Release PR: копит коммиты, в реальном времени пересчитывает будущую версию и changelog прямо в этом PR. Когда вы готовы выпустить - просто мёржите Release PR, и только тогда ставится тег и создаётся релиз. То есть релиз становится осознанным кликом, а не побочным эффектом мёржа фичи.
Общий принцип у обоих один: версия и changelog выводятся из истории коммитов, а не пишутся руками. Поэтому мусорный коммит "fix2" без типа - это не косметика, он реально ломает расчёт версии: инструмент его проигнорирует, и патч может не выйти, либо changelog недосчитается строки. Формат коммита - это вход в конвейер релиза. Отсюда прямое следствие: формат надо валидировать на входе.
Валидация формата: хук commit-msg (анонс урока 41)
Дисциплина, которую держат силой воли, рано или поздно протекает. Поэтому проверку формата автоматизируют локально - через клиентский хук commit-msg. Это скрипт в .git/hooks/commit-msg, которому Git передаёт путь к файлу с сообщением коммита; если скрипт вернёт ненулевой код, коммит отклоняется.
Код: Выделить всё
#!/bin/sh
# .git/hooks/commit-msg
pattern='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore)(\(.+\))?!?: .+'
if ! grep -qE "$pattern" "$1"; then
echo "Сообщение не по Conventional Commits. Пример: feat(api): ..."
exit 1
fi
Код: Выделить всё
$ git commit -m "поправил тут"
Сообщение не по Conventional Commits. Пример: feat(api): ...
Этика и тон ревью
Технику разобрали, но code review - это в первую очередь общение между людьми. Несколько правил, которые отличают команду, где ревью двигает дело, от команды, где его боятся.
- Критикуй код, а не человека. "Эта функция делает три вещи, давай разобьём" вместо "ты опять намешал".
- Различай уровни замечаний. Блокирующее ("тут утечка памяти") и вкусовое ("я бы назвал иначе") - это разные веса. Помечай некритичное как nit, чтобы автор понимал, что мёрж это не держит.
- Объясняй "почему", а не только "что". "Перенеси проверку выше, иначе ниже разыменуем null" учит, а "перенеси выше" - приказывает.
- Хвали хорошее. Если решение красивое - скажи об этом. Ревью не обязано состоять только из претензий.
- Автор: не защищайся рефлекторно. Комментарий к коду - не к тебе. Если не согласен - аргументируй спокойно, остальное правь.
Типичные грабли
- Гигантский PR "за неделю работы" - его никто не вычитает по-настоящему. Режь на серию.
- commit --amend во время ревью вместо fixup - теряются комментарии ревьюера и контекст раунда.
- Забыл --autosquash перед мёржем - в main улетают сырые "fixup!" коммиты. Лечится rebase.autosquash = true.
- rerere включил, но первый раз разрешил конфликт неверно - теперь он тиражирует ошибку. Чисти запись через git rerere forget <путь>.
- Коммиты не по формату при включённом semantic release/release please - версия считается неверно или релиз не выходит. Спасает хук commit-msg.
- BREAKING CHANGE написан в теле с опечаткой (BREAKING-CHANGE, нижний регистр) - инструмент не распознает мажор. Футер должен быть ровно "BREAKING CHANGE:" или тип с "!".
- Создай песочницу: git init lab-review && cd lab-review, переименуй ветку в main: git branch -m main.
- Включи помощников: git config rerere.enabled true и git config rebase.autosquash true.
- Сделай 2 коммита по формату: положи файл, git commit -m "feat(core): добавить сумматор"; правь, git commit -m "test(core): тест на сумматор".
- Имитируй ревью: внеси правку в первый коммит, затем git add . && git commit --fixup <хеш_первого_коммита>. Посмотри git log --oneline - увидишь строку "fixup!".
- Схлопни: git rebase -i --autosquash main. Сохрани todo не редактируя - fixup уже стоит под целью. Проверь git log: правка вмёржена в feat-коммит, "fixup!" исчез.
- Поставь хук: создай .git/hooks/commit-msg из примера выше, chmod +x на него. Попробуй git commit --allow-empty -m "просто так" - должно отклонить. Потом git commit --allow-empty -m "chore: проверка хука" - пройдёт.
- Почему во время ревью лучше делать commit --fixup, а не commit --amend?
- Что именно делает флаг --autosquash при интерактивном rebase и какие коммиты он ищет?
- Как из типа коммита (feat/fix и пометка ломающего изменения) выводится номер версии по SemVer?
- Чем модель release please отличается от semantic release по моменту выпуска релиза?
PR проходит ревью с первого раза не магией, а процессом: маленький сфокусированный diff, чистая история, внятное описание, саморевью. Отвечаешь на правки честным циклом fixup -> autosquash, а rerere избавляет от повторного разрешения тех же конфликтов. Conventional Commits превращают сообщения коммитов в машиночитаемый сигнал, из которого semantic release или release please сами считают версию по SemVer и собирают changelog, а хук commit-msg стоит на входе и не пускает мусор. Глубже rebase - в уроке 24, глубже хуки - в уроке 41. А этику ревью держи всегда: ты ревьюишь код, а не человека.