Совместная работа: код-ревью в масштабе, соглашения о коммитах, релизы
Девять статей трека мы разбирали Git как систему: объекты, DAG, ссылки, слияния, восстановление. Всё это — механика одного репозитория. Но настоящая цена ошибок проявляется там, где репозиторий один, а людей — тридцать, триста или тридцать тысяч. Тогда выясняется неприятное: Git не содержит ни одной команды для код-ревью, апрува, релиза или «процесса». git review не существует.
Всё, что вы называете «нашим процессом», — надстройка над четырьмя примитивами: коммит-DAG, merge-base, объекты-теги и текст сообщения коммита. Pull request, апрувы, CODEOWNERS, статус-чеки, merge queue — это код GitHub, GitLab, Gerrit или Phabricator, который в итоге сводится к «посчитать diff двух деревьев» и «двинуть ссылку, если разрешено». Понимание этой границы решает большинство командных споров. «Git заставляет нас делать squash» — неправда, так настроена платформа. «Git плохо мержит» — Git слил текст ровно как обещал, сломалась семантика. «Перейдём на Conventional Commits, и релизы станут проще» — станут ровно настолько, насколько вы автоматизируете чтение этих сообщений.
Эта статья — про надстройку: что видит ревьюер и почему, чем размер изменения важнее любых регламентов, зачем нужна очередь слияний, как сообщение коммита превращается в версию и релизные заметки, и почему тег — это объект, а не подпись на заборе. Одна рука при этом всё время на модели данных из «Внутри Git».
Базовое введение — статья «Git» в треке development, практическое оформление изменения — «Как создать хороший Pull Request». Здесь не про то, как оформить PR, а про то, что происходит, когда таких PR тридцать в день.
1. Что из «совместной работы» реально живёт в Git
Первое упражнение — разложить привычные вещи по двум колонкам: что переносится с любой платформы на любую другую вместе с .git, а что вы потеряете при миграции.
| Возможность | Где живёт | Что физически стоит за ней |
|---|---|---|
| Кто автор изменения | Git | поля author/committer в объекте коммита |
| Соавторство, ревьюер, DCO | Git | трейлеры в теле сообщения: Co-authored-by, Reviewed-by, Signed-off-by |
| «Что именно на ревью» | Git | git diff $(git merge-base main HEAD) HEAD |
| Что изменилось после force-push | Git | reflog и git range-diff (см. «Восстановление») |
| Релизная точка | Git | объект tag + git describe |
| Подлинность автора | Git | подпись в заголовке gpgsig (см. «Хуки и автоматизация») |
| Заметки к коммиту постфактум | Git | git notes в refs/notes/* |
| Pull request, апрувы, комментарии | Платформа | своя БД, к Git отношения не имеет |
| CODEOWNERS, обязательные ревьюеры | Платформа | файл читает платформа, Git его не знает |
| Branch protection, статус-чеки, merge queue | Платформа | правила на git-receive-pack и оркестратор поверх CI |
Вывод, который стоит сделать сразу: всё ценное для археологии кода должно оказаться в Git, а не в комментариях к PR. Обсуждение в интерфейсе GitHub переживёт компанию ровно до следующей миграции; причина решения, записанная в теле коммита, переживёт всё, потому что она внутри хеша. Это главный практический аргумент за содержательные сообщения — не эстетика, а сохранность знания.
2. Ревью — это функция от DAG, а не «список файлов»
Самый частый источник недоумения на ревью: «почему у меня в diff чужие правки?», «почему после мержа main в ветку изменённых файлов стало больше?». Ответ целиком в модели данных: diff вычисляется между двумя деревьями, вопрос лишь в том, какие два взять.
# ветка отошла от main три коммита назад, main с тех пор ушёл на два коммита вперёд
$ git merge-base main feature/cart-limits
9c2ae10f3d7b4a1c8e05f6b2d4a90c3f1e7b8d52
# три точки: сравниваем базу и вершину ветки — ровно работа автора
$ git diff --stat main...feature/cart-limits
internal/cart/limits.go | 84 +++++++++++++++++++++++++
internal/cart/limits_test.go | 61 +++++++++++++++++
2 files changed, 145 insertions(+)
# две точки: сравниваем вершину main и вершину ветки — плюс «откат» чужой работы
$ git diff --stat main..feature/cart-limits
internal/cart/limits.go | 84 ++++++++++++++++++
internal/cart/limits_test.go | 61 ++++++++++++
internal/billing/invoice.go | 37 ---------
pkg/telemetry/spans.go | 12 ----
4 files changed, 145 insertions(+), 49 deletions(-)
Во втором выводе появились файлы, которые автор PR не открывал. Это не «поломанный diff», а честный ответ на вопрос «чем дерево feature отличается от дерева main»: в feature нет правок invoice.go, которые в main уже есть. Все нормальные платформы показывают «Files changed» как трёхточечный diff. Отсюда важное следствие: пока PR висит на ревью, merge-base не меняется — и сам diff не меняется тоже, зато результат слияния и вердикт CI меняются с каждым чужим мержем в main. Отдельная ловушка: у .. в git log и в git diff разный смысл.
# log: две точки — разность МНОЖЕСТВ достижимых коммитов, ровно то, что нужно
$ git log --oneline --no-merges main..pr/1421
3f7a91c добавить лимит позиций в корзине
b28d40e вынести конфиг лимитов в отдельную структуру
$ git diff main...pr/1421 # а для diff нужный вариант — с тремя точками
$ git log -p --reverse main..pr/1421 # читать по коммиту, в порядке написания
Повторное ревью после force-push
Автор поправил замечания и сделал git rebase -i + force-push. Платформа честно скажет «force-pushed», но не всегда покажет, что именно изменилось между двумя версиями ветки. Решающий эту задачу инструмент встроен в Git — git range-diff (разбор в «Переписывании истории»):
$ git range-diff main origin/pr/1421 pr/1421
1: a1b2c3d = 1: d4e5f6a добавить лимит позиций в корзине
2: b2c3d4e ! 2: e5f6a7b вынести конфиг лимитов в отдельную структуру
@@ internal/cart/limits.go
- if len(items) > 50 {
+ if len(items) > cfg.MaxItems {
3: -- > 3: f6a7b8c добавить тест на граничное значение
Читается так: первый коммит не изменился (=), второй изменился (!) — и видно ровно как, третий добавлен (>). Ревьюеру не нужно перечитывать весь PR: одна команда, экономящая часы в неделю, и её почти никто не знает.
3. Размер изменения — единственная переменная, которая точно работает
Если из всей статьи запомнить один факт, пусть это будет он: не «регламент ревью», не «чек-лист из сорока пунктов», не «два апрува вместо одного», а размер изменения. Классическое исследование SmartBear/Cisco («Best Kept Secrets of Peer Code Review») дало три устойчивых числа: плотность найденных дефектов резко падает после 200–400 изменённых строк; эффективность падает при скорости выше ~500 строк в час; после 60 минут непрерывного ревью человек перестаёт находить дефекты вообще. Исследование Google «Modern Code Review: A Case Study at Google» (Sadowski et al., ICSE-SEIP 2018) показывает другую сторону: медианное изменение там — менее 10 файлов, около 35 % трогают один файл, а медианное время до первой реакции ревьюера — менее часа. Не потому, что инженеры Google быстрее читают, а потому, что читать нужно мало.
Механизм понятен и без исследований: ревью — это очередь, а по закону Литтла время ожидания растёт вместе с числом заявок в системе. Крупный PR держит ревьюера долго → очередь растёт → автор ждёт → начинает новую ветку → WIP растёт → ревью становится формальным. Дальше LGTM без чтения, и конструкция превращается в ритуал. Подробнее про поток и WIP — в «Kanban и поток».
Соответственно, работающие рычаги — не про Git:
- Резать задачу, а не PR. Не «разбить 2000 строк на пять частей по 400», а делать инкремент, который можно смержить и который не ломает продукт: сначала интерфейс и заглушка, потом реализация, потом переключение. Это branch by abstraction и фича-флаги из «Стратегий ветвления».
- Отделять механическое от содержательного. Переименование 300 файлов и правка логики — два разных коммита и два разных PR. Первый ревьюится за минуту, второй — внимательно.
- Стек веток вместо одного мегабранча. Ветка B основана на A, C — на B. Каждый PR маленький, ревью идёт параллельно. Ключевая команда —
git rebase --update-refs, которая при перебазировании двигает все ветки стека сразу (разбор в «Переписывании истории»). Инструменты поверх: Graphite, git-branchless, Stacked Git. - Автоматизировать всё, что не требует суждения. Форматирование, импорты, стиль — линтер в CI (см. «Хуки и автоматизация»). Каждый комментарий вида «здесь пробел» — это вычет из бюджета внимания ревьюера.
Ship / Show / Ask: ревью не обязано быть обязательным
Модель Рувана Вилсенаха Ship / Show / Ask: Ship — мержим сразу без PR (опечатка, патч-обновление зависимости, лог); Show — мержим сразу, но открываем PR постфактум как уведомление; Ask — открываем PR и ждём ответа (новая абстракция, изменение контракта, миграция БД, всё про безопасность). Здоровая команда живёт во всех трёх режимах: где всё — Ask, платят очередью и WIP; где всё — Ship, платят инцидентами.
схему данных
или модель безопасности?"} B -- Да --> ASK["ASK: блокирующее ревью,
обязательный апрув владельца"] B -- Нет --> C{"Появляется новая абстракция
или нетривиальное решение?"} C -- Да --> ASK C -- Нет --> D{"Кто-то в команде
должен об этом узнать?"} D -- Да --> SHOW["SHOW: мержим,
открываем PR как уведомление"] D -- Нет --> E{"Полностью покрыто
тестами и линтером?"} E -- Да --> SHIP["SHIP: мержим в trunk"] E -- Нет --> ASK ASK --> Q["Очередь слияний"] SHOW --> Q SHIP --> Q
Обратите внимание: границу задаёт риск изменения, а не должность автора. «Джуниоры всегда через ревью, синьоры — как хотят» — плохая эвристика: синьор чаще трогает контракты, то есть именно то, что нужно ревьюить.
4. Механика ревью в масштабе
Когда PR-ов десятки в день, ручное «кто-нибудь посмотрите» перестаёт работать. Первое, что настраивают, — маршрутизация через владение кодом: CODEOWNERS (файл платформы, не Git!) сопоставляет glob-пути с командами.
# .github/CODEOWNERS — последнее совпадение выигрывает, специфичное пишем ниже
/internal/billing/ @acme/payments
/deploy/ @acme/platform @acme/security
*.proto @acme/api-guild
Владение должно быть узким: строка * @acme/backend означает, что владельцев нет. Второе — разделение блокирующего и неблокирующего: половина конфликтов на ревью — это непонимание статуса комментария, и Conventional Comments снимают его одним префиксом.
nit: тут можно короче через strings.Builder — не блокирую
issue (blocking): при пустом слайсе будет паника на items[0]
question: почему таймаут 30 с, а не как в остальных клиентах — 5 с?
suggestion: можно вынести в отдельную функцию, но и так нормально
Бюджет на раунды. Больше двух раундов правок — сигнал, что обсуждать надо было не в PR, а до него: у автора и ревьюера разные модели задачи. Дешевле пятнадцать минут разговора, чем четыре круга комментариев.
Ротация ревьюеров и bus factor. Если 80 % ревью в модуле делает один человек — это риск, а не эффективность. Мерить можно прямо из Git, если команда пишет трейлеры Reviewed-by. Скрипт ниже читает вывод git log одним потоком: время O(n) по числу коммитов, память O(k) по числу уникальных ревьюеров.
"""Размеры изменений и концентрация ревью из истории Git.
Один проход по выводу git log: O(n) по времени, O(k) по памяти (k — ревьюеров)."""
import subprocess, sys
from collections import Counter
SEP = "\x1e" # разделитель записей: в текстах коммитов не встречается
out = subprocess.run( # \0 отделяет сообщение (%B) от блока --numstat
["git", "log", "--since=6 months ago", "--no-merges", "--numstat",
f"--format={SEP}%B%x00"], cwd=sys.argv[1],
capture_output=True, text=True, check=True).stdout
churns, reviewers = [], Counter()
for record in out.split(SEP)[1:]:
message, _, stats = record.partition("\x00")
# у бинарных файлов numstat даёт "-" вместо чисел — такие строки пропускаем
pairs = [ln.split("\t")[:2] for ln in stats.strip().splitlines()]
churns.append(sum(int(a) + int(d) for a, d in pairs if a.isdigit() and d.isdigit()))
for line in message.splitlines(): # трейлеры — строки «Ключ: значение»
if line.lower().startswith(("reviewed-by:", "co-authored-by:")):
reviewers[line.split(":", 1)[1].strip()] += 1
churns.sort()
pct = lambda q: churns[min(int(q * len(churns)), len(churns) - 1)]
print(f"коммитов {len(churns)}; строк p50/p90/p99: {pct(.5)} / {pct(.9)} / {pct(.99)}")
print(*(f" {n:5d} {name}" for name, n in reviewers.most_common(5)), sep="\n")
Смотреть надо не на среднее (распределение размеров PR тяжелохвостое, среднее бесполезно), а на p90 и p99: именно длинный хвост съедает время ревьюеров.
Подробнее о том, какие метрики инженерной работы имеют смысл, а какие ломаются законом Гудхарта, — в «DORA и инженерные метрики». Про содержательную сторону ревью — что именно смотреть в коде — в «Код-ревью и стандарты кода».
5. Очередь слияний: почему зелёный PR плюс зелёный main дают красный main
Это самая недооценённая проблема масштаба, и она прямо следует из модели данных. Когда CI проверяет ваш PR, он собирает эфемерное слияние: берёт main, сливает вашу ветку, тестирует получившееся дерево — и выбрасывает его. Этот merge-коммит никогда не попадёт в репозиторий. Пока PR ждал ревью, в main влились ещё три PR; результат вашего слияния теперь другой, и его никто не тестировал.
Текстовых конфликтов при этом может не быть вообще — классический семантический конфликт (см. «Конфликты слияния»): PR #1 переименовывает метод Charge() в ChargeWithIdempotency() и правит все восемь вызовов, а PR #2 добавляет девятый вызов Charge() в новом файле. Оба зелёные, оба сливаются без конфликта — файлы-то разные. Собранный main не компилируется.
Очередь слияний (GitHub merge queue, Bors, Zuul, Graphite Merge Queue) делает две вещи: сериализует слияния и проверяет ровно то дерево, которое станет новым main. Наивная сериализация — по одному PR за раз — упирается в длительность CI: при двадцатиминутной сборке это максимум три слияния в час. Поэтому используется спекулятивная сборка: очередь заранее строит варианты «main + 1», «main + 1 + 2», «main + 1 + 2 + 3» и запускает их параллельно.
Экономика простая. Если каждый кандидат ломает сборку с вероятностью p независимо, батч из n кандидатов проходит целиком с вероятностью (1 - p)^n: при p = 0.02 и n = 10 это 82 % — терпимо, при p = 0.10 — уже 35 %, и очередь большую часть времени занята пересборками. Отсюда порядок внедрения, который часто делают наоборот: сначала убить flaky-тесты (они дают тот самый p, причём фальшивый), потом сократить время сборки и только потом включать спекуляцию с большим батчем.
Когда очередь не нужна: команда до 8–10 человек с быстрым CI и короткими ветками — там дешевле правило «перед мержем подтяни main» и --force-with-lease. Когда нужна обязательно: десятки слияний в день в один trunk. Промежуточный вариант — требование «ветка должна быть up-to-date с main»: даёт корректность, но при высокой интенсивности превращается в гонку, где никто не успевает смержиться.
6. Соглашения о коммитах: сообщение — это интерфейс к истории
Сообщение коммита — единственная часть репозитория, которую нельзя восстановить из кода: diff всегда ответит на вопрос «что изменилось», а на вопрос «почему» может ответить только человек и только в момент написания.
Анатомия хорошего сообщения
cart: ограничить число позиций в корзине значением из конфига
Раньше лимит был захардкожен как 50. Партнёрский тариф требует 500,
а для триала бизнес хочет 10, поэтому значение вынесено в CartConfig
и читается из того же источника, что и остальные лимиты тарифа.
Проверка сделана на уровне сервиса, а не хендлера: корзину меняют
также фоновый импорт и админка, и они шли мимо HTTP-валидации.
Fixes: CART-1421
Reviewed-by: Мария Орлова <m.orlova@example.com>
Co-authored-by: Игорь Ким <i.kim@example.com>
Правила, которые не про вкусовщину:
- Первая строка — до ~50 символов, без точки, в повелительном наклонении («Ограничить», а не «Ограничил»). Мнемоника: заголовок дополняет фразу «если применить этот коммит, он …». Так пишет сам Git: «Merge branch», «Revert commit».
- Пустая строка после заголовка обязательна. Git парсит сообщение как «первый абзац = subject, остальное = body»; без пустой строки
git log --oneline,git shortlogи%sпокажут месиво. - Тело — про «почему» и «почему не иначе». Что сделано, видно из diff; какие альтернативы отвергнуты — не видно ниоткуда. Ширина тела — 72 символа:
git logдобавляет отступ в 4 пробела, и 72 + 4 укладываются в 80.
Трейлеры — это возможность Git, а не соглашение платформы
Строки вида Ключ: значение в конце сообщения Git понимает нативно — механизм из мира ядра Linux со своими командами:
# добавить трейлер при коммите или дописать его в готовое сообщение
$ git commit --trailer "Reviewed-by: Мария Орлова <m.orlova@example.com>"
$ git interpret-trailers --trailer "Fixes: CART-1421" --in-place .git/COMMIT_EDITMSG
# кто ревьюил модуль за полгода — прямо из истории, без API платформы
$ git log --since='6 months' --format='%(trailers:key=Reviewed-by,valueonly)' \
-- internal/cart | sort | uniq -c | sort -rn
41 Мария Орлова <m.orlova@example.com>
12 Игорь Ким <i.kim@example.com>
Отдельно стоит знать Signed-off-by (ставится флагом git commit -s) — это DCO, Developer Certificate of Origin: юридическое заявление «я имею право внести этот код». Не путать с криптографической подписью -S — совершенно разные вещи, разобранные в «Хуках и автоматизации».
Conventional Commits: когда окупается
Conventional Commits задают машиночитаемую грамматику заголовка:
<тип>[(область)][!]: <описание> — заголовок
[тело] — как обычно, «почему»
[BREAKING CHANGE: ...] — трейлер для ломающих изменений
feat(cart): читать лимит позиций из конфига тарифа
fix(billing): не терять идемпотентный ключ при ретрае
refactor(api)!: убрать поле legacy_id из ответа /v1/orders
Технически это даёт ровно одно: тип и !/BREAKING CHANGE однозначно отображаются в приращение версии по SemVer, а описание — в строку changelog. Ни читаемость, ни качество кода от соглашения не улучшаются.
Честный разбор. Оно окупается только там, где есть автоматика, которая его читает: генерация релизных заметок, автоматический bump версии, роутинг уведомлений. Без потребителя вы получаете chore: перед каждым сообщением и ноль пользы — карго-культ в чистом виде. И второй нюанс: при squash-мерже сообщением коммита становится заголовок PR, а не сообщения отдельных коммитов, поэтому проверять формат надо на платформе — линтер в commit-msg-хуке (см. «Хуки и автоматизация») в этом режиме валидирует то, что никогда не попадёт в trunk.
Зачем это всё: археология
Плата за дисциплину сообщений возвращается при разборе инцидента — вот инструменты, превращающие историю в базу знаний:
# кто изменил строки 120–135; -w игнорирует пробелы, -C -C ловит перенос кода
$ git blame -w -C -C -L 120,135 internal/cart/limits.go
# выкинуть из blame шумные коммиты (массовое переформатирование)
$ git config blame.ignoreRevsFile .git-blame-ignore-revs
$ echo "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678 # gofumpt по всей базе" \
>> .git-blame-ignore-revs
# «кирка»: когда в истории появилась или исчезла строка / регулярка
$ git log -S 'MaxItems' --oneline -- internal/cart
3f7a91c cart: ограничить число позиций в корзине
$ git log -G 'Charge\(' --oneline
$ git log -L :ChargeWithIdempotency:internal/billing/charge.go # история функции
Именно ради этих запросов имеет смысл держать историю осмысленной: коммит «фиксы» на 900 строк делает git log -S бесполезным — вы найдёте коммит, но не поймёте причину.
7. Релиз: это ссылка плюс обещание
В модели данных релиз — до неприличия простая вещь: именованная точка в DAG; всё остальное (артефакты, changelog, деплой) — снаружи. Тегов при этом два вида, и разница не косметическая.
# легковесный тег — просто файл в refs/tags с хешем коммита, ничего больше
$ git tag v1.4.0-tmp
$ git cat-file -t v1.4.0-tmp
commit
# аннотированный тег — полноценный ОБЪЕКТ в базе, со своим хешем
$ git tag -a v1.4.0 -m "Релиз 1.4.0: лимиты корзины, идемпотентность биллинга"
$ git cat-file -t v1.4.0
tag
$ git cat-file -p v1.4.0
object 3f7a91c8d2e4b5a6f7091c2d3e4b5a6f70912c3d
type commit
tag v1.4.0
tagger Мария Орлова <m.orlova@example.com> 1721120400 +0300
Релиз 1.4.0: лимиты корзины, идемпотентность биллинга
Аннотированный тег — четвёртый тип объекта Git (см. «Внутри Git»): у него есть автор, дата, сообщение, и он подписывается. Для релизов используйте только аннотированные (-a) или подписанные (-s); легковесные — для личных закладок. Дальше — свойства, которые регулярно удивляют, и git describe, дающий версию прямо из истории:
# теги НЕ уезжают при обычном push
$ git push origin main # тег v1.4.0 остался только у вас
$ git push --follow-tags origin main # аннотированные теги, достижимые из main
$ git fetch --tags # обратно — тоже явно
# ближайший достижимый аннотированный тег + расстояние + сокращённый хеш
$ git describe --tags --always --dirty
v1.4.0-17-g3f7a91c
# ^^^^^^ тег, 17 коммитов после него, g = git и хеш; суффикс -dirty при грязном дереве
# в релизном пайплайне: либо ровно тег, либо ошибка
$ git describe --exact-match --tags HEAD
fatal: no tag exactly matches '3f7a91c...'
Строка describe однозначно указывает на коммит и при этом читается человеком — идеальный штамп версии в бинарнике (в Go: -ldflags "-X main.version=$(git describe --tags --always --dirty)"). И главное: тег иммутабелен по соглашению, а не по механике. Технически git tag -f и git push --force подвинут его куда угодно. Практически это ложь всем, кто уже скачал релиз: у одних v1.4.0 — один код, у других — другой, и Git не подсветит расхождение (git fetch просто откажется обновлять тег). Правильный ответ на «в релизе баг» — выпустить v1.4.1, а не переставить v1.4.0.
Версионирование: SemVer и его границы
SemVer формулирует контракт MAJOR.MINOR.PATCH и работает ровно там, где есть публичный API и внешние потребители: библиотеки, SDK, HTTP-контракты. Где он буксует:
- Определение «ломающего» субъективно. По закону Хайрама при достаточном числе пользователей любое наблюдаемое поведение системы становится чьей-то зависимостью — включая порядок ключей в JSON и текст сообщения об ошибке.
- Ветка
0.x— отказ от контракта. По спецификации в0.xломать можно что угодно; экосистемы вроде npm лечат это правилом «в0.xминорная версия ведёт себя как мажорная». - Для приложения, а не библиотеки, SemVer часто бессмысленен. У веб-сервиса нет «обратной совместимости версии» — есть совместимость API, и она версионируется отдельно (
/v1,/v2). Здесь честнее CalVer:2026.07.3говорит «когда», а не «насколько ломающе».
Практическое правило: SemVer для того, что импортируют; CalVer или порядковый номер для того, что деплоят.
Релизный поезд и хотфикс
Ключевое правило релизных веток: исправление едет из release-ветки в main, а не наоборот — мержить release-1.4 целиком значит тащить в trunk служебные коммиты («bump version», «freeze»). Правильный инструмент — cherry-pick -x:
$ git switch main && git cherry-pick -x 8c1d0af
$ git log -1 --format=%B
hotfix: увеличить таймаут платёжного шлюза до 30 с
(cherry picked from commit 8c1d0af9e2b3c4d5e6f708192a3b4c5d6e7f8091)
# аудит: что есть в release-1.4, но не доехало в main
$ git log --oneline --no-merges --cherry-pick --right-only main...release-1.4
8c1d0af hotfix: увеличить таймаут платёжного шлюза
Флаг -x дописывает строку с исходным хешем — единственная причина, по которой потом можно доказать, что фикс доехал. А --cherry-pick сравнивает коммиты по patch-id — хешу самого изменения, а не объекта; поэтому уже перенесённые коммиты, у которых другой хеш, но то же содержимое, из вывода исчезают. Механика patch-id разобрана в «Merge и rebase».
Changelog из истории
$ git log --no-merges --format='* %s (%h)' v1.3.0..v1.4.0 # что вошло в релиз
$ git shortlog -sn --no-merges v1.3.0..v1.4.0 # вклад участников
23 Мария Орлова
11 Игорь Ким
# только ломающие изменения, если приняты Conventional Commits
$ git log v1.3.0..v1.4.0 --format='%s%n%b' | grep -E '^(BREAKING CHANGE:|[a-z]+(\(.+\))?!:)'
Инструменты, которые делают это автоматически и заодно двигают версию: semantic-release, release-please, changesets (последний — для монорепозиториев с несколькими пакетами, см. «Большие репозитории и монорепо»). Все они делают одно: читают диапазон последний-тег..HEAD, извлекают структуру из сообщений, вычисляют новую версию, генерируют файл, ставят тег.
Сам по себе тег ничего не разворачивает: как из него получается артефакт в проде — в «CD и стратегиях релиза», зачем при этом подписывать теги и фиксировать происхождение — в «Безопасности цепочки поставок».
8. Жизненный цикл изменения целиком
Соберём всё в одну картину — от локальной ветки до развёрнутого релиза, с честным указанием, где изменение может откатиться назад.
Два перехода здесь — источники большинства организационных болей.
ВTrunk --> Откачено. Откат после мержа — это git revert, а не «удалить коммит»: история опубликована, переписывать её нельзя (радиус поражения force-push разобран в «Переписывании истории»). Отдельная ловушка — откат merge-коммита: git revert -m 1 <merge> требует явно указать «главного» родителя и делает повторный мерж той же ветки бесполезным, потому что для Git она уже слита. Разбор — в «Merge и rebase».
Развёрнуто --> Хотфикс --> ВTrunk. Самая частая продакшн-ошибка: фикс уехал в релизную ветку, задеплоен, всех отпустило — и никто не перенёс его в main. Через две недели релиз 1.5 привозит тот же баг обратно. Лечится не дисциплиной, а ночной джобой, которая гоняет git log --cherry-pick --right-only main...release-* и заводит тикет на всё, что не доехало.
9. Почему споры про rebase, squash и коммиты — не про Git
Мы обещали честность в этом вопросе ещё в обзоре трека, и здесь ей самое место — на уровне команды, а не отдельного разработчика. Технически спор пустой: merge, rebase и squash одинаково создают валидные DAG, Git поддерживает все три и не имеет мнения. Спор идёт о том, какой артефакт вы считаете единицей истории:
| Позиция | Единица истории | Что оптимизируется | Чем платят |
|---|---|---|---|
| Merge-коммиты, история как есть | физический коммит | полнота: видно, как реально шла работа | шум, git log нечитаем, bisect спотыкается о промежуточные состояния |
| Squash при мерже | pull request | читаемость trunk, простой revert, простой bisect |
потеряна структура крупных изменений, «один коммит на 1200 строк» |
| Rebase + линейная история, коммиты сохранены | логический шаг | и читаемость, и granularity | требует дисциплины: каждый коммит обязан быть рабочим и собираемым |
Третий вариант лучший — и самый дорогой: он требует, чтобы разработчики владели rebase -i, fixup и --autosquash, чтобы каждый коммит проходил сборку, чтобы ревью шло по коммитам. Команда, которая этого не умеет, получит из него худшее от всех трёх. Отсюда алгоритм выбора, снимающий 90 % холивара:
- Определите потребителя истории. Кто её читает:
git bisectпри инцидентах? Генератор changelog? Аудитор? Никто? «Никто» — тоже ответ, и тогда squash. - Проверьте, ломает ли режим ваш
bisect. Если промежуточные коммиты не собираются, линейная история из них — иллюзия, и squash честнее. - Сделайте настройку принудительной, а не устной, и не смешивайте режимы в одном репозитории: половина коммитов squash, половина merge — история, которой не может доверять ни один инструмент анализа.
И общее правило, применимое ко всем соглашениям из этой статьи: соглашение без исполняющего механизма — это пожелание. Формат коммитов проверяет линтер, размер PR — бот, наличие апрува владельца — branch protection, целостность trunk — merge queue. Всё, что держится на памяти людей, разъедется за квартал — не из-за расхлябанности, а потому что состав команды меняется.
10. Типичные ошибки
- Обсуждать архитектуру в комментариях к PR. Решение остаётся в базе платформы и теряется при миграции. Итог должен попасть в тело коммита или в ADR (см. «Архитектурные решения и ADR»).
- Требовать два апрува на всё. Ответственность размывается пропорционально числу ревьюеров: каждый считает, что посмотрит второй. Один внимательный лучше двух формальных. Мерить ревью числом комментариев — из той же серии: мгновенно порождает нитпикинг.
- Переставлять уже выложенный тег. У части команды
v1.4.0останется старой навсегда, и вы об этом не узнаете. Легковесные теги для релизов — из той же серии: нет автора, нет даты, нельзя подписать. - Забывать
--follow-tagsпри push. Тег есть локально, в CI релиза нет, все ищут проблему в пайплайне. Переносить фикс без-x— через месяц никто не докажет, что этот коммит и есть тот хотфикс. - Вводить Conventional Commits без потребителя. Получаете
chore:перед каждым сообщением и ноль пользы. - Держать merge queue при высоком проценте flaky-тестов. Очередь превращается в машину по пересборке; команда учится нажимать «bypass», и смысл теряется.
- Владелец
*в CODEOWNERS. Формально владельцы есть, фактически ревью маршрутизируется в никуда. - Долгоживущий PR. Чем дольше он открыт, тем сильнее расходится контекст и дороже слияние. Возраст PR — такая же метрика, как его размер.
Мини-итог
- В Git нет ревью, PR и релизов — есть DAG,
merge-base, объекты-теги и трейлеры; всё остальное надстроено платформой, и знание границы решает большинство споров. - Что видит ревьюер — это
diff(merge-base, вершина ветки), то естьmain...feature; две точки вdiffи вlogозначают разное. После force-push повторное ревью делается черезgit range-diff— самую недооценённую команду Git. - Размер изменения — главный рычаг качества ревью: за 200–400 строк и 60 минут эффективность обрывается. Резать надо задачу, а не diff. И не каждое изменение требует блокирующего ревью: Ship / Show / Ask распределяет внимание по риску, а не по должности.
- Зелёный PR плюс зелёный main не дают зелёный main: CI проверяет эфемерное слияние, которое никогда не будет запушено. Очередь слияний со спекулятивной сборкой решает это — но только при низком проценте падений.
- Сообщение коммита — единственное, что нельзя восстановить из кода. Трейлеры (
Co-authored-by,Reviewed-by,Signed-off-by) — нативная возможность Git со своими командами; Conventional Commits окупаются только вместе с автоматикой, которая их читает. - Релиз — аннотированный (лучше подписанный) тег: объект с автором, датой и сообщением. Теги не уезжают при обычном
pushи не должны переставляться. SemVer — для того, что импортируют; CalVer — для того, что деплоят. - Фиксы едут из релизной ветки в trunk через
cherry-pick -x, а аудит недоехавшего — через--cherry-pick --right-only. - Соглашение без исполняющего механизма — пожелание. Линтер, бот, branch protection, merge queue: всё, что держится на памяти людей, разъедется за квартал.
Источники
- Pro Git, глава «Distributed Git» — рабочие процессы в распределённой модели.
- gitrevisions(7) — точная семантика
..,...,^,@{...}; git-range-diff, git-interpret-trailers, git-describe, git-shortlog — документация ключевых команд статьи. - «Best Kept Secrets of Peer Code Review», SmartBear/Cisco — те самые числа про 200–400 строк и 60 минут.
- Sadowski et al., «Modern Code Review: A Case Study at Google», ICSE-SEIP 2018 — как ревью устроено при десятках тысяч инженеров.
- Google Engineering Practices: Code Review — свод правил ревьюера и автора; лучший бесплатный текст по теме.
- Rouan Wilsenach, «Ship / Show / Ask» и Conventional Comments — распределение ревью по риску и снятие двусмысленности комментариев.
- Conventional Commits 1.0.0, Semantic Versioning 2.0.0, CalVer, Hyrum’s Law — версионирование и его границы.
- Chris Beams, «How to Write a Git Commit Message» — семь правил, к которым сводится всё про сообщения; Developer Certificate of Origin — что именно утверждает
Signed-off-by. - GitHub merge queue, Bors, Zuul — реализации гейтинга со спекулятивной сборкой.
- Forsgren, Humble, Kim, «Accelerate» (2018) — эмпирика связи размера партии, времени поставки и надёжности.
Что дальше
Трек закончен. Мы прошли путь от «Git — это key-value хранилище с DAG поверх» до очередей слияний и релизных тегов, и на каждом шаге ответ находился в модели данных, а не в списке флагов. Если сейчас перечитать карту трека, большинство пунктов должно читаться как очевидные следствия, а не как набор команд для заучивания. Куда двигаться дальше — зависит от того, что стало узким местом:
- Красный trunk, долгие сборки, ручные деплои — DevOps: CI/CD, контейнеры, облака, особенно «CD и стратегии релиза».
- Ревью находит дефекты, которые должны были найти тесты — Тестирование и «Тесты в CI».
- Споры на ревью идут о структуре кода, а не о поведении — Принципы разработки и «Код-ревью и стандарты кода».
- Подпись коммитов и происхождение артефактов оказались важнее, чем казалось — Безопасность, в частности «Безопасность цепочки поставок».
- Проблема не в инструментах, а в потоке работы и метриках — Управление проектами и «DORA и инженерные метрики».
Общая карта портала с порядком изучения треков — Роадмап.