5.3Инструменты
Применишь bisect к поиску регрессии, проверишь код выхода автоматического теста и сравнишь worktree, submodule и go.work. Разберёшь, что остаётся после хуков и почему большие файлы хранят отдельно.
- Как
git bisectнаходит коммит за log₂N проверок, почемуbisect run go test ./...может назвать не тот и что значат коды 125 и 128+. - Почему хуки
pre-commit,commit-msgиpre-pushне приезжают с клоном и не заменяют CI. - Что у worktree общее с основной копией и почему одну ветку нельзя открыть в двух.
- Чем подмодуль отличается от монорепозитория с
go.workи что уместно в Go. - Что LFS кладёт в дерево вместо файла, где сам файл и что увидит тот, у кого LFS не настроен.
bisect: поиск пополам
Шестнадцатого сентября поддержка пишет, что скидка в заказах не применяется. В релизе v0.4.0 она
считалась, а после него в main сервиса shop влили четыре ветки. Сервер
git.localhost локальный, как в главе 4.3. Каталог с репозиториями в выводе заменён на
~/src, строки прогресса push и fetch опущены.
$ git log --oneline --graph v0.4.0^..main
* 1e25c85 (HEAD -> main, origin/main, origin/HEAD) Merge branch 'shutdown'
|\
| * e00bedf Таймаут завершения из SHOP_SHUTDOWN_TIMEOUT
| * 26b83f6 Завершать сервер по SIGTERM
|/
* 9bddaee Merge branch 'readme'
|\
| * 00bc1ab Описать скидки в README
|/
* 99afbe7 Merge branch 'promo'
|\
| * 4b56d81 Проверять срок действия промокода
| * 4020517 Вынести скидку в applyDiscount
| * 895e6ae Не падать на неизвестном промокоде
| * 3c72289 Скидка по промокоду
|/
* 96ccf6a Merge branch 'slog'
|\
| * 2dd8fba Логировать запросы через slog
|/
* 4ecc7a4 (tag: v0.4.0) Сервис заказов
Аня пишет тест, который повторяет жалобу, и пока его не коммитит:
$ cat price/discount_test.go
package price
import "testing"
func TestTotalDiscount(t *testing.T) {
items := []Item{{Price: 1000, Qty: 3}}
if got := Total(items, 10); got != 2700 {
t.Fatalf("Total со скидкой 10%% = %d, want 2700", got)
}
}
$ go test ./...
? shop [no test files]
--- FAIL: TestTotalDiscount (0.00s)
discount_test.go:8: Total со скидкой 10% = 3000, want 2700
FAIL
FAIL shop/price 0.269s
? shop/promo [no test files]
FAIL
$ git status -s
?? price/discount_test.go
Коммит, где тест проходит, называют good, где падает, bad. git bisect start принимает сначала bad, потом
один или несколько good и ищет среди коммитов, достижимых из bad и недостижимых из good: здесь их двенадцать,
считая сам main. bisect переключается на коммит, который делит их примерно пополам, и ждёт ответа
git bisect good или git bisect bad. Каждый ответ отбрасывает половину, так что проверок
нужно около log₂N: на двенадцать коммитов три-четыре, на тысячу десять.
$ git bisect start main v0.4.0
Bisecting: 5 revisions left to test after this (roughly 3 steps)
[4b56d81de925ff0b0d5d92036b37cd4c54fe875d] Проверять срок действия промокода
HEAD на время поиска отсоединён (глава 1.2). Незакоммиченный тест остаётся на диске: git при переключении меняет
только отслеживаемые файлы, и тест, которого нет в истории, участвует в каждой проверке. Разметку bisect хранит в
ссылках refs/bisect/*, а git bisect reset их удаляет и возвращает на исходную ветку.
bisect run и код 125
Отвечать руками не обязательно: git bisect run запускает команду на каждом шаге и ставит good или bad по
её коду выхода. Проще всего отдать ему сам тест:
$ git bisect run go test ./... -run TestTotalDiscount
running 'go' 'test' './...' '-run' 'TestTotalDiscount'
? shop [no test files]
--- FAIL: TestTotalDiscount (0.00s)
discount_test.go:8: Total со скидкой 10% = 3000, want 2700
FAIL
FAIL shop/price 0.170s
? shop/promo [no test files]
FAIL
Bisecting: 2 revisions left to test after this (roughly 2 steps)
[3c7228956ca2df21cbb3ed8f096739c4c889616c] Скидка по промокоду
running 'go' 'test' './...' '-run' 'TestTotalDiscount'
# shop/price
price/promo.go:7:22: multiple-value promo.Percent(code) (value of type (int64, bool)) in single-value context
FAIL shop [build failed]
FAIL shop/price [build failed]
? shop/promo [no test files]
FAIL
Bisecting: 0 revisions left to test after this (roughly 1 step)
[96ccf6a0883d0cab0a8ae9fe8f7bb993f042610a] Merge branch 'slog'
running 'go' 'test' './...' '-run' 'TestTotalDiscount'
? shop [no test files]
ok shop/price 0.319s
3c7228956ca2df21cbb3ed8f096739c4c889616c is the first bad commit
commit 3c7228956ca2df21cbb3ed8f096739c4c889616c
Author: Боря <borya@example.com>
Date: Wed Sep 9 10:00:00 2026 +0300
Скидка по промокоду
price/promo.go | 8 ++++++++
promo/promo.go | 13 +++++++++++++
2 files changed, 21 insertions(+)
create mode 100644 price/promo.go
create mode 100644 promo/promo.go
bisect found first bad commit
Ответ неверный. 3c72289 не собирается: Боря вызвал promo.Percent как функцию с одним
результатом и поправил это следующим коммитом, а CI проверял только вершину ветки (глава 3.3). Ошибка компиляции
даёт у go test тот же код 1, что и упавший тест, и bisect записал коммит в bad.
| Код выхода | Что делает bisect run |
|---|---|
| 0 | отмечает коммит good |
| 1–124, 126, 127 | отмечает bad |
| 125 | пропускает коммит, как git bisect skip, и берёт соседний |
| 128–255 | останавливает поиск, сделанная разметка сохраняется |
Коды 126 и 127 оболочка ставит сама, когда команда не исполняемая или не найдена, а процесс, убитый сигналом N,
получает 128+N; 125 документация git называет самым большим разумным значением ниже них. Код от 128 обычно значит, что
сигнал убил скрипт или go: упавший тест go test сводит к 1. Скрипт, убитый KILL,
остановил поиск строкой exit code 137 … is < 0 or >=
128. Если 126 или 127 вернул уже первый запуск, git с версии 2.36 повторяет команду на good-коммите и при том же
коде останавливается с bogus exit code 127 for good revision. Проверка, которую Аня отдаёт bisect:
$ cat ../check.sh
#!/bin/sh
# bisect смотрит только на код выхода
go build ./... 2>/dev/null || exit 125
go test ./... -run TestTotalDiscount >/dev/null
$ git bisect start main v0.4.0
Bisecting: 5 revisions left to test after this (roughly 3 steps)
[4b56d81de925ff0b0d5d92036b37cd4c54fe875d] Проверять срок действия промокода
$ git bisect run ../check.sh
running '../check.sh'
Bisecting: 2 revisions left to test after this (roughly 2 steps)
[3c7228956ca2df21cbb3ed8f096739c4c889616c] Скидка по промокоду
running '../check.sh'
Bisecting: 2 revisions left to test after this (roughly 2 steps)
[96ccf6a0883d0cab0a8ae9fe8f7bb993f042610a] Merge branch 'slog'
running '../check.sh'
Bisecting: 1 revision left to test after this (roughly 1 step)
[895e6aeb5e273b939911ecfde25b357e1dcd5b82] Не падать на неизвестном промокоде
running '../check.sh'
Bisecting: 0 revisions left to test after this (roughly 0 steps)
[4020517a4715bdc1ef0185d405807ed81ee118fb] Вынести скидку в applyDiscount
running '../check.sh'
4020517a4715bdc1ef0185d405807ed81ee118fb is the first bad commit
commit 4020517a4715bdc1ef0185d405807ed81ee118fb
Author: Боря <borya@example.com>
Date: Wed Sep 9 12:00:00 2026 +0300
Вынести скидку в applyDiscount
price/price.go | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
bisect found first bad commit
Вместо 3c72289 bisect проверил слияние slog и 895e6ae и остановился на
4020517, где pct/100 в целых числах обнуляет скидку. go build ./... тестов не
компилирует, и 125 уходит, только когда не собирается сам сервис. Поэтому тест пишут на API, что уже было в good,
иначе на старых коммитах он снова даст bad.
skip, replay, свои термины и --first-parent
Если пропущенный коммит стоит вплотную к виноватому, точного ответа нет. Допустим, не собирается и 895e6ae:
Аня пропускает оба через git bisect skip, отмечает good слияние slog, и выходит так:
$ git bisect bad 4020517
There are only 'skip'ped commits left to test.
The first bad commit could be any of:
3c7228956ca2df21cbb3ed8f096739c4c889616c
895e6aeb5e273b939911ecfde25b357e1dcd5b82
4020517a4715bdc1ef0185d405807ed81ee118fb
We cannot bisect more!
git bisect log печатает журнал шагов командами, которые можно выполнить заново. В другой сессии Аня
отметила good несобираемый 3c72289 и исправила так:
$ git bisect log > ../bisect.txt
$ grep -v 3c72289 ../bisect.txt > ../fixed.txt
$ git bisect reset
Previous HEAD position was 4020517 Вынести скидку в applyDiscount
Switched to branch 'main'
Your branch is up to date with 'origin/main'.
$ git bisect replay ../fixed.txt
Bisecting: 5 revisions left to test after this (roughly 3 steps)
[4b56d81de925ff0b0d5d92036b37cd4c54fe875d] Проверять срок действия промокода
Bisecting: 2 revisions left to test after this (roughly 2 steps)
[3c7228956ca2df21cbb3ed8f096739c4c889616c] Скидка по промокоду
$ git bisect skip
Bisecting: 2 revisions left to test after this (roughly 2 steps)
[96ccf6a0883d0cab0a8ae9fe8f7bb993f042610a] Merge branch 'slog'
Когда ищут не поломку, а починку, good и bad путают. git bisect start --term-old=broken --term-new=fixed
вводит свои слова, git bisect terms их напоминает. Код 0 в bisect run всё равно значит старое
состояние, и проверку пришлось перевернуть: sh -c '! go build ./...'. Ответ пришёл строкой
895e6ae… is the first fixed commit, а под ней git напечатал bisect found first bad commit.
--first-parent (git 2.29) идёт только по первым родителям слияний (глава 1.2). Если CI проверял каждое
слияние в main, так быстрее найти ветку с поломкой, а потом искать внутри неё
(git bisect start 4b56d81 96ccf6a пришёл к 4020517 за два запуска):
$ git bisect start --first-parent main v0.4.0
Bisecting: 1 revision left to test after this (roughly 1 step)
[99afbe7f922369a910140441521337948e06071b] Merge branch 'promo'
$ git bisect run ../check.sh
running '../check.sh'
Bisecting: 0 revisions left to test after this (roughly 0 steps)
[96ccf6a0883d0cab0a8ae9fe8f7bb993f042610a] Merge branch 'slog'
running '../check.sh'
99afbe7f922369a910140441521337948e06071b is the first bad commit
commit 99afbe7f922369a910140441521337948e06071b
Merge: 96ccf6a 4b56d81
Author: Боря <borya@example.com>
Date: Thu Sep 10 10:00:00 2026 +0300
Merge branch 'promo'
price/price.go | 7 ++++++-
price/promo.go | 10 ++++++++++
promo/promo.go | 23 +++++++++++++++++++++++
3 files changed, 39 insertions(+), 1 deletion(-)
create mode 100644 price/promo.go
create mode 100644 promo/promo.go
bisect found first bad commit
Хуки: pre-commit, commit-msg, pre-push
Хуком называют исполняемый файл с именем события, который git запускает сам: перед коммитом, после ввода
сообщения, перед отправкой. Лежит он в каталоге хуков, по умолчанию .git/hooks, запускается из корня
рабочей копии, и если pre-commit, commit-msg или pre-push вышел не с нулём,
операция отменяется. В новом репозитории там только образцы вроде pre-commit.sample, суффикс их
отключает. Аня кладёт свои хуки в каталог .githooks в репозитории:
$ cat .githooks/pre-commit
#!/bin/sh
# Перед коммитом: форматирование и go vet.
files=$(gofmt -l .)
if [ -n "$files" ]; then
echo "pre-commit: gofmt не применён к файлам:" >&2
echo "$files" >&2
exit 1
fi
go vet ./...
$ cat .githooks/commit-msg
#!/bin/sh
# $1 - файл с сообщением коммита. Заголовок - в стиле Conventional Commits.
pattern='^(feat|fix|docs|refactor|test|chore)(\([a-z0-9-]+\))?!?: .+'
if ! head -n 1 "$1" | grep -Eq "$pattern"; then
echo "commit-msg: заголовок не похож на «fix(price): что сделано»" >&2
exit 1
fi
$ cat .githooks/pre-push
#!/bin/sh
# $1 - имя remote, $2 - адрес. В stdin по строке на ссылку:
# <локальная ссылка> <хеш> <ссылка на сервере> <хеш>
go test ./...
commit-msg получает путь к файлу с сообщением и сверяет заголовок с Conventional Commits (глава 2.1).
pre-push получает имя remote и адрес, а в stdin по строке на отправляемую ссылку; этот хук их не читает и
гоняет тесты.
$ chmod +x .githooks/pre-commit .githooks/commit-msg .githooks/pre-push
$ git config core.hooksPath .githooks
$ git add .githooks
$ git commit -m "Добавить хуки"
commit-msg: заголовок не похож на «fix(price): что сделано»
$ git commit -m "chore: хуки gofmt, vet и go test"
[main 2522cf4] chore: хуки gofmt, vet и go test
3 files changed, 20 insertions(+)
create mode 100755 .githooks/commit-msg
create mode 100755 .githooks/pre-commit
create mode 100755 .githooks/pre-push
$ git add price/discount_test.go
$ git commit -m "test(price): скидка 10% в Total"
[main 2955e2d] test(price): скидка 10% в Total
1 file changed, 10 insertions(+)
create mode 100644 price/discount_test.go
$ git push
? shop [no test files]
--- FAIL: TestTotalDiscount (0.00s)
discount_test.go:8: Total со скидкой 10% = 3000, want 2700
FAIL
FAIL shop/price 0.176s
? shop/promo [no test files]
FAIL
error: failed to push some refs to 'http://git.localhost/shop/shop.git'
Тест, закоммиченный раньше исправления, на сервер не ушёл. Теперь исправление, набранное второпях:
$ git commit -am "fix(price): умножать до деления в applyDiscount"
pre-commit: gofmt не применён к файлам:
price/price.go
$ gofmt -w price/price.go
$ git commit -am "fix(price): умножать до деления в applyDiscount"
[main 45fd5ce] fix(price): умножать до деления в applyDiscount
1 file changed, 2 insertions(+), 1 deletion(-)
$ git push
? shop [no test files]
ok shop/price 0.320s
? shop/promo [no test files]
To http://git.localhost/shop/shop.git
1e25c85..45fd5ce main -> main
Почему хуки не приезжают с клоном и не заменяют CI
clone переносит объекты и ссылки, а .git/hooks заполняет из шаблона теми же образцами. Так
задумано: хук исполняет любой код, и если бы клон привозил хуки с сервера, чужой репозиторий запускал бы свои
программы на твоей машине при первом коммите. У Бори свежий клон, .githooks в нём есть, но git о нём не
знает:
$ git config core.hooksPath
$ git commit -qam "wip"
$ git log --oneline -2
7307a17 (HEAD -> main) wip
45fd5ce (origin/main, origin/HEAD) fix(price): умножать до деления в applyDiscount
$ git config core.hooksPath .githooks
$ git commit -qam "wip 2"
commit-msg: заголовок не похож на «fix(price): что сделано»
$ git commit -q --no-verify -am "wip 2"
$ git log --oneline -3
2180619 (HEAD -> main) wip 2
7307a17 wip
45fd5ce (origin/main, origin/HEAD) fix(price): умножать до деления в applyDiscount
core.hooksPath живёт в локальном конфиге и с клоном тоже не приезжает, каждый выполняет команду сам.
Относительный путь считается от корня рабочей копии и работает в любом worktree.
--no-verify у commit пропускает pre-commit и commit-msg, у
push отключает pre-push. В git 2.54 хуки можно описать и в конфиге
(hook.<имя>.command и .event, несколько на событие), но конфиг в репозиторий тоже не
попадает. Хук даёт быстрый ответ, но ничего не гарантирует:
- у того, кто не настроил
core.hooksPath, хуков нет, а--no-verifyотключает их одним флагом; rebase,cherry-pickиrevertбез остановок коммитят мимоpre-commitиcommit-msg(хуки идут наrewordи на--continueпосле конфликта уcherry-pickиrevert), аmergeзапускаетpre-merge-commitиcommit-msg;- хук смотрит на файлы на диске, и в коммит может уйти не то, что он проверил (вопрос 2);
- слияние кнопкой в GitLab и правка в веб-редакторе происходят на сервере, где локальных хуков нет;
git push -nозначает--dry-run, а не--no-verify, иpre-pushпри этом запускается.
Гарантию даёт обязательная проверка CI (глава 4.2): её запускает сервер, и флагом на машине разработчика она не выключается.
worktree: вторая рабочая копия
После обеда Боря просит посмотреть его ветку metrics. У Ани в promo-cache незаконченная
правка, прятать её в stash не хочется. git worktree add заводит ещё одну рабочую копию того же
репозитория в соседнем каталоге:
$ git status -sb
## promo-cache
M promo/promo.go
$ git fetch
From http://git.localhost/shop/shop
* [new branch] metrics -> origin/metrics
$ git worktree add ../shop-metrics metrics
Preparing worktree (new branch 'metrics')
branch 'metrics' set up to track 'origin/metrics'.
HEAD is now at d06f03d feat(metrics): счётчик заказов в expvar
$ git worktree list
~/src/shop 45fd5ce [promo-cache]
~/src/shop-metrics d06f03d [metrics]
Новая копия получила ветку metrics от origin/metrics. Вместо каталога
.git в ней файл со ссылкой:
$ cd ../shop-metrics
$ cat .git
gitdir: ~/src/shop/.git/worktrees/shop-metrics
$ git rev-parse --git-dir --git-common-dir
~/src/shop/.git/worktrees/shop-metrics
~/src/shop/.git
$ git status -sb
## metrics...origin/metrics
$ go test ./...
? shop [no test files]
ok shop/metrics 0.298s
ok shop/price 0.281s
? shop/promo [no test files]
Служебный каталог у копии свой, а общий (common dir) один на все копии:
| Общее для всех копий | У каждой копии своё |
|---|---|
| объекты, ветки, теги, remote-tracking ветки | HEAD и его reflog, индекс, файлы на диске |
stash, .git/config, хуки | ORIG_HEAD, состояние merge, rebase и bisect |
Долгий bisect run удобно гонять в отдельной копии: основная его не видит. Одну ветку две копии
держать не могут, а коммит из одной сразу виден в другой:
$ git switch promo-cache
fatal: 'promo-cache' is already used by worktree at '~/src/shop'
$ git worktree add ../shop-2 promo-cache
Preparing worktree (checking out 'promo-cache')
fatal: 'promo-cache' is already used by worktree at '~/src/shop'
$ git commit -qam "docs(metrics): уточнить комментарий"
$ cd ../shop
$ git log --oneline -1 metrics
dd6d07b (metrics) docs(metrics): уточнить комментарий
HEAD каждой копии ссылается на ветку, а индекс у каждой свой. Коммит в одной сдвинул бы ветку под индексом другой, и следующий коммит там откатил бы чужую правку (опыт в вопросе 3). Удаляют копию через git, и с изменёнными или неотслеживаемыми файлами он откажет:
$ git worktree remove ../shop-metrics
fatal: '../shop-metrics' contains modified or untracked files, use --force to delete it
$ rm ../shop-metrics/review.md
$ git worktree remove ../shop-metrics
$ git worktree list
~/src/shop 45fd5ce [promo-cache]
Ветка metrics осталась. Каталог, удалённый простым rm -rf, git помнит с пометкой
prunable до git worktree prune или git gc через три месяца.
Подмодуль: коммит другого репозитория в дереве
Подмодулем называют репозиторий, вложенный в каталог другого. Семнадцатого сентября Боря вынес описание API
в отдельный репозиторий contracts, из него генерируют клиентов на нескольких языках. Аня подключает его к
shop:
$ git submodule add http://git.localhost/shop/contracts.git api/contracts
Cloning into '~/src/shop/api/contracts'...
$ git status -s
A .gitmodules
A api/contracts
$ cat .gitmodules
[submodule "api/contracts"]
path = api/contracts
url = http://git.localhost/shop/contracts.git
$ git commit -qm "chore: contracts подмодулем в api/contracts"
$ git ls-tree HEAD api/
160000 commit 2f2de874c3855818f5431e422a5bfaa819a91e8f api/contracts
$ git -C api/contracts log --oneline
2f2de87 (HEAD -> main, origin/main, origin/HEAD) orders.proto: TotalRequest
$ git cat-file -t 2f2de87
fatal: Not a valid object name 2f2de87
В дереве shop запись с режимом 160000 и типом commit (глава 1.1), то есть хеш коммита другого
репозитория; самого коммита среди объектов shop нет, а адрес записан в .gitmodules. За веткой
подмодуль по умолчанию не следит. Обычный clone его не скачивает:
$ git clone -q http://git.localhost/shop/shop.git shop2
$ cd shop2
$ ls -A api/contracts
$ git submodule status
-2f2de874c3855818f5431e422a5bfaa819a91e8f api/contracts
$ git submodule update --init
Submodule 'api/contracts' (http://git.localhost/shop/contracts.git) registered for path 'api/contracts'
Cloning into '~/src/shop2/api/contracts'...
Submodule path 'api/contracts': checked out '2f2de874c3855818f5431e422a5bfaa819a91e8f'
$ git submodule status
2f2de874c3855818f5431e422a5bfaa819a91e8f api/contracts (heads/main)
$ git -C api/contracts status -sb
## HEAD (no branch)
update --init ставит подмодуль на записанный коммит в detached HEAD (глава 1.2), git clone
--recurse-submodules делает то же сразу. Неприятности начинаются, когда подмодуль меняют. Аня коммитит поле в
orders.proto и новый указатель в shop, но отправляет только shop:
$ cd api/contracts
$ git switch -q -c promo-code
$ git commit -qam "orders.proto: promo_code"
$ cd ../..
$ git status -s
M api/contracts
$ git diff --submodule
Submodule api/contracts 2f2de87..d51343f:
> orders.proto: promo_code
$ git commit -qam "feat: promo_code в контракте"
Боря подтягивает shop:
$ git pull
From http://git.localhost/shop/shop
cea8e30..96d01a2 main -> origin/main
Fetching submodule api/contracts
fatal: remote error: upload-pack: not our ref d51343f28839fccc6ed2780c2a815722193a291d
Errors during submodule fetch:
api/contracts
$ git log --oneline -1
cea8e30 (HEAD -> main) chore: contracts подмодулем в api/contracts
shop хранит хеш коммита
contracts. Коммит 96d01a2 с новым указателем уже на сервере, а
d51343f остался только у Ани, поэтому fetch подмодуля у Бори падает и его main стоит на cea8e30.
Страхует от этого push.recurseSubmodules=check: push в shop заканчивается fatal:
Aborting., пока коммиты подмодуля не отправлены. on-demand пушит в подмодуле ту же refspec, что в
shop, то есть main: коммит Ани на promo-code или в detached HEAD так не уйдёт, и
push кончится тем же fatal: Aborting. Подмодуль отправляют первым. Аня отправляет
contracts, у Бори pull проходит, но подмодуль остаётся на старом коммите:
$ git pull -q
$ git status -s
M api/contracts
$ git diff --submodule
Submodule api/contracts d51343f...2f2de87 (commits not present)
$ git submodule update
From http://git.localhost/shop/contracts
* [new branch] promo-code -> origin/promo-code
Submodule path 'api/contracts': checked out 'd51343f28839fccc6ed2780c2a815722193a291d'
$ git status -s
Здесь M api/contracts значит, что подмодуль отстал: git commit -a записал бы старый
2f2de87 и молча откатил правку Ани (на копии так и вышло). С submodule.recurse=true
pull обновляет подмодули сам.
Монорепозиторий и go.work
Код, который меняется вместе, держат в одном репозитории. В money, устроенном как в главе 4.3, два модуля:
корневой и rates с тегами rates/vX.Y.Z. Аня добавляет в rates курс евро и сразу
пользуется им в корневом модуле:
$ cat go.mod
module git.localhost/shop/money/v2
go 1.27.1
require git.localhost/shop/money/rates v0.1.0
$ go build ./...
# git.localhost/shop/money/v2
./money.go:10:56: undefined: rates.EUR
Сборка берёт rates v0.1.0 из кэша модулей. Файл go.work объявляет рабочую
область: перечисленные в нём модули становятся основными, и импорт между ними идёт с диска.
$ go work init . ./rates
$ cat go.work
go 1.27.1
use (
.
./rates
)
$ go build ./...
$ go list -m
git.localhost/shop/money/v2
git.localhost/shop/money/rates
$ git status -s
M money.go
M rates/rates.go
?? go.work
$ GOWORK=off go build ./...
# git.localhost/shop/money/v2
./money.go:10:56: undefined: rates.EUR
С GOWORK=off код собирается как в CI. Документация Go не советует коммитить go.work:
в CI он подменил бы версии зависимостей. Порядок поэтому такой: выпустить rates, поднять требование,
отправить код.
$ echo go.work >> .gitignore
$ git add .gitignore rates/rates.go
$ git commit -qm "rates: курс евро"
$ git tag -a rates/v0.2.0 -m "rates v0.2.0"
$ git push -q origin main rates/v0.2.0
$ GOWORK=off go get git.localhost/shop/money/rates@v0.2.0
go: downloading git.localhost/shop/money/rates v0.2.0
go: upgraded git.localhost/shop/money/rates v0.1.0 => v0.2.0
$ GOWORK=off go build ./...
$ git status -s
M go.mod
M go.sum
M money.go
$ git commit -qam "ToEUR через rates v0.2.0"
LFS: указатель в дереве, файл отдельно
Каждая версия бинарника уходит в каждый клон (глава 1.4). Git LFS кладёт в git файл-указатель, содержимое отправляет на отдельный сервер и скачивает только нужные версии. Восемнадцатого сентября Аня добавляет для бенчмарка выгрузку заказов на 7,1 МиБ:
$ git lfs version
git-lfs/3.8.0 (GitHub; darwin arm64; go 1.27.1)
$ git lfs install
Hook already exists: pre-push
#!/bin/sh
# $1 - имя remote, $2 - адрес. В stdin по строке на ссылку:
# <локальная ссылка> <хеш> <ссылка на сервере> <хеш>
go test ./...
To resolve this, either:
1: run `git lfs update --manual` for instructions on how to merge hooks.
2: run `git lfs update --force` to overwrite your hook.
git lfs install прописывает фильтр lfs в глобальный конфиг и ставит четыре хука. В
.githooks у Ани уже есть pre-push, и LFS не поставил ни одного, хотя фильтр записал. Что
дописать, печатает git lfs update --manual. Сначала файл:
$ git lfs track "*.json.gz"
Tracking "*.json.gz"
$ cat .gitattributes
*.json.gz filter=lfs diff=lfs merge=lfs -text
$ git add .gitattributes price/testdata/orders.json.gz price/orders_test.go
$ git commit -qm "test(price): бенчмарк Total на выгрузке заказов"
$ git cat-file -p HEAD:price/testdata/orders.json.gz
version https://git-lfs.github.com/spec/v1
oid sha256:d9c14849ee901cb137d5411bab82b3b94d9eb877b807e984457f1837296f46f7
size 7456915
$ git lfs ls-files
d9c14849ee * price/testdata/orders.json.gz
$ find .git/lfs/objects -type f
.git/lfs/objects/d9/c1/d9c14849ee901cb137d5411bab82b3b94d9eb877b807e984457f1837296f46f7
$ wc -c price/testdata/orders.json.gz
7456915 price/testdata/orders.json.gz
$ git cat-file -s HEAD:price/testdata/orders.json.gz
132
Фильтр clean из .gitattributes при git add кладёт содержимое в .git/lfs/objects
под его SHA-256 и отдаёт git указатель: в дереве блоб на 132 байта, на диске 7 456 915. Фильтр smudge при
checkout находит объект по oid локально или скачивает. Теперь хук:
$ cat .githooks/pre-push
#!/bin/sh
# $1 - имя remote, $2 - адрес. В stdin по строке на ссылку:
# <локальная ссылка> <хеш> <ссылка на сервере> <хеш>
git lfs pre-push "$@" || exit 1
go test ./...
$ git commit -qam "chore: LFS в pre-push"
На git push хук сначала заливает объект на LFS-сервер (адрес remote с /info/lfs на конце),
а потом git отправляет коммиты. GitLab держит LFS-объекты отдельно от репозиториев: по умолчанию в
shared/lfs-objects или в объектном хранилище. У Бори на новом ноутбуке git lfs install
не выполнялся:
$ git clone -q http://git.localhost/shop/shop.git shop4
$ cd shop4
$ cat price/testdata/orders.json.gz
version https://git-lfs.github.com/spec/v1
oid sha256:d9c14849ee901cb137d5411bab82b3b94d9eb877b807e984457f1837296f46f7
size 7456915
$ git status -s
$ go test ./price
--- FAIL: TestOrdersFixture (0.00s)
orders_test.go:23: gzip: invalid header
FAIL
FAIL shop/price 0.335s
FAIL
$ git lfs install
Updated Git hooks.
Git LFS initialized.
$ git lfs pull
Downloading LFS objects: 100% (1/1), 7.5 MB | 0 B/s
$ wc -c price/testdata/orders.json.gz
7456915 price/testdata/orders.json.gz
$ go test ./price
ok shop/price 0.609s
Без фильтра указатель на диске совпадает с блобом, status чистый, и по gzip: invalid header
трудно догадаться, что дело в LFS.
Если свой pre-push не вызывает git lfs pre-push, коммит с указателем уходит, а объект
остаётся у тебя, и клон у коллеги выдаёт Clone succeeded, but checkout failed. и две строки подсказки.
Исправленный потом хук и git lfs push origin main смотрят только коммиты, которых на сервере нет, и объект не
отправляют. Отправляет его git lfs push --all origin.
Архив Go-модуля с файлом в LFS собирает прокси, а без прокси тот, кто делает go get. В модуле
geo
через //go:embed встроен файл на 2 МБ из LFS: у потребителя с LFS программа напечатала длину 2000000,
без LFS 132, и хеши в их go.sum разошлись. Перенести в LFS файлы из старых коммитов может git lfs
migrate import, но он переписывает историю (глава 5.2).
Вопросы
4main bad и отдать проверку git bisect
run. Скрипт выходит с 0, если тест прошёл, с 1, если упал, и с 125, если коммит не собирается. На полсотни
коммитов уйдёт около шести проверок и по одной на каждый пропуск.Подготовка
Прогони проверку руками на релизе и на main: на good она должна выйти с 0, на bad с 1. Новый тест
оставь незакоммиченным, а скрипт держи вне репозитория: закоммиченный, он исчез бы на старых коммитах, и bisect
остановился бы с bogus exit code 127. go test выходит с 1 и при ошибке компиляции,
так что сборку проверяют отдельно: go build ./... || exit 125. Без этого bisect назвал виноватым
несобираемый 3c72289.
Где bisect ошибается
- опечатка в
-run:go testпишет[no tests to run]и выходит с 0; все проверенные коммиты стали good, а виноватым назван самmain; - нестабильный тест: одна неверная отметка уводит поиск в другую половину, её вычищают через
logиreplay; - несобираемый коммит вплотную к виноватому даёт вместо одного кандидата список;
- в истории со слияниями сначала ищут ветку через
--first-parent, потом внутри неё.
Проверку, убитую сигналом (скажем, от OOM killer), bisect не отмечает и останавливается. Разметка сохраняется, и
после починки скрипта git bisect run запускают снова, без start.
.git/hooks, а этот каталог клон не переносит: иначе чужой
репозиторий исполнял бы у тебя свой код. Скрипты держат в репозитории и включают через
core.hooksPath, но каждый делает это сам. Заменить CI хук не может: его обходят флагом, его нет у тех,
кто не настроил, и проверяет он файлы на диске, а не коммит.Что хук пропускает
pre-commit с gofmt -l . видит файлы на диске, а коммит берёт индекс. Боря добавил файл с
лишним пробелом в func Count, отформатировал его уже после git add, и хук пропустил
коммит:
$ git add promo/count.go
$ gofmt -w promo/count.go
$ git commit -q -m "feat(promo): число промокодов"
$ git status -s
M promo/count.go
$ git show HEAD:promo/count.go | gofmt -l
<standard input>
В коммит ушла неотформатированная версия. Мимо хуков идут и коммиты от rebase,
cherry-pick и revert без остановок, а слияние кнопкой на сервере до локальных
хуков не доходит вовсе.
Как делят работу
Хук ловит мелочи за секунды, до ревью. В нём держат быстрые проверки, а тесты уносят в pre-push.
Гарантию даёт обязательная проверка CI на каждый merge request (глава 4.2), где повторяют те же gofmt -l
и go vet.
git worktree add:
вторая рабочая копия со своими HEAD, индексом и файлами над теми же объектами и ветками. Одну ветку git в две копии не
пускает, потому что коммит в одной сдвинул бы ветку под индексом другой.stash или worktree
git stash без -u не берёт новые файлы, pop при конфликте оставляет запись
(глава 2.2), и пока ты собираешь чужую ветку, о спрятанной правке легко забыть. git worktree add
../shop-metrics metrics оставляет твою копию как была, второй клон не нужен, хватит
git fetch. Закончил,
git worktree remove ../shop-metrics.
Почему одна ветка на одну копию
На отдельном клоне Аня обошла запрет флагом -f, открыла main ещё раз в shop-2
и закоммитила там правку README:
$ git worktree add -f ../shop-2 main
Preparing worktree (checking out 'main')
HEAD is now at 45fd5ce fix(price): умножать до деления в applyDiscount
$ cd ../shop-2
$ git commit -qam "docs: README"
$ cd ../shop
$ git status -s
M README.md
$ git diff --staged
diff --git a/README.md b/README.md
index f66c266..77af276 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# shop
-Сервис заказов на Go. Запуск: `go run .`
+Сервис заказов. Запуск: `go run .`
## Скидки
В shop ветка уже указывает на новый коммит, а индекс остался прежним. Разницу между HEAD и индексом git
показывает как подготовленную правку в обратную сторону, и коммит по привычке откатит работу из соседнего каталога.
go.mod. Модули, которые
меняют вместе, держат в одном репозитории и правят через незакоммиченный go.work. Подмодуль для Go-кода
не годится: go get его содержимого не получает.| Подмодуль | Модули в одном репозитории | Отдельные репозитории | |
|---|---|---|---|
| Связь | хеш коммита в дереве | каталоги, теги с префиксом | версия в go.mod |
| Правка с двух сторон | два коммита и два push по порядку | один коммит, локально go.work | выпустить версию, поднять require |
| Уместно | .proto, спецификации, чужой код на C | модули одной команды, меняются вместе | библиотеки со своим циклом выпуска |
Подмодуль и go get
go собирает архив модуля через git archive, а тот подмодули не включает. В billing пакет
rates подключён подмодулем: у автора всё собирается, а в архиве модуля пакета нет:
$ go get git.localhost/shop/billing@main
go: downloading git.localhost/shop/billing v0.0.0-20260917120000-8e71b9798004
go: git.localhost/shop/billing imports
git.localhost/shop/billing/rates: cannot find module providing package git.localhost/shop/billing/rates
Модули в одном репозитории
Каждый выпускают тегом с префиксом каталога (глава 4.3), а вместе их правят через go.work,
внесённый в .gitignore.
С go.work корневой модуль видел rates.EUR из соседнего каталога, а GOWORK=off go
build ./... упал с undefined: rates.EUR: в go.mod всё ещё v0.1.0.
Перед push прогони сборку с GOWORK=off.
Что стоит запомнить
- bisect делит диапазон истории и помечает коммиты good, bad или skip.
- bisect run трактует специальные коды выхода как good, bad или skip по соглашению Git.
- Worktree разделяет рабочие каталоги, сохраняя общее хранилище objects и refs.
Частые ошибки
- Тест с ошибкой окружения нельзя автоматически считать доказательством регрессии.
- Git hooks не приезжают автоматически с обычным клоном.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Прочитай результат bisect run
Тестовый скрипт вернул 125 на коммите C. Как поступит bisect и почему этот код полезен?
$ git bisect run ./check.sh
C: exit code 125
Bisecting: 4 revisions left to test after this (roughly 2 steps)
Ответ и разбор
Код 125 просит Git пропустить текущий commit, например если эта ревизия не собирается или тест здесь неприменим. Поиск продолжится на других коммитах. Если скрипт вернёт 1 для инфраструктурной ошибки, Git может ошибочно отметить commit bad; различай exit code теста и сбой стенда.
Код-ревью
2. Не дай тестовой ошибке исказить bisect
Команда запускает bisect автоматически так:
git bisect run go test ./...Что произойдёт, если пакет не собирается из-за отсутствующей зависимости или инфраструктурного timeout?
Ответ и разбор
Любой ненулевой код Go test обычно будет считаться bad, хотя причина может быть не в регрессии. Оберни тест скриптом: 0 - good, подтверждённый тестовый провал - bad, не относящаяся к коду ошибка/невозможность проверить - 125 (skip). Зафиксируй окружение и повторно проверь найденную границу вручную.
Применение
3. Выбери worktree или stash
Нужно параллельно сравнить две ветки, не прерывая текущую правку. Как открыть вторую рабочую копию и какое ограничение останется?
Ответ и разбор
Создай отдельную рабочую копию через git worktree add ../review feature. Хранилище объектов и refs общее, поэтому одну branch нельзя одновременно checkout-ить в двух worktree. Если нужна другая ветка, выбери её или создай отдельную.
Применение
4. Оцени LFS pointer
В checkout файл model.bin содержит несколько строк вида oid sha256:... и size ..., а не данные модели. Что это и какие условия нужны для получения оригинала?
Ответ и разбор
После git fsck --no-reflogs --unreachable осмотри кандидатов через git show --stat <oid> и проверь, что найден объект stash. Верни stash-ссылку командой git stash store -m "recovered" <oid>, затем изучи его через git stash show -p и примени только если изменения подходят. Можно применить найденный stash commit напрямую через git stash apply <oid>. Не запускай prune до осмотра и восстановления ссылки.