voidlearn
 

4.2Процесс

Разберёшь, что именно показывает diff merge request и как последовательность веток ведёт себя при обычном merge или squash. Сопоставишь модели ветвления, защиту веток и смысл подписи коммита.

Что сможешь объяснить после этой главы
  • Какой diff показывает merge request и почему он не замечает, что main ушёл вперёд.
  • Как вести стопку зависимых MR и что с ней делать после squash-merge нижнего.
  • Чем Git Flow, GitHub Flow и trunk-based отличаются по истории и когда какая модель к месту.
  • Что запрещает защищённая ветка, почему зелёные MR ломают main и как читается CODEOWNERS.
  • Как подписать коммит ключом SSH или GPG и почему «подпись верна» не значит «это писала Аня».

Merge request: две ветки и diff от merge-base

Merge request в GitLab и pull request в GitHub просят влить ветку-источник в целевую ветку. Для git это две ветки на сервере и ссылка, которую сервер заводит на каждый запрос: refs/merge-requests/<номер>/head в GitLab (глава 4.1) и refs/pull/<номер>/head в GitHub. Обсуждение, одобрения и статусы проверок хранятся в базе сервера и в клон не приезжают.

Восьмого сентября Аня отправила ветку retry сервиса shop и открыла MR в main. Ждать ревью она не стала: начала от retry ветку retry-log и открыла второй MR, уже в retry. Днём Боря добавил коммит в main. После git fetch у Ани так (локальная main стоит, где была при клонировании):

$ git log --oneline --graph origin/main retry-log
* 7a38cce (origin/main, origin/HEAD) Отдавать версию в /health
| * 4ec85fa (HEAD -> retry-log, origin/retry-log) Логировать неудачные попытки
| * 3cae426 (origin/retry, retry) Повторять до трёх раз
| * 8b2f32f Добавить withRetry
|/
* 78c0f75 (main) Отдавать заказ по id
* bc8b6c0 Создать модуль shop
$ git diff --stat origin/main retry
 health.go |  9 ---------
 retry.go  | 13 +++++++++++++
 2 files changed, 13 insertions(+), 9 deletions(-)
$ git diff --stat origin/main...retry
 retry.go | 13 +++++++++++++
 1 file changed, 13 insertions(+)

Через пробел или две точки git diff сравнивает снимки вершин, и ветка Ани будто удаляет health.go, который просто появился в main позже. С тремя точками он сравнивает вершину retry с merge-base (глава 3.1) и показывает только сделанное в ветке. Такой diff и видишь в pull request на GitHub. GitLab умеет ещё сравнивать ветку с пробным слиянием в свежую целевую ветку, чтобы из diff пропали изменения, пришедшие в main другим путём.

Обратная сторона: diff от merge-base не меняется, когда main уходит вперёд, и ничего не говорит о том, как ветка поведёт себя рядом с новым кодом. Отстала ли ветка, скажет merge-base --is-ancestor:

$ git merge-base --is-ancestor origin/main retry; echo $?
1

Код 1: вершина origin/main не входит в историю retry, и CI на ветке проверял снимок без коммита Бори.

Маленький MR

В руководстве Google по код-ревью сказано: около 100 строк обычно нормально, 1000 обычно слишком много, а 200 строк в одном файле читать легче, чем те же 200 в 50 файлах. Маленькое изменение быстрее проверяют и внимательнее читают, его не жалко выбросить, если ревьюер не согласен с подходом, и проще откатить. Рефакторинг отправляют отдельно от изменения поведения, а тесты кладут вместе с кодом, который они проверяют. Как ни режь, после каждого влитого MR main должен собираться и работать.

При squash-merge MR становится одним коммитом, и сообщением GitLab по умолчанию делает заголовок MR, поэтому его пишут как заголовок коммита (глава 2.1). При слиянии и rebase коммиты ветки попадают в main как есть, и порядок в них наводят до слияния через --autosquash (глава 3.3).

Стопка merge request

Второй MR Ани нацелен на retry, поэтому ревьюер видит в нём только свой слой:

$ git diff --stat retry...retry-log
 retry.go | 3 +++
 1 file changed, 3 insertions(+)

Если нижний MR правят по ревью, верхнюю ветку переносят на его новую вершину, а всю стопку на свежий main сдвигает git rebase --update-refs (глава 3.3). Сложнее, когда нижний MR влили. Девятого сентября Боря влил первый MR squash-ем и удалил ветку retry на сервере. Аня делает git fetch --prune:

