Git и командная работа Совместная работа: код-ревью в масштабе, соглашения о коммитах, релизы
0%

Совместная работа: код-ревью в масштабе, соглашения о коммитах, релизы

Совместная работа: код-ревью в масштабе, соглашения о коммитах, релизы

Девять статей трека мы разбирали 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 вычисляется между двумя деревьями, вопрос лишь в том, какие два взять.

База ревью: двухточечный и трёхточечный diff на одном DAG

# ветка отошла от 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:

  1. Резать задачу, а не PR. Не «разбить 2000 строк на пять частей по 400», а делать инкремент, который можно смержить и который не ломает продукт: сначала интерфейс и заглушка, потом реализация, потом переключение. Это branch by abstraction и фича-флаги из «Стратегий ветвления».
  2. Отделять механическое от содержательного. Переименование 300 файлов и правка логики — два разных коммита и два разных PR. Первый ревьюится за минуту, второй — внимательно.
  3. Стек веток вместо одного мегабранча. Ветка B основана на A, C — на B. Каждый PR маленький, ревью идёт параллельно. Ключевая команда — git rebase --update-refs, которая при перебазировании двигает все ветки стека сразу (разбор в «Переписывании истории»). Инструменты поверх: Graphite, git-branchless, Stacked Git.
  4. Автоматизировать всё, что не требует суждения. Форматирование, импорты, стиль — линтер в CI (см. «Хуки и автоматизация»). Каждый комментарий вида «здесь пробел» — это вычет из бюджета внимания ревьюера.

Ship / Show / Ask: ревью не обязано быть обязательным

Модель Рувана Вилсенаха Ship / Show / Ask: Ship — мержим сразу без PR (опечатка, патч-обновление зависимости, лог); Show — мержим сразу, но открываем PR постфактум как уведомление; Ask — открываем PR и ждём ответа (новая абстракция, изменение контракта, миграция БД, всё про безопасность). Здоровая команда живёт во всех трёх режимах: где всё — Ask, платят очередью и WIP; где всё — Ship, платят инцидентами.

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

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 % холивара:

  1. Определите потребителя истории. Кто её читает: git bisect при инцидентах? Генератор changelog? Аудитор? Никто? «Никто» — тоже ответ, и тогда squash.
  2. Проверьте, ломает ли режим ваш bisect. Если промежуточные коммиты не собираются, линейная история из них — иллюзия, и squash честнее.
  3. Сделайте настройку принудительной, а не устной, и не смешивайте режимы в одном репозитории: половина коммитов 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: всё, что держится на памяти людей, разъедется за квартал.

Источники

Что дальше

Трек закончен. Мы прошли путь от «Git — это key-value хранилище с DAG поверх» до очередей слияний и релизных тегов, и на каждом шаге ответ находился в модели данных, а не в списке флагов. Если сейчас перечитать карту трека, большинство пунктов должно читаться как очевидные следствия, а не как набор команд для заучивания. Куда двигаться дальше — зависит от того, что стало узким местом:

Общая карта портала с порядком изучения треков — Роадмап.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов