voidlearn
 

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
3 2 4 5 1 2dd8fba 3c72289 895e6ae 4020517 4b56d81 00bc1ab 26b83f6 e00bedf 4ecc7a4 96ccf6a 99afbe7 9bddaee 1e25c85 предок good код 125 первый bad потомки bad из шага 1 good: v0.4.0 bad: main тест прошёл, код 0 тест упал, код 1 не собирается, код 125 не проверялся в круге номер шага
Пять запусков на двенадцать коммитов. bisect run проверяет коммит в середине, отбрасывает половину и повторяет. Несобираемый коммит bisect пропускает и обходит через соседей, пока не останется один кандидат.

Вместо 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 api/contracts shop.git contracts.git shop2 api/contracts cea8e30 96d01a2 2f2de87 d51343f cea8e30 96d01a2 2f2de87 d51343f нет на сервере cea8e30 96d01a2 2f2de87 push push забыт fetch fetch d51343f: not our ref
Указатель есть, коммита нет. Каждый коммит 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.

Push прошёл, а файла на сервере нет

Если свой 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).

Вопросы

4
Суть: релиз отметить good, main 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, потом внутри неё.
Код 128 и выше ничего не отмечает

Проверку, убитую сигналом (скажем, от 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.

Суть: на пару минут хватит stash, для всего дольше удобнее 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-модулями выражают версией в 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.

Локально собирается, в CI нет

С 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 до осмотра и восстановления ссылки.