$ git log --oneline --graph origin/main retry-log
* 410eea8 (origin/main, origin/HEAD) Повторять запрос при ошибке
* 7a38cce Отдавать версию в /health
| * 4ec85fa (HEAD -> retry-log, origin/retry-log) Логировать неудачные попытки
| * 3cae426 (retry) Повторять до трёх раз
| * 8b2f32f Добавить withRetry
|/
* 78c0f75 (main) Отдавать заказ по id
* bc8b6c0 Создать модуль shop
$ git diff --stat origin/main...retry-log
 retry.go | 16 ++++++++++++++++
 1 file changed, 16 insertions(+)

В main все изменения retry легли одним новым коммитом 410eea8, а в retry-log остались исходные коммиты, которых в main нет. Merge-base прежний, и diff второго MR снова включает работу первого. Обычный rebase начнёт как раз с этих коммитов:

$ git rebase origin/main
Auto-merging retry.go
CONFLICT (add/add): Merge conflict in retry.go
error: could not apply 8b2f32f... Добавить withRetry
hint: Resolve all conflicts manually, mark them as resolved with
hint: "git add/rm <conflicted_files>", then run "git rebase --continue".
hint: You can instead skip this commit: run "git rebase --skip".
hint: To abort and get back to the state before "git rebase", run "git rebase --abort".
hint: Disable this message with "git config set advice.mergeConflict false"
Could not apply 8b2f32f... # Добавить withRetry
$ git rebase --abort
$ git rebase --onto origin/main retry
Successfully rebased and updated refs/heads/retry-log.
$ git log --oneline --graph origin/main retry-log
* e53f9a8 (HEAD -> retry-log) Логировать неудачные попытки
* 410eea8 (origin/main, origin/HEAD) Повторять запрос при ошибке
* 7a38cce Отдавать версию в /health
* 78c0f75 (main) Отдавать заказ по id
* bc8b6c0 Создать модуль shop
$ git diff --stat origin/main...retry-log
 retry.go | 3 +++
 1 file changed, 3 insertions(+)

8b2f32f добавляет retry.go, а в main файл уже есть с другим содержимым: конфликт add/add. Разрешать его незачем, эти коммиты не нужны. --onto переносит только коммиты после старой вершины нижней ветки, границей служит локальная retry. Если её уже удалили, границу задают хешем: git rebase --onto origin/main 3cae426. Отправляют ветку через git push --force-with-lease (глава 4.1).

GitLab при слиянии MR переназначает на main до четырёх открытых MR, нацеленных на его ветку, и с версии 19.1 показывает их стопкой; о переносе веток документация молчит. В GitHub стопки pull request в публичном превью, и после слияния нижнего PR сервер сам перебазирует остальные.

Squash-merge посреди стопки

Если нижний MR влит merge-коммитом, git rebase main в верхней ветке перенесёт только её коммиты. Если rebase-ом, git пропустит уже применённые коммиты по совпадению изменений и напишет skipped previously applied commit. После squash-merge rebase применяет коммиты нижней ветки заново: одиночный совпадёт со squash-коммитом и пропустится, уже внесённые правки отпадут как пустые, а коммиты, которые по очереди правят одни строки, как у Ани, дадут конфликт. Для них и нужен --onto.

Git Flow

Модель описал Винсент Дриссен в заметке «A successful Git branching model» 5 января 2010 года. Две ветки живут всегда: каждый коммит в master (сейчас чаще main) по определению выпущенный релиз, а в develop собирается следующий. Остальные ветки временные:

  • feature начинается от develop и вливается в неё же. По заметке такие ветки обычно есть только у разработчика.
  • release-* отходит от develop, когда там собрано всё для релиза. В ней поднимают версию и чинят мелочи, новые фичи нельзя. Вливается в master с тегом и обратно в develop.
  • hotfix-* начинается от тега на master при срочной ошибке в проде. Вливается в master и в develop, а при открытой release-ветке в неё вместо develop.

Слияния идут с --no-ff (глава 3.1), чтобы фичу было видно и откатывалась она одним revert. Вот копия shop, которую вели по этим правилам: две фичи, релиз 0.2.0 и исправление 0.2.1.

