Тема 04

Удалённые репозитории и работа в команде

Пока работаешь один, весь git живёт в одном каталоге. В команде появляется сервер, и большая часть сюрпризов растёт из того, что твой клон узнаёт о нём с опозданием. С этого статья и начинает: remote и refspec, remote-tracking ветки, fetch и pull, отказ push и три способа отправить переписанную ветку силой. Потом командный процесс: merge request и ревью, модели ветвления от Git Flow до trunk-based, защищённые ветки, CODEOWNERS и подписанные коммиты. Закрывают статью теги и Go-модули: как go get выбирает версию, откуда берутся псевдоверсии, зачем /v2 в пути модуля и как подключить приватный репозиторий.

Что реально проверяют этой секцией

Работал ли ты в команде. Вопросы звучат просто: чем fetch отличается от pull, что делать с отклонённым push, почему после rebase нужен --force-with-lease, а не --force, как ветка доходит до main. По ответам видно, помнишь ли ты, что у каждого участника своя копия истории и что одной неосторожной командой можно стереть чужую работу. Go-разработчика спросят ещё и про версии модулей. Опубликованный тег не переносят, и надо уметь объяснить почему.

4.1Remote

Для git сервер такой же репозиторий, как твой клон, а связь с ним держат три записи в .git/config: адрес, правило раскладки ссылок и upstream у каждой ветки. Клон не следит за сервером, он помнит, каким тот был при последнем обмене. Отсюда «up to date» при свежем чужом push, отказ push и разница между --force и --force-with-lease.

Что сможешь объяснить после этой главы
  • Что git clone пишет в конфиг и почему локальная ветка после клона одна.
  • Как читается refspec и почему в клоне с --depth 1 не находится соседняя ветка.
  • Чем origin/main отличается от main, что меняет fetch и что добавляет pull.
  • Почему сервер отклоняет push и чем «fetch first» отличается от «non-fast-forward».
  • Когда --force-with-lease спасает чужой коммит, когда нет и что к нему добавляет --force-if-includes.

Клон: имя, адрес и ветки сервера

Примеры главы идут на Go-сервисе shop. Сервер стоит на той же машине: git http-backend отдаёт голый репозиторий (bare, без рабочей копии) по smart HTTP, как GitLab и GitHub при клоне по HTTPS. Боря уже отправил туда main с двумя коммитами и ветку metrics. Второго сентября Аня клонирует проект:

$ git clone http://localhost:8765/shop.git
Cloning into 'shop'...
remote: Enumerating objects: 12, done.
remote: Counting objects: 100% (12/12), done.
remote: Compressing objects: 100% (11/11), done.
remote: Total 12 (delta 2), reused 0 (delta 0), pack-reused 0 (from 0)
Receiving objects: 100% (12/12), done.
Resolving deltas: 100% (2/2), done.
$ cd shop
$ git remote -v
origin	http://localhost:8765/shop.git (fetch)
origin	http://localhost:8765/shop.git (push)
$ git branch -a
* main
  remotes/origin/HEAD -> origin/main
  remotes/origin/main
  remotes/origin/metrics

Строки с remote: печатает сервер, пока собирает pack-файл (глава 1.4), остальные печатает клиент, пока его принимает. Такие счётчики есть в каждом обмене, где передаются объекты, дальше в выводе push и fetch они опущены. Клон по пути к каталогу (git clone ../srv/shop.git) идёт в обход протокола, после Cloning into там только done.: git копирует объекты напрямую, связывая файлы жёсткими ссылками.

Remote даёт короткое имя адресу другого репозитория. clone заводит одно, origin, и git remote -v печатает его адрес дважды: откуда забирать и куда отправлять. Адреса разойдутся, если задать отдельный remote.origin.pushurl. Сервер отдал все ветки, но локальная появилась одна. Остальные лежат под remotes/origin/: это remote-tracking ветки, локальные ссылки, в которых клон помнит, где стояли ветки сервера при последнем обмене. В конфиг клон дописал вот что:

