Удалённые репозитории и работа в команде
Пока работаешь один, весь 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 включает --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(-)
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=false | merge-коммит | тем, кто хочет видеть, где работа шла параллельно |
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 и вернут после.
Коллега переписал ветку, поверх которой лежат твои коммиты. 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 без явного хеша.
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.
После отказа 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Что меняет каждая команда
| Что | git fetch | git 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 сдвинут, ветка и файлы нет.
Команда вливает 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 ветку двигает только обмен с сервером.
--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.
Все эти проверки смотрят, не появилось ли на сервере неожиданного. Если ты сам потерял коммит при 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 сервер сам перебазирует остальные.
Если нижний 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.
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'
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 здесь ничего не решают.
| GitLab | GitHub | |
|---|---|---|
| Защита сразу | ветка по умолчанию: разработчики не пушат, мейнтейнеры пушат, 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.
Если файл ничей, 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Чем модели отличаются
| Git Flow | GitHub Flow | Trunk-based | |
|---|---|---|---|
| Долгоживущие ветки | master и develop | main | main, релизные по необходимости |
| Рабочая ветка живёт | пока делается фича | до слияния 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 остаётся одна 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(+)
trunkbaseddevelopment.com называет это правило ошибкой. Одна задача спокойно даёт три 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 в настройках проекта.
Если для 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-beforegit сверяет с датой коммитера, которую пишет сам подписывающий, и коммит задним числом проходит. - Ревью и качества. Подпись ставят до MR, о проверках она ничего не знает.
- Того, что ключ не украден. Спасает отзыв: ключ из
gpg.ssh.revocationFileдаётB. - Что подпись переживёт переписывание. Rebase, cherry-pick и amend создают коммиты с подписью выполнившего или без неё, GitHub при Rebase and merge кладёт коммиты без подписи.
Для ключа не из 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-tags | annotated, которых нет на сервере, если их коммит уходит на сервер или уже там |
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.Z | vX.Y.(Z+1)-0.время-хеш | v0.1.1-0.20260908090000-02e7b62003d7 |
пререлиз vX.Y.Z-pre | vX.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. Старая запись остаётся рабочей.
На копии в 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, который идёт мимо прокси, и его хеш разойдётся со всеми.
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.
Боря добавил в 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 без
запроса.
Вопросы
4git 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^{}
Как теги попадают на сервер
| Команда | lightweight | annotated |
|---|---|---|
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 ничего не качает |
| чистый кэш, напрямую из git | verifying …@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 сразу понимает, где корень репозитория.