main hotfix-0.2.1 release-0.2 develop feature/* e4f7126 be45978 2178cf0 c51dff7 5988569 ffb5b7d 2bd5036 2d1d878 e11a291 6d56db2 ff0a995 c34c774 2c01c3d 545e8a1 feature/retry feature/metrics v0.1.0 v0.2.0 v0.2.1
Git Flow на настоящей истории. Фичи вливаются в develop. Релизная ветка отходит от неё и вливается в main и обратно, хотфикс начинается от релиза v0.2.0 и тоже вливается в обе ветки.
$ git log --oneline --first-parent main
2c01c3d (tag: v0.2.1, main) Merge branch 'hotfix-0.2.1'
2d1d878 (tag: v0.2.0) Merge branch 'release-0.2'
e4f7126 (tag: v0.1.0) Создать модуль shop

По первым родителям main читается как список релизов. Цена видна на схеме: исправление из release- или hotfix-ветки вливают дважды, и если забыть слияние в develop (здесь 545e8a1), ошибка вернётся со следующим релизом. А код из develop встречается с продом только на релизе.

Оговорка автора через десять лет

5 марта 2020 года Дриссен дописал в начало заметки «Note of reflection». Модель стали применять как догму, пишет он, хотя придумывал он её не для веб-приложений, которые выкатывают непрерывно и не держат нескольких версий. Команде с continuous delivery он советует процесс проще, например GitHub Flow, а git-flow оставляет продуктам с явными версиями или несколькими поддерживаемыми версиями.

GitHub Flow

Скотт Чакон описал процесс GitHub 31 августа 2011 года, объясняя, почему там не пользуются git-flow: GitHub выкатывался каждый день, часто по нескольку раз. Жёсткое правило одно: всё в master можно выкатывать. Работу ведут в ветке от master с понятным именем, pull request открывают, как только нужен отзыв, после одобрения и зелёного CI вливают и сразу выкатывают. Срочное исправление идёт тем же путём. В истории это main и короткие ветки, как на схеме ниже, только без релизной ветки. Модели тесно, когда в проде живут две версии и чинить надо обе.

Trunk-based и релизные ветки

trunkbaseddevelopment.com определяет модель так: все работают в одной ветке, стволе (в git обычно main), и не заводят других долгоживущих веток разработки. Совсем маленькая команда коммитит прямо в ствол, остальные идут через короткие ветки: каждая живёт не дольше пары дней, и работает в ней один человек или пара. Незаконченное прячут за feature flag или делают через branch by abstraction. Как флаги живут в проде, разбирает тема «CI/CD и деплой» раздела DevOps.

Выпускать можно из ствола или из релизной ветки. Команда с непрерывной выкаткой выпускает из ствола и чинит там же. При релизах, скажем, раз в месяц релизную ветку отрезают за несколько дней до выпуска и разработку в ней не ведут. Ошибку чинят в стволе, а в релизную ветку переносят cherry-pick-ом, и не наоборот: исправление только в релизной ветке легко не донести до ствола.

Ещё одна копия shop. Подсчёт запросов вошёл в main выключенным, за переменной SHOP_METRICS. Четвёртого сентября Боря отрезал release-0.3 с тегом v0.3.0. Седьмого Аня исправила ответ 500 на неизвестный заказ в ветке fix-404 и влила её в main, а Боря перенёс исправление:

$ git switch release-0.3
Switched to branch 'release-0.3'
$ git cherry-pick -x 4f61800
[release-0.3 a09b290] Отвечать 404 на неизвестный заказ
 Author: Аня <anya@example.com>
 Date: Mon Sep 7 10:00:00 2026 +0300
 1 file changed, 1 insertion(+), 1 deletion(-)
$ git tag -a v0.3.1 -m 'shop 0.3.1'
release-0.3 main короткие ветки ac8b184 191f13b 669f692 220d6fb 3ac8b45 01efb1b 4f61800 ea35fb7 a09b290 0c7285d 5b8c769 retry metrics-flag fix-404 retry-log v0.3.0 v0.3.1 cherry-pick -x
Ствол, короткие ветки и релизная ветка. Короткие ветки вливаются в main в тот же день. release-0.3 начинается от релиза v0.3.0 и получает только копию исправления, которое сначала влили в ствол.

Восьмого в main влили ещё retry-log. Дошло ли исправление до релизной ветки, по хешу не проверишь, у копии он другой:

$ git branch --contains 4f61800
* main
$ git cherry -v release-0.3 main
- 4f618002be4c5d42d494ab18b7b2444f29872e73 Отвечать 404 на неизвестный заказ
+ 0c7285d8578251d5e8756aafae87568ccbc5262b Логировать неудачные попытки

git cherry сравнивает коммиты по patch-id: минус значит, что такое изменение в release-0.3 уже есть. Так живёт сам Go: рядом с master лежат release-branch.go1.26 и release-branch.go1.27, исправление сначала сливают в master, потом cherry-pick-ом переносят в две последние релизные ветки, и то только уязвимости, серьёзные ошибки без обходного пути и документацию.

Защищённая ветка

Защиту ветки проверяет сервер, когда принимает push: смотрит, кто и как обновляет ссылку, и отказывает раньше, чем она сдвинется. Объяснение клиент видит в строках remote:, у GitHub, например, remote: error: GH006: Protected branch update failed for refs/heads/main. Локальные настройки и --force здесь ничего не решают.

GitLabGitHub
Защита сразуветка по умолчанию: разработчики не пушат, мейнтейнеры пушат, force push запрещён всемправило (branch protection rule или ruleset) заводит администратор
Force push и удалениеforce push запрещён, пока не включён Allowed to force push; удалить можно только из интерфейса или APIзапрещены, пока не включены Allow force pushes и Allow deletions
Владельцы кодаRequire approval from code owners (Premium, Ultimate)Require review from Code Owners
Кто обходиту кого есть Allowed to push and merge: вливает без MR и одобренийадминистраторы, пока не включено Do not allow bypassing the above settings

Для веток, из которых выкатывают, GitLab советует Allowed to push and merge: No one, чтобы в main вели только merge request. И если на ветку подходят несколько правил с шаблонами, GitLab применяет самое мягкое.

Обязательные проверки: зелёный MR и сломанный main

Статус проверки (status check в GitHub, pipeline в GitLab) привязан к коммиту: проверки прошёл снимок вершины ветки. После слияния в main появляется другой снимок, и его никто не собирал. Аня переименовала findOrder в lookupOrder вместе с вызовами, Боря в своей ветке добавил новый вызов findOrder. Обе ветки собираются, git сливает их без конфликтов:

$ git merge --no-ff -m "Merge branch 'pay'" pay
Merge made by the 'ort' strategy.
 pay.go | 5 +++++
 1 file changed, 5 insertions(+)
 create mode 100644 pay.go
$ go build ./...
# shop
./pay.go:4:9: undefined: findOrder

Против таких сочетаний есть три способа:

СпособЧто проверяютЦена
Свежая ветка: Require branches to be up to date before merging (GitHub)ветку, в историю которой входит текущий mainпосле каждого чужого слияния обновлять ветку и ждать CI
CI на пробном слиянии: merged results pipelines (GitLab Premium, Ultimate)временный коммит слияния ветки с текущей целевой веткойMR, влитые почти одновременно, друг друга не видят
Очередь: merge train (GitLab Premium, Ultimate), merge queue (GitHub)ветку вместе со всеми MR перед ней в очереди: A, A+B, A+B+C параллельнопуть до main длиннее, упавший MR выбывает из очереди

По документации GitHub, свежую ветку (strict) обязательные проверки требуют по умолчанию. Очередь GitHub проверяет сочетания во временных ветках gh-readonly-queue/<база>/… и шлёт отдельное событие merge_group: если workflow GitHub Actions на него не подписан, обязательная проверка не отчитается и слияние не пройдёт. В GitLab зелёный pipeline требуют не в защите ветки, а в настройках проекта: Merge checks, Pipelines must succeed.

CODEOWNERS

CODEOWNERS лежит в репозитории, в каждой строке шаблон пути, похожий на .gitignore, и владельцы. Для shop на GitLab:

# .gitlab/CODEOWNERS
# общее правило первым: строки ниже его перекрывают
*                   @shop/backend

# платежи: хватит одобрения любого из двоих
/internal/payments/ @shop/payments @borya

# зависимости и CI
go.mod              @shop/platform
go.sum              @shop/platform
/.gitlab-ci.yml     @shop/platform

# сам файл владельцев
/.gitlab/CODEOWNERS @shop/leads

GitHub назначает владельцев изменённых файлов ревьюерами, GitLab по умолчанию только вносит их в правила одобрения (ревьюерами их делает отдельная настройка). Обязательным их одобрение делает защита ветки (в GitLab CODEOWNERS есть только в Premium и Ultimate). Правила чтения похожи, но не одинаковы:

  • Побеждает последняя подходящая строка. Поэтому * пишут первым. GitLab разрешает делить файл на секции [Имя] и требует одобрения по каждой обязательной.
  • Где лежит. GitHub ищет в .github/, в корне, в docs/; GitLab в корне, в docs/, в .gitlab/. Работает первый найденный.
  • Из какой ветки. Файл берут из целевой ветки MR, так что MR, который правит CODEOWNERS, для себя владельцев не поменяет. GitLab считает правила один раз, при создании MR.
  • Ошибки молчат. GitHub пропускает строку с ошибкой и не назначает владельцем пользователя без права записи. ! и [ ] из .gitignore в GitHub не работают, а комментарий в конце строки не понимает GitLab.
У CODEOWNERS тоже должен быть владелец

Если файл ничей, MR с обычным одобрением может вычеркнуть владельцев платежей, и следующий MR в платежи пройдёт без них. GitHub советует назначить владельца самому CODEOWNERS или каталогу .github/.

Подпись коммита: SSH и GPG

Имя и почту автора git берёт из настроек и не проверяет. Подпись добавляет к коммиту след закрытого ключа. Сначала git подписывал через GPG, в 2.19 добавились сертификаты X.509, а с 2.34 годится ключ SSH, в том числе тот, которым ходят на сервер. Аня включает подпись:

$ git config --global gpg.format ssh
$ git config --global user.signingKey ~/.ssh/id_ed25519.pub
$ git config --global commit.gpgSign true
$ git commit -m 'Логировать неудачные попытки'
[main fc39b76] Логировать неудачные попытки
 1 file changed, 3 insertions(+)
$ git cat-file -p HEAD
tree f7070d2e48dadb71f2534ab9e28329a4fdd1be35
parent 488eaf280c72838ae2c1ec0caf3b5503821d6e4a
author Аня <anya@example.com> 1788850800 +0300
committer Аня <anya@example.com> 1788850800 +0300
gpgsig -----BEGIN SSH SIGNATURE-----
 U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgZ9qqt+2R4kwFFhCy8yxFVQsPo0
 D5JEQpby0x2huGzoMAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5
 AAAAQOIF6B3N2vtr8QJEPP6n/B02GQj22scPp3D9jc0POEB9jie479Ird2eLhJwNBUG/oP
 IzsO/UOmLR6U1D1LIXRQE=
 -----END SSH SIGNATURE-----

Логировать неудачные попытки

Подпись лежит в заголовке gpgsig и входит в хеш коммита. С проверкой сложнее: у SSH нет сети доверия, и git сверяется со списком, какой ключ кому принадлежит. Пока списка нет, --show-signature пишет ошибку и No signature, хотя подпись есть:

$ git log --show-signature -1
commit fc39b76142c62ba414c0069c23542f975636fbf6 (HEAD -> main)
error: gpg.ssh.allowedSignersFile needs to be configured and exist for ssh signature verification
No signature
Author: Аня <anya@example.com>
Date:   Tue Sep 8 10:00:00 2026 +0300

    Логировать неудачные попытки
$ echo "anya@example.com namespaces=\"git\" $(cat ~/.ssh/id_ed25519.pub)" >> ~/.ssh/allowed_signers
$ git config --global gpg.ssh.allowedSignersFile ~/.ssh/allowed_signers
$ git log --show-signature -1
commit fc39b76142c62ba414c0069c23542f975636fbf6 (HEAD -> main)
Good "git" signature for anya@example.com with ED25519 key SHA256:YHZEp/En6W9lr3i/jNGzoqntsbvM2B1u8wymWtAcMTo
Author: Аня <anya@example.com>
Date:   Tue Sep 8 10:00:00 2026 +0300

    Логировать неудачные попытки

namespaces="git" разрешает ключу только подписи git. Документация git предлагает держать такой файл каждому у себя или собирать централизованно из ключей тех, у кого есть право push. Подписан весь объект без gpgsig: дерево, родитель, автор и коммитер с датами, сообщение. Через хеши дерево закрепляет все файлы, а родитель всю историю до коммита (глава 1.1). Одно изменённое слово ломает подпись:

$ git cat-file commit HEAD | sed 's/неудачные/все/' | git hash-object -t commit -w --stdin
2a973fbfa77837ebd49a2cd34f392495b1e56180
$ git verify-commit 2a973fb; echo $?
Could not verify signature.
Signature verification failed: incorrect signature
1

На другой машине Аня подписывает ключом GPG: там gpg.format по умолчанию, openpgp, в user.signingKey идентификатор ключа, а доверие к ключам ведёт сам gpg:

$ git config --global user.signingKey 772A8D1A37AE7645
$ git config --global commit.gpgSign true
$ git commit -m 'Логировать неудачные попытки'
[main b429038] Логировать неудачные попытки
 1 file changed, 3 insertions(+)
$ git log --show-signature -1
commit b429038d529f8b7aa14c47817044d06e1cca36ae (HEAD -> main)
gpg: Signature made Wed Sep 16 18:39:22 2026 MSK
gpg:                using EDDSA key BC2B702029D7683274529D96772A8D1A37AE7645
gpg: Good signature from "Аня <anya@example.com>" [ultimate]
Author: Аня <anya@example.com>
Date:   Tue Sep 8 10:00:00 2026 +0300

    Логировать неудачные попытки

У подписи GPG своё время, Signature made, с часов подписавшего, отдельно от даты коммита. Статус одной буквой даёт формат %G?: G подпись верна и ключ доверенный; U верна, но чей ключ, неизвестно; B не сходится; N подписи нет. Отметку Verified серверы ставят, если ключ есть в аккаунте и почта коммитера в нём подтверждена, а у GPG эта почта должна быть и в самом ключе. Про почту при SSH прямо пишет GitLab, GitHub описывает это только для GPG.

Что подпись доказывает, а что нет

Боря в ветке timeouts подставил в коммит имя и почту Ани и подписал его своим ключом. В списке у Ани пока только её ключ:

$ git log --show-signature -1 origin/timeouts
commit 78cfa335105d6d04fa4ca80f5f03353f1a43ecb3 (origin/timeouts)
Good "git" signature with ED25519 key SHA256:UxcvKAXY4JOF8IYlqxAAS4euRpHLaPvEYufX5wytApc
No principal matched.
Author: Аня <anya@example.com>
Date:   Tue Sep 8 14:00:00 2026 +0300

    Повторять до пяти раз
$ git log --format='%h %G? %an: %s' main origin/timeouts
78cfa33 U Аня: Повторять до пяти раз
fc39b76 G Аня: Логировать неудачные попытки
488eaf2 N Боря: Создать модуль shop
$ git merge --verify-signatures origin/timeouts
fatal: Commit 78cfa33 has an untrusted GPG signature, allegedly by (null).

Good говорит только о математике: подпись сходится с ключом, записанным в ней самой. Чей это ключ, git не знает, отсюда No principal matched и U, а merge --verify-signatures такую вершину не вливает. Боря присылает свой открытый ключ:

$ echo "borya@example.com namespaces=\"git\" $(cat ~/borya.pub)" >> ~/.ssh/allowed_signers
$ git log --show-signature -1 origin/timeouts
commit 78cfa335105d6d04fa4ca80f5f03353f1a43ecb3 (origin/timeouts)
Good "git" signature for borya@example.com with ED25519 key SHA256:UxcvKAXY4JOF8IYlqxAAS4euRpHLaPvEYufX5wytApc
Author: Аня <anya@example.com>
Date:   Tue Sep 8 14:00:00 2026 +0300

    Повторять до пяти раз

Теперь G: подписал Боря, а в авторе и коммитере Аня. Git проверяет подпись ключом, а с именами в коммите подписанта не сравнивает. Серверы сверяют коммитера по подтверждённой почте. Про автора GitLab пишет прямо: подпись удостоверяет только коммитера. GitHub смотрит на автора лишь в режиме vigilant и ставит Partially verified, если автор не коммитер и сам включил этот режим. Автор и подписант расходятся и честно: cherry-pick, rebase и amend оставляют автора, а подпись ставит тот, кто выполнил команду, или никто. GitHub при Rebase and merge кладёт коммиты в базовую ветку без подписи, а правило GitLab Reject unsigned commits не касается коммитов, созданных самим GitLab через интерфейс и API.

С датой то же. Ключ Ани утёк, и в её строку в allowed_signers дописали valid-before="20260910" (сроки файл понимает с OpenSSH 8.8). Оба коммита ниже подписаны этим ключом уже после 10 сентября, различаются только даты внутри:

$ git log --no-walk --format='%h %G? %cd %s' --date=short leak-12 leak-09
5180214 U 2026-09-12 Повторять до тридцати раз
60d8993 G 2026-09-09 Повторять до тридцати раз

Своего времени у SSH-подписи нет, срок ключа git сверяет с датой коммитера (verify time 2026-09-12T10:00:00 > valid-before 2026-09-10T00:00:00), а её пишет тот, кто коммитит. От утечки спасает отзыв: подпись ключом из gpg.ssh.revocationFile получает B. GitHub при этом хранит запись о прошлой проверке, и Verified остаётся, а GitLab после отзыва SSH-ключа помечает его коммиты Unverified.

Что удостоверяет подпись

Объект коммита, то есть снимок, история до него, имена, даты и сообщение, не менялся с тех пор, как его подписали этим закрытым ключом. Кому принадлежит ключ, решает список ключей или аккаунт на сервере. Подпись не доказывает, что код написал человек из поля author, что коммит сделан в указанный день, что его смотрели, что он безопасен и что ключ не украден.

Вопросы

4
Суть: по двум вопросам: сколько версий продукта живёт одновременно и как часто выходят релизы. При одной версии в проде и выкатке по готовности хватает GitHub Flow или trunk-based с релизом из ствола. Если чинить надо несколько версий, к стволу добавляют релизные ветки с cherry-pick. Git Flow, по словам его автора, подходит продукту с явными версиями или с несколькими поддерживаемыми версиями.

Чем модели отличаются

Git FlowGitHub FlowTrunk-based
Долгоживущие веткиmaster и developmainmain, релизные по необходимости
Рабочая ветка живётпока делается фичадо слияния pull requestне дольше пары дней
Откуда релизслияние release-ветки в master с тегомиз main после слиянияиз ствола или из релизной ветки
Срочное исправлениеhotfix от тега, слияние в master и developобычная ветка и pull requestв стволе, потом cherry-pick в релизную ветку
Незаконченная работав feature-веткев веткев стволе за флагом

Как рассуждать

Go-сервису, который выкатывают из CI несколько раз в неделю, хватает main и коротких веток. Если ветки живут неделями, дело в размере задач, и помогают нарезка и флаги, а не смена модели. Go-библиотека обходится тегами vX.Y.Z на main (глава 4.3), а релизная ветка нужна, когда старую версию приходится чинить, а main ушёл дальше. Так живёт и сам Go. Git Flow к месту у продукта с релизами раз в несколько недель и версиями, установленными у клиентов; платят за него двойными слияниями и поздней встречей develop с продом.

«Работаем по Git Flow», а релизных веток нет

Часто от Git Flow остаётся одна develop перед main, которую раз в спринт вливают целиком. Срочное исправление коммитят прямо в main, и develop о нём не знает, пока main не вольют обратно. Из всей модели выжила лишняя долгоживущая ветка.

Суть: резать на изменения, после каждого из которых main собирается и работает: рефакторинг, тесты, потом поведение, незаконченное за флагом. Чтобы не ждать, следующий MR строят на ветке предыдущего и нацеливают на неё. После squash-merge нижнего MR верхнюю ветку надёжнее переносить через git rebase --onto: обычный rebase применит коммиты нижней заново.

Как нарезать

Повторы запросов в shop легли в четыре MR: withRetry с тестами, вызов из findOrder, логирование неудачных попыток, метрика за выключенным флагом, и после каждого сервис работает. Ориентир Google: около 100 строк обычно нормально, 1000 обычно много.

Стопка

MR ветки retry-log нацелен на retry, и git diff retry...retry-log показывает ревьюеру только его слой. Что делать после слияния нижнего, зависит от способа. Если его влили merge-коммитом, git rebase main перенесёт только коммиты верхней ветки, если rebase-ом, пропустит уже применённые. После squash-merge rebase применит коммиты нижней ветки заново и здесь упрётся в конфликт add/add, поэтому границу указывают явно и отправляют ветку через git push --force-with-lease:

$ git rebase --onto origin/main retry
Successfully rebased and updated refs/heads/retry-log.
$ git diff --stat origin/main...retry-log
 retry.go | 3 +++
 1 file changed, 3 insertions(+)
Один MR на одну задачу из трекера

trunkbaseddevelopment.com называет это правило ошибкой. Одна задача спокойно даёт три MR: рефакторинг, новая функциональность, ещё рефакторинг, каждый в своей короткой ветке и со ссылкой на задачу.

Суть: CI в MR проверяет снимок вершины ветки, а в main попадает результат слияния, который никто не собирал. Два зелёных MR вместе ломают сборку без единого конфликта. Проверять надо то, что окажется в main: требовать свежую ветку, гонять CI на пробном слиянии или ставить MR в очередь. А защита ветки закрывает пути мимо MR.

Как это получается

Аня переименовала findOrder в lookupOrder вместе с вызовами, Боря в другой ветке добавил вызов findOrder. Обе ветки собираются, слияние проходит чисто, а сборка main падает: ./pay.go:4:9: undefined: findOrder. Merge сравнивает строки, а не смысл кода (глава 3.1).

Что настроить

  • Свежая ветка. Require branches to be up to date before merging в GitHub не даст влить ветку, пока в её историю не входит текущий main.
  • Пробное слияние. Merged results pipelines в GitLab проверяют временный коммит слияния с текущей целевой веткой, но MR, влитые почти одновременно, друг друга не видят.
  • Очередь. Merge train в GitLab и merge queue в GitHub проверяют MR вместе со всеми, кто стоит перед ним: A, A+B, A+B+C.
  • Защита ветки. В main только через MR: в GitLab Allowed to push and merge: No one, в GitHub Do not allow bypassing, иначе администраторы проходят мимо правил. В GitLab ещё включают Pipelines must succeed в настройках проекта.
В GitLab решает pipeline MR, а не ветки

Если для MR запускаются pipeline разных типов, Pipelines must succeed смотрит на pipeline merge request. По документации GitLab старый успешный pipeline MR разрешает влить ветку, даже если pipeline ветки, запущенный позже, упал.

Суть: подпись доказывает, что объект коммита целиком (дерево, родители, автор, коммитер, даты, сообщение) не менялся после подписи конкретным закрытым ключом. Чей это ключ, решает список доверенных ключей или аккаунт на сервере. Автора, дату, ревью и безопасность кода подпись не удостоверяет, а украденный ключ подписывает так же хорошо.

Что покрывает и как проверить

Подпись в заголовке gpgsig считается от объекта коммита без него, а в объекте хеши дерева и родителя, так что закреплены все файлы и вся история до коммита. Для SSH нужен gpg.ssh.allowedSignersFile, для GPG ключ в keyring с доверием. Проверяют git verify-commit, буквой %G? и git merge --verify-signatures.

Чего подпись не доказывает

  • Авторства. Коммит с автором и коммитером Аней, подписанный ключом Бори, git помечает G с Good "git" signature for borya@example.com. Серверы сверяют коммитера, автора GitHub учитывает только в режиме vigilant.
  • Даты. Срок SSH-ключа valid-before git сверяет с датой коммитера, которую пишет сам подписывающий, и коммит задним числом проходит.
  • Ревью и качества. Подпись ставят до MR, о проверках она ничего не знает.
  • Того, что ключ не украден. Спасает отзыв: ключ из gpg.ssh.revocationFile даёт B.
  • Что подпись переживёт переписывание. Rebase, cherry-pick и amend создают коммиты с подписью выполнившего или без неё, GitHub при Rebase and merge кладёт коммиты без подписи.
«Good» ещё не значит «доверенный»

Для ключа не из allowed_signers git пишет Good "git" signature with ED25519 key … и следом No principal matched. При этом verify-commit возвращает 1, а %G? даёт U. Скрипт, который ищет в выводе слово Good, пропустит чужой ключ; проверяют букву G. Код возврата надёжен для SSH, а у GPG verify-commit возвращает 0 и при недоверенном ключе, пока не задан gpg.minTrustLevel.

Что стоит запомнить

  • Merge request сравнивает source и target относительно merge-base.
  • Защищённая ветка контролирует серверные операции, а не локальный Git.
  • Подпись подтверждает владение ключом и целостность commit, но не истинность имени автора.

Частые ошибки

  • Squash меняет commit graph и может нарушить основание стопки зависимых MR.
  • Зелёные проверки отдельных MR не доказывают, что итоговая комбинация собирается.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Прочитай MR diff после продвижения target

MR показывает изменения относительно общего предка, хотя main уже продвинулся. Что не покрывает такой diff и какую проверку запускают перед merge?

merge-base(source, target) = A
source = A + feature
target = A + main-fix
MR diff = A..source
Ответ и разбор

Diff показывает изменения source относительно общего предка A, но не результат совместного слияния с новым main-fix. Запусти pipeline на merge result/merge train или обнови source ветку от актуального target и проверь получившееся дерево перед merge.

Код-ревью

2. Сохрани зависимые MR после squash

MR B основан на исходной верхушке A. MR A влили squash-коммитом в main, который не является потомком исходных commits A. Как безопасно перенести только B и проверить diff?

old graph: main: M0, branch-A: M0-A1-A2, branch-B: M0-A1-A2-B1-B2
after squash: main: M0-S, branch-B: M0-A1-A2-B1-B2
old-A-tip = A2
git branch backup/feature-B feature-B
git rebase --onto origin/main <old-A-tip> feature-B
git range-diff <old-A-tip>..backup/feature-B origin/main..feature-B
Ответ и разбор

Сохрани backup, затем перенеси только диапазон после старой верхушки A: git rebase --onto origin/main A2 feature-B. Так B1 и B2 станут потомками squash-коммита, а изменения A не попадут в MR второй раз. Сравни старый и новый диапазоны через git range-diff и проверь целевой diff перед push.

Применение

3. Выбери модель для частых коротких релизов

Команда ежедневно выпускает небольшие изменения, держит main готовой к выкладке и использует feature flags. Какая модель ветвления сюда подходит и что она требует от команды?

Ответ и разбор

Подходит trunk-based development: короткие ветки быстро сливаются в main, незавершённое поведение прячется за feature flags. Нужны автоматические проверки, небольшие изменения и готовность быстро выключить флаг; длинные интеграционные ветки вернут расхождение и дорогие merge.

Применение

4. Оцени смысл подписи

Подпись commit проверена ключом K, который зарегистрирован у сотрудника. Что проверка доказывает, а что остаётся проверить при расследовании?

Ответ и разбор

Подпись подтверждает, что commit object не изменили после подписания и что подписант контролировал приватный ключ, соответствующий K. Она не доказывает, что ключ в этот момент использовал именно сотрудник, что содержимое безопасно или что автора в поле author установили верно. Сверь trust policy, журнал ключей и контекст изменения.