$ tail -n 6 .git/config
[remote "origin"]
	url = http://localhost:8765/shop.git
	fetch = +refs/heads/*:refs/remotes/origin/*
[branch "main"]
	remote = origin
	merge = refs/heads/main

url говорит, куда ходить. fetch хранит refspec, правило раскладки ссылок сервера по локальным именам. Секция [branch "main"] назначает ветке upstream, о нём раздел ниже, а про origin/HEAD было в главе 1.2.

Remote бывает и не один. В клоне форка исходный проект подключают вторым, git remote add upstream <адрес>, и его ветки едут в refs/remotes/upstream/. Это имя из документации GitHub, с upstream ветки из этой главы оно не связано. Если ветка есть в обоих remote, git switch metrics откажет (matched multiple), и remote называют сами: git switch --track origin/metrics или checkout.defaultRemote=origin в конфиге.

Refspec: какие ссылки куда класть

Строку +refs/heads/*:refs/remotes/origin/* делит двоеточие. Слева ссылки на сервере, справа имена, под которыми они лягут в клоне, а звёздочка переносит хвост имени: refs/heads/metrics сервера становится refs/remotes/origin/metrics. Плюс разрешает перезаписать локальную ссылку, даже если новое значение не потомок старого. Без плюса fetch отказался бы обновить origin/metrics после чужого rebase и напечатал бы ! [rejected] metrics -> origin/metrics (non-fast-forward). Какие ссылки есть на сервере, показывает git ls-remote, ничего не скачивая:

$ git ls-remote origin
af76ac493a46ae06b87e04e9efa081d5da3f693b	HEAD
af76ac493a46ae06b87e04e9efa081d5da3f693b	refs/heads/main
1d477fd3a5b0963b561de14c333507dccd8ad1ac	refs/heads/metrics
1d477fd3a5b0963b561de14c333507dccd8ad1ac	refs/merge-requests/1/head

Последняя ссылка устроена как на GitLab, который заводит refs/merge-requests/<номер>/head на каждый merge request (здесь её создали руками). Под refs/heads/* она не подпадает, поэтому в клон не приехала. Второе правило дописывают ещё одной строкой fetch:

$ git config --add remote.origin.fetch '+refs/merge-requests/*/head:refs/remotes/origin/mr/*'
$ git fetch
From http://localhost:8765/shop
 * [new ref]         refs/merge-requests/1/head -> origin/mr/1
$ git branch -r
  origin/HEAD -> origin/main
  origin/main
  origin/metrics
  origin/mr/1

Теперь код merge request открывается локально через git switch --detach origin/mr/1, даже если своей ветки у него в этом репозитории нет. Refspec можно написать и прямо в команде: git fetch origin metrics значит «забери ветку metrics», правая часть пустая.

$ git fetch origin metrics
From http://localhost:8765/shop
 * branch            metrics    -> FETCH_HEAD

Результат лёг в .git/FETCH_HEAD. Если ветка подходит под правило из remote.origin.fetch, git с версии 1.8.4 попутно сдвигает и её remote-tracking ветку (здесь origin/metrics и так свежая). Без правила remote-tracking ветки нет.

В клоне с --depth 1 соседней ветки нет

--depth включает --single-branch, и clone записывает правило только для одной ветки. Забрать другую по имени получится, но remote-tracking ветки у неё не будет:

$ git clone -q --depth 1 http://localhost:8765/shop.git
$ cd shop
$ git config --get-all remote.origin.fetch
+refs/heads/main:refs/remotes/origin/main
$ git fetch origin metrics
From http://localhost:8765/shop
 * branch            metrics    -> FETCH_HEAD
$ git switch metrics
fatal: invalid reference: metrics
$ git remote set-branches --add origin metrics
$ git fetch
From http://localhost:8765/shop
 * [new branch]      metrics    -> origin/metrics

set-branches --add дописал второе правило, и ветка приехала. На том же спотыкаются шаги CI, которые сравнивают ветку merge request с origin/main в неглубоком клоне: нет правила, нет и origin/main. Про глубину истории в CI есть вопрос в главе 1.4.

fetch: забрать и ничего не трогать

Пока Аня возилась с refspec, Боря отправил в main коммит. Клон Ани об этом не знает:

$ git status
On branch main
Your branch is up to date with 'origin/main'.

nothing to commit, working tree clean

«Up to date» значит «совпадает с origin/main», а origin/main показывает туда же, куда при клонировании. status в сеть не ходит. Сервер спрашивает git fetch:

$ git fetch
From http://localhost:8765/shop
   af76ac4..d088609  main       -> origin/main
$ git status
On branch main
Your branch is behind 'origin/main' by 1 commit, and can be fast-forwarded.
  (use "git pull" to update your local branch)

nothing to commit, working tree clean

Строка fetch описывает одну ссылку: флаг, старое и новое значение, откуда и куда. Флаги из этой главы:

ФлагЧто случилось со ссылкойКак выглядит
пробелперемотана вперёдaf76ac4..d088609 main -> origin/main
*новая ссылка[new branch] metrics -> origin/metrics
+перезаписана не вперёд, между хешами три точкиb6a0d07...a0dc01d retry -> origin/retry (forced update)
-удалена при --prune[deleted] (none) -> origin/metrics
tтег переставлен[tag update] v0.2.0 -> v0.2.0, глава 4.3
!отклонена[rejected] metrics -> origin/metrics (non-fast-forward)

fetch докачал недостающие объекты и сдвинул origin/main. Локальная main, индекс и файлы остались как были: ветки под refs/heads/ fetch трогает, только если правая часть refspec прямо на них указывает. Поэтому его можно звать когда угодно, хоть посреди незакоммиченной работы. Что пришло, показывает диапазон из главы 1.2, а список всего принесённого лежит в FETCH_HEAD (первая колонка с хешами здесь отрезана):

$ git log --oneline main..origin/main
d088609 (origin/main, origin/HEAD) Отдавать версию в /health
$ cut -f 2- .git/FETCH_HEAD
	branch 'main' of http://localhost:8765/shop
not-for-merge	branch 'metrics' of http://localhost:8765/shop
not-for-merge	'refs/merge-requests/1/head' of http://localhost:8765/shop

Первая строка без пометки not-for-merge: это upstream текущей ветки, её и вливает git pull. Аня вливает сама, и слияние выходит перемоткой (глава 3.1):

$ git merge origin/main
Updating af76ac4..d088609
Fast-forward
 health.go | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
Сервер: shop.git Клон Ани: .git Рабочая копия main af76ac4 d088609 push Бори: main → d088609 main origin/main af76ac4 d088609 fetch принёс d088609 и сдвинул origin/main main остался на af76ac4 снимок af76ac4 health.go: "ok" fetch файлы не трогает, их обновит merge git fetch merge
Одна ветка в трёх местах. Push Бори сдвинул main на сервере. fetch принёс новый коммит в клон Ани и сдвинул только origin/main, а main и файлы догонят сервер после слияния.

Upstream: с какой веткой сервера связана твоя

Аня заводит ветку для повторов запросов к базе и пишет store.go. У новой ветки нет пары на сервере, и push без аргументов не знает, куда отправлять:

$ git switch -c retry
Switched to a new branch 'retry'
$ git add store.go
$ git commit -qm "Повторять запрос к базе"
$ git push
fatal: The current branch retry has no upstream branch.
To push the current branch and set the remote as upstream, use

    git push --set-upstream origin retry

To have this happen automatically for branches without a tracking
upstream, see 'push.autoSetupRemote' in 'git help config'.
$ git push -u origin retry
To http://localhost:8765/shop.git
 * [new branch]      retry -> retry
branch 'retry' set up to track 'origin/retry'.
$ git config --get-regexp '^branch\.retry\.'
branch.retry.remote origin
branch.retry.merge refs/heads/retry

Эти две строки и задают upstream, ветку сервера, с которой связана локальная. В клоне её представляет origin/retry. -u (длинно --set-upstream) записал их при первом push, и дальше по ним работают команды без аргументов: push и pull знают, куда и откуда, status знает, с чем сравнивать. В командах upstream пишется @{u}:

$ git commit -qam "Ограничить число повторов"
$ git status -sb
## retry...origin/retry [ahead 1]
$ git rev-parse --abbrev-ref @{u}
origin/retry
$ git rev-list --left-right --count @{u}...HEAD
0	1
$ git log --oneline @{u}..
c52f3d6 (HEAD -> retry) Ограничить число повторов
$ git push
To http://localhost:8765/shop.git
   6e5288e..c52f3d6  retry -> retry

Ahead и behind git считает обходом с тремя точками из главы 1.2: слева коммиты только upstream, справа только твоей ветки. Считает по origin/retry, так что «behind» без fetch устаревает, а вот git log @{u}.. не врёт и без него: свой push сдвигает origin/retry сам.

Upstream появляется и без -u. Если локальной ветки нет, а remote-tracking с таким именем есть ровно в одном remote, git switch создаёт ветку и сразу связывает её:

$ git switch metrics
branch 'metrics' set up to track 'origin/metrics'.
Switched to a new branch 'metrics'
$ git branch -vv
  main    d088609 [origin/main] Отдавать версию в /health
* metrics 1d477fd [origin/metrics] Считать запросы
  retry   c52f3d6 [origin/retry] Ограничить число повторов

Куда уходит git push без аргументов, решает push.default. С git 2.0 там simple: git отправляет текущую ветку в её upstream, и только если имена совпадают. В другой remote (git push fork) ветка уйдёт в одноимённую и без upstream. Второе условие всплывает, когда ветку начинают прямо от remote-tracking:

$ git switch -c hotfix origin/main
branch 'hotfix' set up to track 'origin/main'.
Switched to a new branch 'hotfix'

Upstream ветки hotfix теперь origin/main, и push по upstream ушёл бы прямо в main сервера. git push отказывается со словами fatal: The upstream branch of your current branch does not match the name of your current branch. Чинят одним из двух способов: git push -u origin hotfix перепишет upstream на origin/hotfix, а ветка, созданная с --no-track, его вовсе не получит. Настройка push.autoSetupRemote=true (git 2.37) сама добавит -u при первом push ветки без upstream, и после --no-track хватит простого git push. Ветку hotfix после опыта Аня удалила и вернулась в main.

Push: сервер ждёт перемотку

Третьего сентября Аня коммитит в main логирование запросов. Боря в это время поднимает таймаут сервера и отправляет первым. Push Ани:

$ git push
To http://localhost:8765/shop.git
 ! [rejected]        main -> main (fetch first)
error: failed to push some refs to 'http://localhost:8765/shop.git'
hint: Updates were rejected because the remote contains work that you do not
hint: have locally. This is usually caused by another repository pushing to
hint: the same ref. If you want to integrate the remote changes, use
hint: 'git pull' before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
$ git fetch
From http://localhost:8765/shop
   d088609..23d68bc  main       -> origin/main
$ git push
To http://localhost:8765/shop.git
 ! [rejected]        main -> main (non-fast-forward)
error: failed to push some refs to 'http://localhost:8765/shop.git'
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart. If you want to integrate the remote changes,
hint: use 'git pull' before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
$ git status -sb
## main...origin/main [ahead 1, behind 1]

Push начинается с того, что сервер перечисляет свои ссылки с хешами. Клиент сравнивает вершину сервера с тем, что собирается отправить, и без флагов принуждения пропускает только перемотку (fast-forward, глава 3.1): новая вершина должна быть потомком старой. Иначе коммит, на который указывала ветка сервера, выпал бы из неё.

Два текста отказа различаются тем, что клиент знает о чужом коммите. В первый раз 23d68bc у Ани не было, и проверить родство git не мог: fetch first. После fetch коммит есть, и ответ точный:

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

origin/main не предок main, ветки разошлись: non-fast-forward. Проверять умеет и сам сервер. Голый репозиторий с receive.denyNonFastForwards=true не пропустит даже --force: объекты он примет, ссылку не сдвинет и ответит ! [remote rejected] main -> main (non-fast-forward). Защищённые ветки GitLab и GitHub по умолчанию так же запрещают принудительный push (глава 4.2).

pull: fetch, а потом merge или rebase

git pull запускает git fetch и вливает upstream в текущую ветку. Пока ветки не разошлись, он просто перематывает. Разошлись, и git 2.54 без настройки не выбирает за тебя:

$ git pull
hint: You have divergent branches and need to specify how to reconcile them.
hint: You can do so by running one of the following commands sometime before
hint: your next pull:
hint:
hint:   git config pull.rebase false  # merge
hint:   git config pull.rebase true   # rebase
hint:   git config pull.ff only       # fast-forward only
hint:
hint: You can replace "git config" with "git config --global" to set a default
hint: preference for all repositories. You can also pass --rebase, --no-rebase,
hint: or --ff-only on the command line to override the configured default per
hint: invocation.
fatal: Need to specify how to reconcile divergent branches.

Отказ появился в git 2.33.1. Раньше pull делал merge-коммит, а с 2.27 ещё и предупреждал об этом. Вот что получается слиянием:

$ git pull --no-rebase
Merge made by the 'ort' strategy.
 main.go | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)
$ git log --oneline --graph -4
*   282f10f (HEAD -> main) Merge branch 'main' of http://localhost:8765/shop
|\
| * 23d68bc (origin/main, origin/HEAD) Поднять таймаут до 5 секунд
* | b120e8a Логировать запросы
|/
* d088609 Отдавать версию в /health

Ветка main слилась сама с собой. За узлом «Merge branch 'main' of …» нет никакого решения, два человека просто коммитили в одну ветку в одно и то же утро, а log --graph и bisect теперь обходят лишнюю развилку. Аня отменяет слияние (reset разобран в главе 5.1) и переставляет свой коммит поверх коммита Бори:

$ git reset --hard ORIG_HEAD
HEAD is now at b120e8a Логировать запросы
$ git pull --rebase
Successfully rebased and updated refs/heads/main.
$ git log --oneline --graph -3
* 2db9371 (HEAD -> main) Логировать запросы
* 23d68bc (origin/main, origin/HEAD) Поднять таймаут до 5 секунд
* d088609 Отдавать версию в /health
$ git push
To http://localhost:8765/shop.git
   23d68bc..2db9371  main -> main

У коммита Ани новый хеш 2db9371, потому что у него новый родитель (глава 3.3). Переписывать его можно было спокойно: b120e8a на сервер не попал. Чтобы не выбирать каждый раз, способ задают в конфиге:

НастройкаВетки разошлисьКому подходит
pull.rebase=trueсвои коммиты переносятся поверх пришедшихобщая ветка, где нужна линейная история
pull.rebase=falsemerge-коммиттем, кто хочет видеть, где работа шла параллельно
pull.ff=onlyотказ после fetch: ветка и файлы не тронутытем, кто после fetch решает сам

Если в конфиге и pull.rebase=true, и pull.ff=only, git 2.54 на разошедшихся ветках откажет с Not possible to fast-forward, а вот --rebase в командной строке pull.ff=only перебивает. Вариант с rebase строже к рабочей копии: с незакоммиченными правками он не начнётся (cannot pull with rebase: You have unstaged changes), слияние же терпит правки в файлах, которых не касается. --autostash или rebase.autoStash=true уберут правки в stash (глава 2.2) на время pull и вернут после.

pull --rebase помнит, где стояла ветка сервера

Коллега переписал ветку, поверх которой лежат твои коммиты. git rebase origin/topic попробует перенести и его старые коммиты, а git pull --rebase и git rebase без аргументов ищут точку отсчёта в reflog remote-tracking ветки (git merge-base --fork-point) и переносят только сделанное поверх её прежней вершины. В опыте, где коллега исправил свой коммит, git rebase origin/topic встал на конфликте в этом коммите, а git pull --rebase перенёс один коммит без вопросов. Вручную то же делает rebase --onto из главы 3.3.

--force, --force-with-lease и --force-if-includes

Отказ non-fast-forward бывает ожидаемым: ты сам переписал свою ветку. У Ани открыт merge request из retry, а main за это время ушла вперёд. Четвёртого сентября в 10:00 Боря прямо на ревью дописал в её ветку проверку отмены контекста и отправил. В 10:30 Аня, не делая fetch, переносит ветку на свежую main:

$ git switch retry
Switched to branch 'retry'
Your branch is up to date with 'origin/retry'.
$ git rebase main
Successfully rebased and updated refs/heads/retry.

Обычный git push отклонён с (fetch first), как в разделе выше: коммита Бори у Ани нет. --force снимает проверку целиком. Его прогоним на копии сервера и обоих клонов, а после опыта вернём всё как было:

$ git push --force
To http://localhost:8765/shop.git
 + b6a0d07...a0dc01d retry -> retry (forced update)

У Бори после fetch:

$ git fetch
From http://localhost:8765/shop
 + b6a0d07...a0dc01d retry      -> origin/retry  (forced update)
$ git log --oneline -3 origin/retry
a0dc01d (origin/retry) Ограничить число повторов
09f8682 Повторять запрос к базе
2db9371 (origin/main, origin/HEAD) Логировать запросы

Коммита b6a0d07 в ветке сервера больше нет, и никто ни о чём не предупредил. Он уцелел только в клоне Бори. Дальше снова настоящие репозитории, где --force не звали.

--force-with-lease ставит условие: перезаписать ветку сервера, только если она стоит там, где её последний раз видел твой клон, то есть на origin/retry. У Ани там c52f3d6, на сервере b6a0d07:

$ git push --force-with-lease
To http://localhost:8765/shop.git
 ! [rejected]        retry -> retry (stale info)
error: failed to push some refs to 'http://localhost:8765/shop.git'

Защита работает, пока origin/retry сдвигается только тогда, когда ты сам посмотрел на новые коммиты. Фоновый fetch это ломает: IDE, которая забирает изменения по таймеру (в VS Code это git.autofetch), или git fetch, набранный между делом:

$ git fetch
From http://localhost:8765/shop
   c52f3d6..b6a0d07  retry      -> origin/retry

Ветка Ани не изменилась, коммита Бори в ней нет, но ожидание lease теперь совпадает с сервером. git push --force-with-lease выкинул бы b6a0d07 так же, как --force: на копии --dry-run ответил forced update. Эту дыру закрывает --force-if-includes из git 2.30:

$ git push --force-with-lease --force-if-includes
To http://localhost:8765/shop.git
 ! [rejected]        retry -> retry (remote ref updated since checkout)
error: failed to push some refs to 'http://localhost:8765/shop.git'
hint: Updates were rejected because the tip of the remote-tracking branch has
hint: been updated since the last checkout. If you want to integrate the
hint: remote changes, use 'git pull' before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.

Второй флаг проверяет, что вершина origin/retry достижима из какой-нибудь записи reflog локальной retry (журнал перемещений ветки, глава 5.2). Грубо говоря, этот коммит хоть раз был в твоей ветке, а b6a0d07 там не был. Сам по себе флаг ничего не делает: он работает только вместе с --force-with-lease без явного хеша.

23d68bc 2db9371 09f8682 a0dc01d main retry у Ани origin/retry retry на сервере d088609 6e5288e c52f3d6 b6a0d07 09f8682, a0dc01d: копии коммитов Ани после rebase b6a0d07: коммит Бори, в ветке Ани его нет git push --force сервер: retry → a0dc01d, коммит b6a0d07 выпал --force-with-lease ждёт c52f3d6, на сервере b6a0d07: stale info фоновый fetch, потом --force-with-lease ждёт b6a0d07 и дожидается: коммит Бори выпадет --force-with-lease --force-if-includes b6a0d07 нет в reflog retry: отказ
Кто спасает коммит Бори. Ветку Ани перенесли на свежую main, а на сервере в retry успел появиться коммит Бори. --force его выбрасывает. Lease спасает, пока origin/retry не сдвинул фоновый fetch, а --force-if-includes ещё и проверяет, был ли коммит в ветке Ани.

Чтобы push прошёл честно, коммит Бори надо забрать в ветку. Проще всего поставить ветку на серверную версию и повторить rebase:

$ git log --oneline ORIG_HEAD..origin/retry
b6a0d07 (origin/retry) Не повторять после отмены контекста
$ git reset --hard origin/retry
HEAD is now at b6a0d07 Не повторять после отмены контекста
$ git rebase main
Successfully rebased and updated refs/heads/retry.
$ git push --force-with-lease --force-if-includes
To http://localhost:8765/shop.git
 + b6a0d07...7ffc204 retry -> retry (forced update)

ORIG_HEAD после rebase хранит прежнюю вершину ветки (глава 3.3), поэтому диапазон показал ровно то, что добавили на сервере. После reset вершина b6a0d07 попала в reflog retry, и проверка прошла. Набирать оба флага не обязательно: git config push.useForceIfIncludes true добавляет второй к каждому --force-with-lease.

Подсказка «use git pull» здесь приводит к копиям коммитов из main

После отказа git советует git pull. На копии git pull --rebase перенёс на origin/retry всё, чего там не было, включая два коммита из main. В ветке появились копии «Поднять таймаут до 5 секунд» и «Логировать запросы» с новыми хешами, а push стал обычной перемоткой: его пропустил бы и простой git push, --force-if-includes ничего не решал. А аккуратный git cherry-pick origin/retry проверку не проходит: у копии другой хеш, и вершина сервера в reflog не попадает. Проверка смотрит на граф, а не на содержимое.

Удалить ветку на сервере и убрать следы у себя

Пятого сентября Боря влил metrics в main через squash (глава 3.3) и удалил ветку на сервере. GitLab и GitHub умеют удалять исходную ветку сами после слияния, если это включить. Из командной строки так:

$ git push origin --delete metrics
To http://localhost:8765/shop.git
 - [deleted]         metrics

Для git удаление тоже push, с пустой левой частью refspec: git push origin :metrics делает то же самое. Клон Ани об этом не узнал, и обычный fetch ему не расскажет:

$ git fetch
From http://localhost:8765/shop
   2db9371..525eda2  main       -> origin/main
$ git branch -a
  main
  metrics
* retry
  remotes/origin/HEAD -> origin/main
  remotes/origin/main
  remotes/origin/metrics
  remotes/origin/mr/1
  remotes/origin/retry
$ git fetch --prune
From http://localhost:8765/shop
 - [deleted]         (none)     -> origin/metrics

По умолчанию fetch remote-tracking ветки только заводит и сдвигает. --prune удаляет те, которых на сервере больше нет, вместе с их reflog. Локальная metrics при этом остаётся, только upstream у неё пропал:

$ git branch -vv
  main    2db9371 [origin/main: behind 1] Логировать запросы
  metrics 1d477fd [origin/metrics: gone] Считать запросы
* retry   7ffc204 [origin/retry] Не повторять после отмены контекста
$ git branch -d metrics
error: the branch 'metrics' is not fully merged
hint: If you are sure you want to delete it, run 'git branch -D metrics'
hint: Disable this message with "git config set advice.forceDeleteBranch false"
$ git branch -D metrics
Deleted branch metrics (was 1d477fd).

-d удаляет ветку, только если она влита в свой upstream, а без upstream в HEAD. После squash в main лежит новый коммит с тем же содержимым, а 1d477fd по графу никуда не влит. Где сливают через squash, -D приходится звать самому, сверившись с main. Ветки с пропавшим upstream для скрипта перечисляет git for-each-ref --format='%(refname:short) %(upstream:track)' refs/heads, у них в конце стоит [gone].

Чтобы не вспоминать про --prune, его включают в конфиге: git config fetch.prune true в этом клоне, с --global во всех. Теги так не чистятся, им нужен отдельный --prune-tags (глава 4.3).

Вопросы

4
Суть: fetch скачивает объекты и сдвигает remote-tracking ветки, больше ничего: локальные ветки, индекс и файлы остаются как были. pull делает тот же fetch и сразу вливает upstream в текущую ветку слиянием или rebase. Когда двое коммитят в одну ветку, слияние склеивает её саму с собой, отсюда «Merge branch 'main' of …».

Что меняет каждая команда

Чтоgit fetchgit pull
объектыдокачиваетдокачивает
refs/remotes/origin/*, FETCH_HEADобновляетобновляет
текущая веткане трогаетперематывает, сливает или переносит коммиты
индекс и рабочая копияне трогаетобновляет, может остановиться на конфликте

Откуда лишние merge-коммиты

Аня и Боря закоммитили в main каждый своё. git pull --no-rebase (и git старше 2.33.1 без настройки) сделал 282f10f Merge branch 'main' of http://localhost:8765/shop, узел без всякого решения. pull.rebase=true переносит неотправленные коммиты поверх пришедших: b120e8a стал 2db9371, история осталась линейной.

Как работать без сюрпризов

Разделить шаги: git fetch, git log --oneline main..origin/main, а потом merge или rebase. Или pull.ff=only: где можно, pull перематывает, на разошедшихся ветках отказывает уже после fetch: origin/main сдвинут, ветка и файлы нет.

git pull origin main в своей ветке не обновляет main

Команда вливает main сервера в текущую ветку. Стоя на feature с --no-rebase, получишь коммит «Merge branch 'main' of http://localhost:8765/shop into feature», а локальная main останется на месте. Обновить её без переключения можно refspec с правой частью: git fetch origin main:main перемотает локальную ветку, если она не выгружена в рабочую копию и перемотка возможна.

Суть: это три разные ссылки в двух репозиториях. main на сервере двигает push. origin/main лежит в твоём клоне и помнит, где серверная ветка стояла при последнем fetch или push. Локальную main двигают commit, merge и rebase. status сравнивает main с origin/main и в сеть не ходит, поэтому о чужом коммите до fetch не знает.

Как узнать, что на сервере сейчас

До fetch клон Ани и сервер расходились, и это видно, если спросить сервер напрямую:

$ git log --oneline -1 origin/main
af76ac4 (HEAD -> main, origin/main, origin/HEAD) Добавить /health
$ git ls-remote origin main
d088609b9db020b83be01dbf97738cbe9d0bf31c	refs/heads/main

ls-remote только читает список ссылок. git remote show origin тоже ходит на сервер и у отставших веток пишет (local out of date). А git fetch обновляет origin/main, и после него status говорит правду: Your branch is behind 'origin/main' by 1 commit.

Почему в origin/main нельзя коммитить

git switch origin/main отказывается: fatal: a branch is expected, got remote branch 'origin/main'. С --detach HEAD отсоединится, и коммит останется без ветки (глава 1.2), ведь remote-tracking ветку двигает только обмен с сервером.

Суть: в ветке сервера есть коммит, которого нет в твоей. Push проходит, только если новая вершина потомок старой, иначе серверный коммит выпал бы из ветки. Коммит чужой: забери его и встрой через rebase или merge, потом push. Ты сам переписал ветку, которая только твоя: отправляй с --force-with-lease.

Три вида отказа

ОтказЧто это значитЧто делать
(fetch first)вершины сервера в клоне нет, родство не проверитьfetch и посмотреть, что пришло
(non-fast-forward)коммит сервера в клоне есть, но он не предок твоей вершинывстроить или, если переписывал сам, lease
[remote rejected]сервер сам запрещает перезапись: receive.denyNonFastForwards, защищённая веткавстроить; --force не поможет

Сначала посмотреть, чьи коммиты мешают

После git fetch команда git log --oneline main..origin/main покажет коммиты, которых нет у тебя. У Ани там оказался один чужой:

$ git log --oneline main..origin/main
23d68bc (origin/main, origin/HEAD) Поднять таймаут до 5 секунд

Если в списке только прежние версии твоих коммитов, ветку переписывал ты сам (amend, rebase), и встраивать нечего. Чужую работу надо сохранить.

Как отправить

Чужие коммиты встраивают: git pull --rebase переставил b120e8a поверх 23d68bc, и push прошёл перемоткой 23d68bc..2db9371. Конфликт при rebase разбирают по главе 3.2, а если за это время кто-то отправил ещё, fetch и rebase повторяют. --force выбросил бы 23d68bc молча. Свою ветку после amend (глава 2.1) или rebase (глава 3.3) встраивать не нужно, git pull по подсказке задвоит коммиты. Её отправляют через --force-with-lease, если на ней никто не строит (следующий вопрос).

Суть: --force перезаписывает ветку сервера без условий. --force-with-lease перезаписывает, только если ветка на сервере стоит там же, где её помнит remote-tracking ветка, и так спасает от чужого push, которого ты не видел. Фоновый fetch сдвигает remote-tracking ветку без твоего участия, и защита пропадает. --force-if-includes требует, чтобы вершина remote-tracking ветки побывала в reflog твоей ветки.

Что проверяет каждый вариант

КомандаУсловие перезаписиГде подводит
--force, +ветканетвыбрасывает всё, чего нет у тебя
--force-with-leaseсервер = origin/<ветка>после фонового fetch
--force-with-lease=ветка:хешсервер = указанный хешхеш надо запомнить до rebase самому
--force-with-lease --force-if-includesсервер = origin/<ветка>, и её вершина есть в reflog веткипосле cherry-pick вместо rebase

--force в той же команде отменяет lease: git push --force-with-lease --force перезапишет ветку без проверки. Если remote-tracking ветки нет вовсе, а на сервере ветку уже создал коллега, lease откажет с stale info.

Явное ожидание

Форма с хешем не смотрит на origin/retry, поэтому фоновый fetch ей не мешает. Её удобно звать из скриптов: запомнить вершину через git rev-parse до переписывания и передать. Вывод снят раньше, между rebase и последним push Ани:

$ git push --force-with-lease=retry:b6a0d07 --dry-run
To http://localhost:8765/shop.git
 + b6a0d07...7ffc204 retry -> retry (forced update)

С явным хешем --force-if-includes ничего не добавляет, так сказано в git help push.

Как это настроить один раз

git config push.useForceIfIncludes true (git 2.30) добавляет --force-if-includes к каждому --force-with-lease. Найденные после отказа чужие коммиты забирают в ветку до push.

Lease защищает ветку сервера, а не тебя от неудачного rebase

Все эти проверки смотрят, не появилось ли на сервере неожиданного. Если ты сам потерял коммит при rebase, например выбросил его в интерактивном списке, lease и if-includes честно пропустят push, и коммит исчезнет и с сервера. Вернуть его поможет reflog (глава 5.2), пока запись жива.

4.2Процесс

Merge request, ревью, защищённые ветки и CODEOWNERS живут на сервере, git о них ничего не знает. Но стоят они на обычных ветках и коммитах, и от механики git зависит, что увидит ревьюер, почему стопка MR разваливается после squash-merge, какую историю оставляет модель ветвления и что удостоверяет подпись.

Что сможешь объяснить после этой главы
  • Какой 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 определяет модель так: все работают в одной ветке, стволе (trunk, в 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.

4.3Теги и Go-модули

Для git тег всего лишь ссылка: её создают одной командой, удаляют и при желании переставляют. Для Go-модуля тег v1.2.3 становится номером версии, и содержимое этой версии go запоминает по хешу: в go.sum, в кэше модулей, в прокси. Отсюда и всё остальное: какой тег ставить и как его отправить, почему опубликованный тег не переставляют, откуда берутся псевдоверсии и /v2 и что нужно для закрытого репозитория.

Что сможешь объяснить после этой главы
  • Чем lightweight тег отличается от annotated и почему после git push тега на сервере нет.
  • Как go по пути модуля находит репозиторий и зачем ему GOPRIVATE и GOINSECURE.
  • Какие теги go считает версиями и что попадёт в go.mod после go get …@main.
  • Из чего собрана псевдоверсия и почему её не пишут руками.
  • Что увидят потребители, если переставить опубликованный тег, и как выпускать исправление.
  • Зачем /v2 в пути модуля, откуда +incompatible и как ставить теги, если модулей в репозитории несколько.

Lightweight и annotated: какой тег доедет до сервера

Примеры главы идут на библиотеке money, которую Боря пишет для сервиса shop из главы 4.1. Путь Go-модуля начинается с имени хоста, и в этом имени нужна точка, поэтому сервер здесь другой: git.localhost на той же машине (имена *.localhost ведут на неё саму). Он отдаёт репозитории через git http-backend, отвечает на служебный запрос go, как это делает GitLab, и работает без TLS.

Седьмого сентября Боря отправил в main форматирование сумм и ставит первый релизный тег. Объект тега разобран в главе 1.1, а при push теги ведут себя так:

$ git tag v0.1.0
$ git push --follow-tags
Everything up-to-date
$ git ls-remote --tags origin
$ git describe
fatal: No annotated tags can describe '006f72e1893b463c889b6d594a8d00db9d09c1f8'.
However, there were unannotated tags: try --tags.

git tag без -a создал lightweight тег: ссылку refs/tags/v0.1.0 прямо на коммит, без автора, даты и сообщения. --follow-tags отправляет только annotated теги, так что сервер ничего не получил. describe без --tags такие теги тоже не замечает. Тег никто ещё не видел, и его можно пересоздать:

$ git tag -d v0.1.0
Deleted tag 'v0.1.0' (was 006f72e)
$ git tag -a v0.1.0 -m "money v0.1.0"
$ git push --follow-tags
To http://git.localhost/shop/money.git
 * [new tag]         v0.1.0 -> v0.1.0
$ git ls-remote --tags origin
cc891989e4d43a94407f28d680c0224bea129845	refs/tags/v0.1.0
006f72e1893b463c889b6d594a8d00db9d09c1f8	refs/tags/v0.1.0^{}

Первая строка ведёт к объекту тега, вторая с суффиксом ^{} к коммиту, на который он указывает. Annotated тег хранит, кто, когда и с каким сообщением выпустил версию, его можно подписать (git tag -s), а describe и --follow-tags по умолчанию видят только такие. Релизы метят им, а lightweight оставляют для личных закладок вроде before-rebase. Какие теги уйдут на сервер, решает форма push:

КомандаКакие теги уйдут
git push, git push origin mainникакие
git push origin v0.1.0названный, любого вида
git push --follow-tagsannotated, которых нет на сервере, если их коммит уходит на сервер или уже там
git push --tagsвсе локальные, включая lightweight-закладки

git config push.followTags true (git 2.4) добавляет --follow-tags к каждому push. С --tags осторожнее: в опыте на копии он унёс на сервер закладку tmp-before-refactor. Обратно fetch сам забирает теги на коммитах всех веток сервера, lightweight тоже, а тег на коммите вне веток обычный fetch не принесёт, нужен git fetch --tags. Самому go вид тега безразличен, lightweight v0.1.0 тоже стал бы версией. Его легко забыть отправить.

Как go находит модуль: путь, git и три переменные

Восьмого сентября Аня подключает money к сервису. В go.mod сервиса стоит module shop, а main.go импортирует git.localhost/shop/money:

$ cd shop
$ GOINSECURE=git.localhost go get git.localhost/shop/money@v0.1.0
go: downloading git.localhost/shop/money v0.1.0
go: git.localhost/shop/money@v0.1.0: verifying module: git.localhost/shop/money@v0.1.0: reading https://sum.golang.org/lookup/git.localhost/shop/money@v0.1.0: 404 Not Found
	server response: not found: git.localhost/shop/money@v0.1.0: unrecognized import path "git.localhost/shop/money": https fetch: Get "https://git.localhost/shop/money?go-get=1": dial tcp: lookup git.localhost on 8.8.8.8:53: no such host

По пути модуля не видно, где лежит репозиторий, поэтому go спрашивает сам хост: GET https://git.localhost/shop/money?go-get=1. В ответ приходит HTML с тегом go-import, где записаны корень репозитория, система контроля версий и адрес. GitLab отвечает на такой запрос сам. Искать и качать модуль по HTTP go согласен только для путей из GOINSECURE, иначе падает на https fetch … connection refused, ведь TLS у нашего сервера нет.

Сам отказ случился уже после скачивания. По умолчанию GOPROXY=https://proxy.golang.org,direct, и go сначала попросил модуль у публичного прокси. Тот ответил 404 (в go get -x это видно), и go пошёл в git сам, это и значит direct. Скачав модуль, он стал сверять хеш с sum.golang.org, публичной базой контрольных сумм (о ней статья «Go: язык»). Ни прокси, ни база не смогли даже разрешить имя git.localhost, зато путь закрытого модуля ушёл в два внешних сервиса. Обе беды снимает одна переменная:

$ export GOPRIVATE=git.localhost GOINSECURE=git.localhost
$ go env GONOPROXY GONOSUMDB
git.localhost
git.localhost
$ go get git.localhost/shop/money@v0.1.0
go: downloading git.localhost/shop/money v0.1.0
go: added git.localhost/shop/money v0.1.0
$ go mod tidy
$ go run .
1234567.89 ₽

GOPRIVATE перечисляет шаблоны закрытых путей и заодно задаёт по умолчанию две более узкие переменные: GONOPROXY (эти модули у прокси не просить) и GONOSUMDB (не сверять с публичной базой). go.sum при этом работает как обычно. GOFLAGS=-insecure из старых инструкций не поможет: флаг убрали из go get в Go 1.17, и Go 1.27 на него отвечает go: -insecure flag is no longer supported; use GOINSECURE instead.

$ curl -s 'http://git.localhost/shop/money?go-get=1'
<html><head><meta name="go-import" content="git.localhost/shop/money git http://git.localhost/shop/money.git"></head><body></body></html>
$ ls $(go env GOMODCACHE)/cache/download/git.localhost/shop/money/@v
list		v0.1.0.lock	v0.1.0.zip
v0.1.0.info	v0.1.0.mod	v0.1.0.ziphash

Дальше работает обычный git, его команды печатает go get -x. go узнаёт через git ls-remote, какие теги есть на сервере, заводит голый репозиторий в $GOMODCACHE/cache/vcs/<хеш адреса>, забирает один коммит командой вида git fetch --depth=1 origin refs/tags/v0.1.0:refs/tags/v0.1.0 и упаковывает его git archive --format=zip. По архиву go считает хеш для go.sum, а сам архив, go.mod версии и её описание ложатся в cache/download. Прокси модулей отдаёт по HTTP файлы с той же раскладкой.

Какие теги go считает версиями и что выбирает go get

Список версий go собирает из тегов, которые вернул ls-remote. Версия выглядит как vMAJOR.MINOR.PATCH, по желанию с пререлизом через дефис. На копии репозитория проверили и другие имена:

ТегВ go list -m -versionsЧто даёт go get …@тег
v0.1.0, lightweight или annotatedестьv0.1.0
v0.7.0-rc.1есть, пререлизv0.7.0-rc.1
0.2.0, V0.6.0, release-5нетпсевдоверсию, как для любого имени ревизии
v0.3нетошибку: @v0.3 читается как запрос «старшая из v0.3.x»

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

$ go get git.localhost/shop/money@main
go: downloading git.localhost/shop/money v0.1.1-0.20260908090000-02e7b62003d7
go: upgraded git.localhost/shop/money v0.1.0 => v0.1.1-0.20260908090000-02e7b62003d7
$ go run .
1 234 567.89 ₽
$ grep money go.mod
require git.localhost/shop/money v0.1.1-0.20260908090000-02e7b62003d7

В go.mod записалось не main, а псевдоверсия: номер, который go собирает для коммита без тега (о нём следующий раздел). Ветка двигается, а сборке нужна неподвижная точка, поэтому новые коммиты main сами не приедут, их снова берут через @main. Хеш коммита go переводит так же, а если на коммите стоит тег версии, пишет версию из тега. В три часа Боря выпускает пререлиз, и Аня смотрит, что теперь видит go:

$ git tag -a v0.2.0-rc.1 -m "money v0.2.0-rc.1"
$ git push -q --follow-tags
$ go list -m -versions git.localhost/shop/money
git.localhost/shop/money v0.1.0 v0.2.0-rc.1
$ go list -m git.localhost/shop/money@latest
git.localhost/shop/money v0.1.0
$ go list -m git.localhost/shop/money@v0.1
git.localhost/shop/money v0.1.0

@latest выбирает старший релиз, пререлиз берёт, только если релизов нет, а без тегов вообще отдаёт псевдоверсию вершины ветки по умолчанию. @v0.1 означает «старшая из v0.1.x», есть ещё @patch и сравнения вроде @<v0.2.0. На какой ветке тег, неважно: в опыте v0.9.0 на экспериментальной ветке стал ответом на @latest, так что версионные теги на эксперименты не ставят.

Псевдоверсия: номер для коммита без тега

Утром девятого сентября Боря уточнил описание пакета. История money и версии, которые go даёт её коммитам:

$ git log --oneline --decorate
45e26ec (HEAD -> main, origin/main) Уточнить описание пакета
02e7b62 (tag: v0.2.0-rc.1) Разделять разряды пробелом
006f72e (tag: v0.1.0) Форматировать копейки
e2926f0 Создать модуль money
$ go list -m git.localhost/shop/money@e2926f0
git.localhost/shop/money v0.0.0-20260906150000-e2926f05527f
$ go list -m git.localhost/shop/money@45e26ec
git.localhost/shop/money v0.2.0-rc.1.0.20260909060000-45e26ec1c418

В псевдоверсии три части: база, время и хеш. Время go берёт у коммиттера, а не у автора (читает %ct), и переводит в UTC: коммит 45e26ec сделан в 09:00 по Москве, в номере стоит 060000. После rebase или cherry-pick у того же изменения будет новый номер. Хеш обрезан до 12 знаков. Базу go берёт от старшего по версии тега среди предков, и форма зависит от того, что это за тег:

Старший тег-предокФормаПример из истории money
нетvX.0.0-время-хешv0.0.0-20260906150000-e2926f05527f
релиз vX.Y.ZvX.Y.(Z+1)-0.время-хешv0.1.1-0.20260908090000-02e7b62003d7
пререлиз vX.Y.Z-prevX.Y.Z-pre.0.время-хешv0.2.0-rc.1.0.20260909060000-45e26ec1c418

Формы подобраны так, чтобы псевдоверсия была старше своего тега и младше следующей версии: v0.1.1-0.… новее v0.1.0, но уступает v0.1.1. MVS (выбор минимальной версии, статья «Go: язык») поэтому предпочтёт коммит после тега самому тегу. Номер у коммита не единственный: 02e7b62 в go.mod Ани записан как v0.1.1-0.…, а сейчас на нём стоит тег, и новый go get напишет v0.2.0-rc.1. Старая запись остаётся рабочей.

коммит и тег что go запишет в go.mod 45e26ec 02e7b62 006f72e e2926f0 main v0.2.0-rc.1 v0.1.0 v0.2.0-rc.1.0.20260909060000-45e26ec1c418 пререлиз-предок: к нему дописано .0. v0.2.0-rc.1 до тега: v0.1.1-0.20260908090000-02e7b62003d7, эта запись в go.mod Ани остаётся рабочей v0.1.0 v0.0.0-20260906150000-e2926f05527f тегов среди предков нет: база v0.0.0 v0.2.0-rc.1.0. 20260909060000 -45e26ec1c418 база: старший тег-предок время коммиттера в UTC первые 12 знаков хеша
Номер для каждого коммита. Коммиту с тегом go отдаёт версию из тега, остальным собирает псевдоверсию от старшего тега среди предков. Когда на коммит ставят тег, новые запросы получают версию, а записанная раньше псевдоверсия продолжает работать.
База: старший тег, а не ближайший

На копии в main с тегом v0.5.0 влили ветку сопровождения с v0.4.2:

$ git describe --tags
v0.4.2-3-g05da56d
$ go list -m git.localhost/shop/money@main
git.localhost/shop/money v0.5.1-0.20260905070000-05da56d87f72

describe нашёл ближайший v0.4.2, go взял старший: иначе номер v0.4.3-0.… был бы младше предка v0.5.0.

Каждую часть go сверяет с репозиторием:

$ go list -m git.localhost/shop/money@v0.2.0-rc.1.0.20260909070000-45e26ec1c418
go: git.localhost/shop/money@v0.2.0-rc.1.0.20260909070000-45e26ec1c418: invalid pseudo-version: does not match version-control timestamp (expected 20260909060000)
$ go list -m git.localhost/shop/money@v0.2.1-0.20260909060000-45e26ec1c418
go: git.localhost/shop/money@v0.2.1-0.20260909060000-45e26ec1c418: invalid pseudo-version: preceding tag (v0.2.0) not found

Время обязано совпасть с коммитом, тег базы существовать и быть его предком, а сам коммит достижим из какой-нибудь ветки или тега сервера. Иначе любой записал бы v9.9.9-0.… и обошёл MVS. Поэтому псевдоверсию не пишут руками, её получают из go get …@хеш или @ветка.

Перенесённый тег: git разрешит, go.sum нет

В 10:00 Боря выпускает v0.2.0 на коммите 45e26ec. Аня обновляется и пишет тест на строку возврата в чеке:

$ go get git.localhost/shop/money@v0.2.0
go: downloading git.localhost/shop/money v0.2.0
go: upgraded git.localhost/shop/money v0.1.1-0.20260908090000-02e7b62003d7 => v0.2.0
$ go test
--- FAIL: TestRefundLine (0.00s)
    refund_test.go:7: refundLine(50) = "Возврат: 0.-50 ₽", want "Возврат: -0.50 ₽"
FAIL
exit status 1
FAIL	shop	0.448s

Отрицательные суммы печатаются криво. Аня отправляет тест, и CI сервиса падает так же. CI ходит за модулями через командный прокси, у него GOPROXY=http://proxy.localhost:3000 и GONOSUMDB=git.localhost. Так работают Athens и Artifactory, а в песочнице это маленький сервер, который забирает версию из git один раз и дальше отдаёт из своего кэша. GOPRIVATE в CI не задан, иначе он включил бы GONOPROXY и увёл закрытые модули мимо прокси.

Десятого сентября Боря чинит знак и решает не плодить версий: переставить v0.2.0 на исправление.

$ git tag -f -a v0.2.0 -m "money v0.2.0"
Updated tag 'v0.2.0' (was 2dc774d)
$ git push origin main v0.2.0
To http://git.localhost/shop/money.git
   45e26ec..5f5390b  main -> main
 ! [rejected]        v0.2.0 -> v0.2.0 (already exists)
error: failed to push some refs to 'http://git.localhost/shop/money.git'
hint: Updates were rejected because the tag already exists in the remote.
$ git push --force origin v0.2.0
To http://git.localhost/shop/money.git
 + 2dc774d...3a592cb v0.2.0 -> v0.2.0 (forced update)

Существующий тег push без --force не трогает, даже если новый коммит потомок старого: перемотки для тегов не бывает. 2dc774d и 3a592cb здесь объекты тегов, а не коммиты. В клоне money, который Аня держит для чтения кода и где вчерашний fetch уже принёс v0.2.0, о перестановке fetch молчит:

$ git fetch
From http://git.localhost/shop/money
   45e26ec..5f5390b  main       -> origin/main
$ git fetch --tags
From http://git.localhost/shop/money
 ! [rejected] v0.2.0     -> v0.2.0  (would clobber existing tag)
$ git fetch --tags --force
From http://git.localhost/shop/money
 t [tag update]      v0.2.0     -> v0.2.0

Сам fetch приносит только новые теги, а существующий с git 2.20 без --force не перезаписывает. git fetch --prune --prune-tags удаляет теги, которых нет на сервере, и по документации обновляет разошедшиеся, но в git 2.54 отвечает тем же would clobber existing tag. Без --prune второй флаг ничего не удаляет, но скачивает все теги сервера, как --tags. Теперь go:

$ go get git.localhost/shop/money@v0.2.0
$ go test
--- FAIL: TestRefundLine (0.00s)
    refund_test.go:7: refundLine(50) = "Возврат: 0.-50 ₽", want "Возврат: -0.50 ₽"
FAIL
exit status 1
FAIL	shop	0.134s
$ go clean -modcache
$ go test
go: downloading git.localhost/shop/money v0.2.0
verifying git.localhost/shop/money@v0.2.0: checksum mismatch
	downloaded: h1:gtY1CNa/wNpdqTm6bRrQLcSMxz81tQgf+WUTGq/L90o=
	go.sum:     h1:dd4yaUkkrkYWf+E6chxUQQ4QJ+MvhrWzbYJVj3P3XaM=

SECURITY ERROR
This download does NOT match an earlier download recorded in go.sum.
The bits may have been replaced on the origin server, or an attacker may
have intercepted the download attempt.

For more information, see 'go help module-auth'.

go get ничего не скачал: версия v0.2.0 уже лежит в кэше модулей, а кэш go считает неизменным. После очистки go взял из git новый коммит, посчитал хеш архива и сравнил с go.sum. Так же упадёт сборка сервиса на чистой машине без прокси. CI с пустым кэшем через прокси снова получил 0.-50 ₽: прокси отдал архив, скачанный накануне, и в git не ходил. Исправленный код достанется только новому потребителю без строки в go.sum, который идёт мимо прокси, и его хеш разойдётся со всеми.

git.localhost: money.git 45e26ec 5f5390b знак исправлен v0.2.0 было 9 сентября стало 10 сентября git push --force origin v0.2.0 go.sum сервиса shop v0.2.0 h1:dd4y… (архив 45e26ec) кэш модулей Ани v0.2.0 уже в кэше, go в git не идёт: тест падает по-старому чистый кэш, напрямую в git архив 5f5390b, h1:gtY1… не равен go.sum: SECURITY ERROR CI с чистым кэшем через прокси прокси отдаёт архив 45e26ec из своего кэша: тест падает по-старому
Один номер, три результата. Тег переставлен на исправленный коммит, но хеш старого архива уже записан в go.sum, а сам архив лежит в кэше Ани и в прокси. Исправление не дошло ни в одно из трёх мест, а сборка с чистого листа сломалась.

Выпущенную версию не исправляют, выпускают следующую. Боря возвращает v0.2.0 на 45e26ec, помечает его отозванным и ставит v0.2.1:

$ git tag -f -a v0.2.0 -m 'money v0.2.0' 45e26ec
Updated tag 'v0.2.0' (was 3a592cb)
$ git push --force origin v0.2.0
To http://git.localhost/shop/money.git
 + 3a592cb...01fd916 v0.2.0 -> v0.2.0 (forced update)
$ cat go.mod
module git.localhost/shop/money

go 1.27

// Отрицательные суммы печатаются как 0.-50 ₽.
retract v0.2.0
$ git commit -qam "Отозвать v0.2.0"
$ git tag -a v0.2.1 -m "money v0.2.1"
$ git push -q --follow-tags

Боря снова переставил тег, но на коммит, который уже разошёлся по кэшам: загрузка с чистым кэшем снова дала h1:dd4y…. Директива retract в go.mod новой версии прячет v0.2.0 от @latest, а тем, кто запросит её явно, go напечатает предупреждение с этим комментарием. У Ани:

$ go list -m -u git.localhost/shop/money
git.localhost/shop/money v0.2.0 (retracted) [v0.2.1]
$ go get git.localhost/shop/money@latest
go: downloading git.localhost/shop/money v0.2.1
go: upgraded git.localhost/shop/money v0.2.0 => v0.2.1
$ go test
PASS
ok  	shop	0.288s
Переставленный тег остаётся в кэше у всех, кто успел его скачать

Внутри кэша модулей go держит голый клон репозитория, и тег там не обновляется. После того как Боря вернул v0.2.0 на место, go test у Ани по-прежнему падал с checksum mismatch: git rev-parse в cache/vcs показывал 5f5390b. Помог ещё один go clean -modcache. У публичного модуля всё жёстче: sum.golang.org навсегда запоминает хеш первой загрузки, а proxy.golang.org держит копию версии, даже когда тег удалён (FAQ proxy.golang.org советует выпустить новую версию, а старую отозвать через retract).

v2 и дальше: /v2 в пути и +incompatible

Одиннадцатого сентября Боря меняет сигнатуру: Format теперь принимает Amount с валютой. Старый код потребителей не соберётся, значит это новая major-версия. Боря ставит v2.0.0, не трогая go.mod:

$ go get git.localhost/shop/money@v2.0.0
go: git.localhost/shop/money@v2.0.0: invalid version: module contains a go.mod file, so module path must match major version ("git.localhost/shop/money/v2")
$ go get git.localhost/shop/money/v2@v2.0.0
go: git.localhost/shop/money@v2.0.0: invalid version: module contains a go.mod file, so module path must match major version ("git.localhost/shop/money/v2")

Начиная с v2 путь модуля обязан кончаться на /v2: один путь импорта обещает совместимый код, так что несовместимая версия получает новый путь (подробно в статье «Go: язык»). Тег v2.0.0 испорчен, а переставлять его нельзя по причинам из прошлого раздела. Боря правит путь и выпускает v2.0.1:

$ go mod edit -module git.localhost/shop/money/v2
$ head -1 go.mod
module git.localhost/shop/money/v2
$ git commit -qam "Перейти на путь модуля /v2"
$ git tag -a v2.0.1 -m "money v2.0.1"
$ git push -q --follow-tags
$ go list -m -versions git.localhost/shop/money/v2
git.localhost/shop/money/v2 v2.0.0 v2.0.1
$ go list -m git.localhost/shop/money/v2@latest
git.localhost/shop/money/v2 v2.0.1
$ go list -m git.localhost/shop/money@latest
git.localhost/shop/money v0.2.1

Для go это теперь два модуля. @latest старого пути остался v0.2.1, и сервис перейдёт на v2, только когда Аня поправит импорты. Если пакеты библиотеки импортируют друг друга, эти импорты тоже переписывают на /v2. Битый v2.0.0 висит в списке, но запросить его нельзя. Есть и второй способ: код копируют в подкаталог v2/ со своим go.mod, а тег ставят без префикса, просто v2.0.0 (проверено на копии).

У старой библиотеки metrics go.mod нет вовсе: её выпускали тегами ещё до модулей, и мажорная версия успела дойти до трёх.

$ go list -m -versions git.localhost/shop/metrics
git.localhost/shop/metrics v1.0.0 v2.0.0+incompatible v3.1.0+incompatible
$ go list -m git.localhost/shop/metrics@latest
git.localhost/shop/metrics v3.1.0+incompatible
$ go list -m git.localhost/shop/metrics/v3@latest
go: git.localhost/shop/metrics/v3@v3.1.0: invalid version: missing git.localhost/shop/metrics/go.mod and .../v3/go.mod at revision v3.1.0

Без go.mod правило пути проверять не по чему, и go делает исключение: пускает v2+ под старым путём с пометкой +incompatible. На сам тег суффикс не ставят. Совместимость здесь никто не обещал, но обновляются такие версии как обычные: в опыте go get -u поднял v1.0.0 сразу до v3.1.0+incompatible.

go.mod без /v3 откатывает @latest назад

Боря добавил в metrics файл go.mod со старым путём и поставил v3.2.0:

$ go list -m -versions git.localhost/shop/metrics
git.localhost/shop/metrics v1.0.0 v2.0.0+incompatible
$ go list -m git.localhost/shop/metrics@latest
git.localhost/shop/metrics v2.0.0+incompatible
$ go list -m git.localhost/shop/metrics@v3.2.0
go: git.localhost/shop/metrics@v3.2.0: invalid version: module contains a go.mod file, so module path must match major version ("git.localhost/shop/metrics/v3")

Последний тег серии v3 теперь с go.mod, и go выкидывает из списка всю серию, вместе с v3.1.0+incompatible (так устроен modfetch/coderepo.go). @latest уехал на v2. Документация Go при переходе на go.mod после v2 советует выпускать следующую major-версию с суффиксом: в опыте /v4 с тегом v4.0.0 заработал сразу.

Несколько модулей в одном репозитории

Двенадцатого сентября Боря выносит клиент курсов ЦБ в отдельный модуль внутри того же репозитория, чтобы его будущие зависимости не тянулись ко всем потребителям money:

$ cd rates && go mod init git.localhost/shop/money/rates && cd ..
go: creating new go.mod: module git.localhost/shop/money/rates
go: to add module requirements and sums:
	go mod tidy
$ git add . && git commit -qm "Модуль rates"
$ git tag -a v2.1.0 -m "money v2.1.0"
$ git push -q --follow-tags
$ go list -m git.localhost/shop/money/rates@v2.1.0
go: git.localhost/shop/money/rates@v2.1.0: invalid version: unknown revision rates/v2.1.0
$ go list -m git.localhost/shop/money/rates@latest
git.localhost/shop/money/rates v0.0.0-20260912070000-59736032e2be

Версии модуля из подкаталога go ищет в тегах с префиксом этого каталога, для rates это rates/v2.1.0. Тег v2.1.0 принадлежит только корневому модулю, а у rates своих тегов нет, отсюда псевдоверсия без базы. Боря выпускает rates отдельно, а через час коммитит в него таймаут:

$ git tag -a rates/v0.1.0 -m "rates v0.1.0"
$ git push --follow-tags
To http://git.localhost/shop/money.git
 * [new tag]         rates/v0.1.0 -> rates/v0.1.0
$ go list -m -versions git.localhost/shop/money/rates
git.localhost/shop/money/rates v0.1.0
$ go list -m git.localhost/shop/money/rates@main git.localhost/shop/money/v2@main
git.localhost/shop/money/rates v0.1.1-0.20260912090000-27bf1af3596b
git.localhost/shop/money/v2 v2.1.1-0.20260912090000-27bf1af3596b
$ go mod download git.localhost/shop/money/v2@v2.1.0
$ unzip -Z1 $(go env GOMODCACHE)/cache/download/git.localhost/shop/money/v2/@v/v2.1.0.zip
git.localhost/shop/money/v2@v2.1.0/doc.go
git.localhost/shop/money/v2@v2.1.0/go.mod
git.localhost/shop/money/v2@v2.1.0/money.go

Один коммит 27bf1af получил две псевдоверсии: у каждого модуля своя линия тегов. В архив корневого модуля каталог rates не попал: вложенный модуль go вырезает целиком. Если money начнёт импортировать rates, то потребует конкретную его версию, а править оба сразу помогает go.work (глава 5.3).

Приватный репозиторий: доступ без вопросов в терминале

Четырнадцатого сентября анонимное чтение на git.localhost закрыли, как обычно и делают на корпоративном сервере. Аня собирает сервис с пустым кэшем (путь к кэшу в выводе сокращён):

$ go clean -modcache
$ go test
go: downloading git.localhost/shop/money v0.2.1
# shop
main.go:6:2: reading git.localhost/shop/money/go.mod at revision v0.2.1: git ls-remote -q --end-of-options http://git.localhost/shop/money.git in ~/go/pkg/mod/cache/vcs/c971…: exit status 128:
	fatal: could not read Username for 'http://git.localhost': terminal prompts disabled
Confirm the import path was entered correctly.
If this is a private repository, see https://go.dev/doc/faq#git_https for additional information.
FAIL	shop [setup failed]

На ?go-get=1 сервер ответил без пароля, а git, придя за репозиторием, получил 401 и захотел спросить логин. go запускает git с GIT_TERMINAL_PROMPT=0, чтобы сборка в CI или в IDE не зависла на невидимом вопросе. Доступ надо настроить так, чтобы git проходил молча. По HTTPS git возьмёт токен из ~/.netrc или credential helper. Второй путь: ходить по SSH с ключом и переписать адрес в конфиге git.

$ git config --global url."ssh://git.localhost:2222/".insteadOf "http://git.localhost/"
$ git ls-remote --get-url http://git.localhost/shop/money.git
ssh://git.localhost:2222/shop/money.git
$ go test
go: downloading git.localhost/shop/money v0.2.1
PASS
ok  	shop	0.142s

url.<base>.insteadOf подменяет начало адреса во всех командах git, и в тех, что запускает go. Адрес в go-import остался прежним, а трафик пошёл по SSH (SSH-сервер песочницы слушает порт 2222, ключ Ани на нём уже есть). Для GitLab строка из его документации выглядит так, и --get-url проверяет подмену без сети:

$ git config --global url."git@gitlab.example.com:".insteadOf "https://gitlab.example.com/"
$ git ls-remote --get-url https://gitlab.example.com/shop/money.git
git@gitlab.example.com:shop/money.git

Запрос ?go-get=1 go делает сам, без git, и insteadOf на него не действует. GitLab отвечает на него без авторизации, пока проект лежит прямо в группе. Для приватной подгруппы нужен токен в .netrc или GOAUTH, либо суффикс .git в пути модуля: с ним go идёт в git без запроса.

Вопросы

4
Суть: lightweight тег просто ссылка на коммит. Annotated тег ведёт к отдельному объекту с автором, датой, сообщением и, по желанию, подписью. Релиз метят annotated. git push ветки теги не отправляет вовсе, --follow-tags отправляет только annotated, так что забытый -a оставляет релиз на твоей машине.

Что лежит в репозитории

git tag v0.1.0 пишет refs/tags/v0.1.0 с хешем коммита. git tag -a v0.1.0 -m … сначала создаёт объект tag (глава 1.1), и ссылка указывает на него. На сервере это видно по второй строке ls-remote:

$ git ls-remote --tags origin
cc891989e4d43a94407f28d680c0224bea129845	refs/tags/v0.1.0
006f72e1893b463c889b6d594a8d00db9d09c1f8	refs/tags/v0.1.0^{}

Как теги попадают на сервер

Командаlightweightannotated
git pushнетнет
git push --follow-tags, push.followTags=trueнетда, если коммит уходит на сервер или уже там
git push origin v0.1.0дада
git push --tagsвсевсе

Для go вид тега неважен: версией стал бы и lightweight, если бы дошёл до сервера.

Что ставить

Релизы: git tag -a или -s и push.followTags=true, чтобы тег уезжал вместе с веткой. Lightweight оставь для личных закладок.

Суть: исправление почти никому не дойдёт. У кого версия в кэше модулей, соберёт старый код. Кто качает напрямую с чистым кэшем, упадёт на checksum mismatch, потому что в go.sum записан хеш старого архива. Прокси продолжит отдавать старый архив. Надо было выпустить v0.2.1, а v0.2.0 отозвать директивой retract.

Три разных v0.2.0

Где собираютЧто получили
машина, где версия уже в кэшестарый код без предупреждений, go get ничего не качает
чистый кэш, напрямую из gitverifying …@v0.2.0: checksum mismatch, SECURITY ERROR
чистый кэш, через командный проксистарый код: прокси скачал версию раньше и в git не ходит
публичный модульproxy.golang.org отдаёт закэшированный архив, загрузка мимо него не сойдётся с хешем в sum.golang.org

Как выпустить исправление

module git.localhost/shop/money

go 1.27

// Отрицательные суммы печатаются как 0.-50 ₽.
retract v0.2.0

Этот go.mod уходит в новую версию v0.2.1. @latest пропускает отозванную версию, go list -m -u пишет v0.2.0 (retracted) [v0.2.1], а явный запрос @v0.2.0 печатает предупреждение retracted by module author с комментарием. Если тег уже переставили, его возвращают на исходный коммит: так прямые загрузки снова совпадут с go.sum и прокси.

Как чинить у себя

Не стирать строки из go.sum: в опыте после этого go mod tidy молча записал новый хеш, тесты прошли, а под v0.2.0 у Ани и у CI остался разный код. Ошибка прямо говорит, что номер один, а архивы разные. Узнать у автора, какой настоящий, перейти на исправленную версию и почистить кэш.

Суть: это псевдоверсия, номер для коммита без тега. В go.mod go пишет только канонические версии, а ветка двигается. Псевдоверсия указывает на один коммит и сама не обновится: новые коммиты ветки забирают повторным go get …@main.

Из чего собрана

v0.1.1-0. значит «после релиза v0.1.0»: старший тег-предок с увеличенным патчем. 20260908090000 время коммиттера в UTC, 02e7b62003d7 первые 12 знаков хеша. Если старший тег-предок пререлиз, база выглядит как v0.2.0-rc.1.0., если тегов нет, как v0.0.0-. Номер старше базового тега и младше следующей версии, поэтому MVS может сравнивать его с настоящими версиями.

Почему не ветка

MVS сравнивает версии, и через год сравнение должно дать тот же ответ. Ветка сегодня значит один коммит, завтра другой, поэтому go сразу переводит её в номер, а если на коммите есть тег версии, пишет тег.

Как обновлять

На копии репозитория, пока у Ани стояла псевдоверсия, а у Бори уже был пререлиз:

$ go get -u git.localhost/shop/money
go: downloading git.localhost/shop/money v0.2.0-rc.1
go: upgraded git.localhost/shop/money v0.1.1-0.20260908090000-02e7b62003d7 => v0.2.0-rc.1
$ go get git.localhost/shop/money@latest
go: downgraded git.localhost/shop/money v0.2.0-rc.1 => v0.1.0

Псевдоверсия сама считается пререлизом, и -u поднял её до пререлиза новее. @latest означает старший релиз и откатил зависимость на v0.1.0, вместе с разделением разрядов. Чтобы обновиться с ветки, явно пиши @main, а когда появится тег, переходи на него.

Псевдоверсия с удалённой ветки ломает чистую сборку

Коммит должен быть достижим из ветки или тега сервера. В опыте Аня взяла @parse, Боря влил ветку через squash и удалил её на сервере (глава 4.1). С пустым кэшем go mod download ответил invalid version: unknown revision be0950195bce: в main лежит новый коммит с тем же кодом, а нужного на сервере больше нет. Прокси может хранить копию, но полагаться на это нельзя: зависимость на ветку переводят на тег до того, как ветку удалят.

Суть: GOPRIVATE с путём группы, чтобы go не просил модуль у публичного прокси и не сверял его с sum.golang.org. Дальше модуль качает git, и ему нужен доступ без вопросов в терминале: токен в .netrc или GOAUTH, либо SSH через url.<base>.insteadOf.

Переменные

Без GOPRIVATE go спросит proxy.golang.org, пойдёт в git и упадёт на сверке с sum.golang.org (verifying module: … 404 Not Found), а путь модуля уйдёт в оба сервиса. GOPRIVATE=gitlab.example.com/shop задаёт сразу GONOPROXY и GONOSUMDB. С командным прокси ставят GOPROXY и GONOSUMDB без GOPRIVATE, иначе модули пойдут мимо прокси. GOINSECURE нужен серверу без HTTPS или с самоподписанным сертификатом (git его всё равно проверит), а GOFLAGS=-insecure убран в Go 1.17.

Доступ

go запускает git с GIT_TERMINAL_PROMPT=0, и без настроенного доступа ошибка кончается словами could not read Username … terminal prompts disabled. Для SSH:

$ git config --global url."git@gitlab.example.com:".insteadOf "https://gitlab.example.com/"
$ git ls-remote --get-url https://gitlab.example.com/shop/money.git
git@gitlab.example.com:shop/money.git

go находит адрес https://… через ?go-get=1, а git сам заменяет его на SSH. В CI вместо ключа обычно дают токен: .netrc с machine gitlab.example.com читают и go, и git.

Подгруппы GitLab

Для gitlab.example.com/shop/backend/money go сначала спрашивает ?go-get=1, и без авторизации GitLab не раскрывает, где кончается путь проекта. Помогает токен для самого go (.netrc или GOAUTH) или суффикс .git в пути модуля: по нему go сразу понимает, где корень репозитория.