Отмена, восстановление и расследование
Коммит ломает прод, ветку удаляют, rebase съедает работу, пароль попадает в историю. Почти всё это
чинится, но разными командами. Статья начинается с отмены: три режима reset в терминах трёх
деревьев, revert и ловушка, которая срабатывает при повторном вливании ветки после отката
слияния. Потом достаём потерянное через reflog и git fsck и ищем границу, за которой возвращать
уже нечего: утёкший секрет не спасает даже переписанная история, пока ключ не сменили. В конце инструменты
расследования и порядка: bisect run с go test, хуки, worktree, submodules против
монорепозитория с go.work и LFS.
Что ты делаешь, когда ошибся. Спрашивают, чем reset отличается от revert, как вернуть коммит после
reset --hard, как откатить уже отправленное слияние и что делать с паролем в истории. По
ответам видно, отделяешь ли ты свои неотправленные коммиты от общей истории: свои переписывают, а в
общей ошибку отменяют только новым коммитом. И помнишь ли, что закоммиченное почти всегда
достаётся через reflog. Незакоммиченное нет. Go-разработчика ещё спросят, как найти коммит, с
которого начал падать тест, и почему хук перед коммитом не заменяет проверку в CI.
5.1Отмена
Сделанный коммит отменяют двумя командами с разной механикой. reset переставляет ветку назад
и по выбранному режиму переписывает индекс и файлы, так что история выглядит, будто коммитов не было.
revert историю не трогает и дописывает коммит с обратной правкой. Первая годится, пока
коммиты лежат только у тебя, вторая работает и после push. А у отменённого слияния есть ловушка, которая
срабатывает через несколько дней.
- Что
reset --soft,--mixedи--hardделают с HEAD, индексом и рабочей копией и что после каждого покажутstatusиdiff. - Что
reset --hardстирает без следа, что ещё можно найти и чем его заменяет--keep. - Почему у
git reset коммит -- путьнет режимов и как им убрать файл из последнего коммита. - Как
revertстроит обратный коммит и зачем merge-коммиту-m 1. - Почему ветка, влитая повторно после revert слияния, не приносит старых коммитов и что делать.
- Какие отмены безопасны после push, а какие только до.
reset: ветка переезжает, режим решает остальное
Примеры главы идут на Go-сервисе shop. В понедельник, седьмого сентября, Боря завёл
репозиторий на сервере, Аня его склонировала. Сервер здесь голый репозиторий в соседнем каталоге, поэтому
push показывает адрес ../srv/shop.git, а строки прогресса передачи опущены.
$ git log --oneline
48f0c0f (HEAD -> main, origin/main, origin/HEAD) Сервис заказов
89aa093 Создать модуль shop
Про три дерева было в главе 1.3: коммит, на который указывает HEAD, индекс и файлы на диске.
git reset <коммит> работает с ними по очереди. Сначала записывает текущую вершину в
ORIG_HEAD и переставляет на коммит ветку, на которую указывает HEAD. Сам HEAD остаётся
привязан к ветке, этим reset и отличается от switch, который переводит HEAD на другую ветку.
В detached HEAD (глава 1.2) ветки нет, и переезжает сам HEAD. Дальше режим решает, где остановиться:
| Режим | Ветка | Индекс | Рабочая копия |
|---|---|---|---|
--soft | на коммит | не трогает | не трогает |
--mixed, по умолчанию | на коммит | копирует из коммита | не трогает |
--hard | на коммит | копирует из коммита | копирует из коммита |
Коммит можно не указывать, тогда берётся HEAD и ветка стоит на месте. git reset без аргументов
копирует HEAD в индекс, то есть снимает с индекса всё добавленное. git reset --hard
копирует HEAD ещё и на диск, и все правки в отслеживаемых файлах пропадают.
--soft: коммиты обратно в индекс
Во вторник Аня подняла таймаут записи в main.go, закоммитила, через десять минут подняла и
таймаут чтения. На сервер ничего не ушло, и два коммита про одно и то же она хочет склеить в один:
$ git log --oneline
bdd7075 (HEAD -> main) Таймаут чтения тоже
a36d8ba Таймаут записи 5 секунд
48f0c0f (origin/main, origin/HEAD) Сервис заказов
89aa093 Создать модуль shop
$ git reset --soft HEAD~2
$ git status
On branch main
Your branch is up to date with 'origin/main'.
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
modified: main.go
$ git diff --staged
diff --git a/main.go b/main.go
index 7f9e4b7..068f7f9 100644
--- a/main.go
+++ b/main.go
@@ -18,8 +18,8 @@ func main() {
srv := &http.Server{
Addr: ":8080",
Handler: newHandler(&Store{db: db}),
- ReadTimeout: time.Second,
- WriteTimeout: time.Second,
+ ReadTimeout: 5 * time.Second,
+ WriteTimeout: 5 * time.Second,
}
log.Fatal(srv.ListenAndServe())
}
$ git diff
$ git commit -m "Таймауты сервера 5 секунд"
[main 84b1df3] Таймауты сервера 5 секунд
1 file changed, 2 insertions(+), 2 deletions(-)
main вернулась на 48f0c0f, а индекс и файлы остались в том виде, в каком их
оставил bdd7075. Относительно новой вершины обе правки выглядят добавленными в индекс:
diff --staged показывает обе строки, git diff пуст, потому что индекс и диск
совпадают. Следующий commit собирает из индекса один коммит. Прежние два никуда не делись,
их вершину хранит ORIG_HEAD (git rev-parse --short ORIG_HEAD ответит
bdd7075) и reflog (глава 5.2).
commit --amend из главы 2.1 по документации делает примерно то же: reset --soft HEAD^
и коммит. А раз soft не трогает индекс, в склеенный коммит попадёт и то, что лежало в индексе до reset. В
опыте, где перед reset --soft HEAD~2 в индекс добавили правку ещё одного файла,
git diff --staged --stat показал оба файла. Перед склейкой проверь, что git status
чистый.
--mixed: коммит обратно в правки
Следующий коммит вышел сборным: Аня через commit -a отправила в него и проверку id в
handler.go, и описание API в README.md. Проверку и документацию лучше разнести, для этого
режим по умолчанию возвращает коммит в неразобранные правки:
$ git commit -am "Проверять id заказа"
[main 17f8216] Проверять id заказа
2 files changed, 9 insertions(+), 1 deletion(-)
$ git reset HEAD~
Unstaged changes after reset:
M README.md
M handler.go
$ git status --short
M README.md
M handler.go
$ git diff --staged
$ git diff --stat
README.md | 4 ++++
handler.go | 6 +++++-
2 files changed, 9 insertions(+), 1 deletion(-)
Ветка вернулась на 84b1df3, индекс переписан из него же, и diff --staged пуст.
Файлы на диске прежние, поэтому обе правки видны в git diff, а M стоит во второй
колонке статуса. Дальше коммиты собираются заново:
$ git add handler.go
$ git commit -qm "Проверять id заказа"
$ git commit -qam "Описать API в README"
$ git log --oneline
1f0321a (HEAD -> main) Описать API в README
3b996bd Проверять id заказа
84b1df3 Таймауты сервера 5 секунд
48f0c0f (origin/main, origin/HEAD) Сервис заказов
89aa093 Создать модуль shop
Файлы, которые добавил отменённый коммит, после mixed становятся неотслеживаемыми: в новом индексе их
нет, а на диске они лежат. С флагом -N они останутся в индексе пустыми, как после
git add -N из главы 1.3, и попадут в git diff.
--hard: к коммиту целиком
После обеда Аня пробует кэш заказов в памяти. Закоммитила cache.go и поле в
store.go, дальше без коммита: тест cache_test.go добавила в индекс, поправила
handler.go, завела файл заметок notes.txt, не добавляя в git. Кэш не
нужен, и она откатывается к коммиту до опыта:
$ git status --short
A cache_test.go
M handler.go
?? notes.txt
$ git reset --hard HEAD~
HEAD is now at 1f0321a Описать API в README
$ git status --short
?? notes.txt
$ git diff HEAD
$ ls -1
go.mod
handler.go
main.go
notes.txt
README.md
store.go
--hard сделал все три дерева равными 1f0321a. Правка в handler.go
затёрта, store.go возвращён. cache.go удалён с диска: в HEAD он был, в
1f0321a его нет. Удалён и cache_test.go, хотя он ни разу не попадал в коммит,
хватило записи в индексе. Цел только notes.txt: о неотслеживаемых файлах reset не знает. Но и
это не гарантия. Неотслеживаемый файл по пути, который есть в целевом коммите, reset перезаписывает молча:
в отдельном опыте так пропал черновик c.txt.
Что из потерянного возвращается, видно сразу:
$ git reset --hard ORIG_HEAD
HEAD is now at 90c11e4 Кэш заказов в памяти
$ git status --short
?? notes.txt
Коммит вернулся, с ним и cache.go, а незакоммиченное нет: в статусе снова один
notes.txt. Содержимое cache_test.go лежит в базе блобом без ссылок, его туда
записал git add, и выловить его можно через git fsck (глава 5.2). Правки в
handler.go не было нигде, кроме файла, и её ничем не достать.
Кажется, что reset работает с коммитами, а файлы, которых в коммитах не было, его не касаются. Это
верно только для неотслеживаемых, да и то не всегда. Файл после git add уже отслеживается, и если в целевом
коммите его нет, --hard удаляет его с диска. Не уверен, что в рабочей копии нет нужного,
сначала git stash -u (глава 2.2), потом reset.
Мягче действует --keep. Он тоже переставляет ветку и, как mixed, переписывает весь индекс из
коммита, а на диске меняет только файлы, которые различаются в двух коммитах. Если в таком файле есть
незакоммиченная правка, он отказывается. Добавленное в индекс остаётся на диске, но уже вне индекса.
У Ани правки в README.md, которого опыт не касался, и в store.go,
которого касался:
$ git status --short
M README.md
M store.go
?? notes.txt
$ git reset --keep HEAD~
error: Entry 'store.go' not uptodate. Cannot merge.
fatal: Could not reset index file to revision 'HEAD~'.
$ git restore store.go
$ git reset --keep HEAD~
$ git status --short
M README.md
?? notes.txt
Первая попытка ничего не тронула, вторая убрала коммит и cache.go, а правку в
README.md оставила. Режим --merge нужен в основном, чтобы сбросить индекс
с неразрешёнными конфликтами, а merge --abort по документации равен ему (глава 3.1). В
отличие от --keep добавленное в индекс он выбрасывает, а новый файл из индекса удаляет и с диска.
Для сравнения тот же git reset HEAD~ из состояния перед откатом, по режиму на копию:
reset с путём: ветка стоит на месте
Правку в README.md Аня откатила, а notes.txt удалила. Вечером она собрала сервис
через go build, добавила всё одной командой и закоммитила вместе с
правкой в main.go бинарник shop на девять мегабайт:
$ git add .
$ git commit -m "Писать в лог адрес сервера"
[main 766ef00] Писать в лог адрес сервера
2 files changed, 1 insertion(+)
create mode 100755 shop
$ git reset HEAD~ -- shop
$ git status --short
D shop
?? shop
$ git diff --staged --stat
shop | Bin 9469666 -> 0 bytes
1 file changed, 0 insertions(+), 0 deletions(-)
С путём reset ветку не двигает и файлов на диске не касается. Он кладёт в индекс версию пути из
указанного коммита, по умолчанию из HEAD, и тогда совпадает с git restore --staged из глав 1.3 и 2.2.
В HEAD~ бинарника нет, и запись из индекса пропала: для git это удаление, уже внесённое в
индекс, а сам файл на диске стал неотслеживаемым. То же делает
git restore --source=HEAD~ --staged shop. Режимов у этой формы нет: soft ничего бы не
сделал, а hard переписал бы диск, для которого есть restore. Их git отвергает, а
--mixed с путём в 2.54 принимает с предупреждением, что запись устарела:
$ git reset --soft HEAD~ -- shop
fatal: Cannot do soft reset with paths.
$ git reset --hard HEAD~ -- shop
fatal: Cannot do hard reset with paths.
$ git commit --amend --no-edit
[main 9123484] Писать в лог адрес сервера
Date: Tue Sep 8 14:00:00 2026 +0300
1 file changed, 1 insertion(+)
$ echo shop >> .gitignore
Amend заменил коммит новым, уже без бинарника. Коммит 766ef00 с девятью мегабайтами
остался в базе, на него ссылается только reflog, и сборка мусора удалит его, когда запись истечёт (главы
1.4 и 5.2). Так убирают файл, пока коммит не отправлен, а про отправленный четвёртый вопрос.
revert: отмена новым коммитом
В среду Боря поднял лимит соединений с базой с 20 до 100 и отправил коммит, а Аня поверх него отправила
свою правку обработчика. К трём часам база упёрлась в max_connections. Коммит уже у всех, и
Аня отменяет его новым:
$ git log --oneline -4
42c9c72 (HEAD -> main, origin/main, origin/HEAD) Отвечать 404 только на sql.ErrNoRows
024f0d6 Поднять лимит соединений до 100
f8d1ed3 Не хранить бинарник в репозитории
9123484 Писать в лог адрес сервера
$ git revert --no-edit 024f0d6
[main 53b4670] Revert "Поднять лимит соединений до 100"
Date: Wed Sep 9 15:00:00 2026 +0300
1 file changed, 1 insertion(+), 1 deletion(-)
$ git show HEAD
commit 53b4670933156bd68b352aeb8473198e05f0db9b (HEAD -> main)
Author: Аня <anya@example.com>
Date: Wed Sep 9 15:00:00 2026 +0300
Revert "Поднять лимит соединений до 100"
This reverts commit 024f0d6cc066b27ee9b39b2b6bfbd8846df810c8.
diff --git a/main.go b/main.go
index 2c0f274..7420b0a 100644
--- a/main.go
+++ b/main.go
@@ -13,7 +13,7 @@ func main() {
if err != nil {
log.Fatal(err)
}
- db.SetMaxOpenConns(100)
+ db.SetMaxOpenConns(20)
srv := &http.Server{
Addr: ":8080",
$ git push
To ../srv/shop.git
42c9c72..53b4670 main -> main
Revert берёт изменение коммита, переворачивает и накладывает на текущую вершину слиянием
трёх снимков, как cherry-pick: база здесь сам 024f0d6, одна сторона HEAD, другая родитель
отменяемого коммита (таблица в главе 3.2). Поэтому правка более позднего 42c9c72 в другом файле
цела. Без --no-edit в терминале git откроет редактор с готовым сообщением, и документация
советует дописать туда причину. Push прошёл обычной перемоткой: история только выросла.
Если после отменяемого коммита ту же строку успели поменять, слияние упрётся в конфликт. На копии, где лимит после Бори снизили до 50:
$ git revert --no-edit 024f0d6
Auto-merging main.go
CONFLICT (content): Merge conflict in main.go
error: could not revert 024f0d6... Поднять лимит соединений до 100
hint: After resolving the conflicts, mark them with
hint: "git add/rm <pathspec>", then run
hint: "git revert --continue".
hint: You can instead skip this commit with "git revert --skip".
hint: To abort and get back to the state before "git revert",
hint: run "git revert --abort".
hint: Disable this message with "git config set advice.mergeConflict false"
В маркерах конфликта вторая сторона подписана parent of 024f0d6. Конфликт разрешают по главе 3.2 и
продолжают git revert --continue, передумал отменять, выходи через --abort.
Несколько коммитов отменяются диапазоном: git revert --no-edit A..B создаёт по коммиту на
каждый, начиная с самого нового, а с --no-commit все обратные правки копятся в индексе для
одного общего коммита.
По документации revert требует чистой рабочей копии. На деле git 2.54 пропустил правку в файле, которого отмена не касается, а отказал только при правке в индексе или в том же файле.
revert merge-коммита: -m 1
В четверг Аня сделала ветку retry: withRetry в новом retry.go,
обёртка вокруг обоих запросов в store.go и логирование попыток. Боря влил ветку с
--no-ff и отправил:
$ git log --oneline --graph -6
* 66c2b9a (HEAD -> main, origin/main, origin/HEAD) Merge branch 'retry'
|\
| * dd31b37 (origin/retry, retry) Логировать повторы
| * 8f664fc Повторять запросы к базе
* | fc0a712 Проверять DATABASE_URL при старте
|/
* 53b4670 Revert "Поднять лимит соединений до 100"
* 42c9c72 Отвечать 404 только на sql.ErrNoRows
К четырём часам посыпались ошибки duplicate key: INSERT доходил до базы, ответ
терялся по таймауту, и withRetry вставлял тот же заказ второй раз. Боря убирает ветку целиком:
$ git revert 66c2b9a
error: commit 66c2b9a0fa5d63d2d8ddc0bb11fdeaf65b18a099 is a merge but no -m option was given.
fatal: revert failed
$ git revert -m 1 --no-edit 66c2b9a
[main acc2005] Revert "Merge branch 'retry'"
Date: Thu Sep 10 16:00:00 2026 +0300
2 files changed, 5 insertions(+), 30 deletions(-)
delete mode 100644 retry.go
$ git show HEAD --stat
commit acc20056ceeeb890fc19113a813a097269a4239d (HEAD -> main)
Author: Боря <borya@example.com>
Date: Thu Sep 10 16:00:00 2026 +0300
Revert "Merge branch 'retry'"
This reverts commit 66c2b9a0fa5d63d2d8ddc0bb11fdeaf65b18a099, reversing
changes made to fc0a71285547c5e41587dbbf05687ec707590365.
retry.go | 21 ---------------------
store.go | 14 +++++---------
2 files changed, 5 insertions(+), 30 deletions(-)
У обычного коммита один родитель, и его изменение считается относительно него. У слияния родителей два
(порядок разобран в главе 1.2), и изменений тоже два: относительно fc0a712 слияние принесло
ветку retry, а относительно dd31b37 то, что появилось в
main. Какое отменять, git не угадывает. -m 1 объявляет основной линией первого
родителя, и отменяется всё, что пришло из ветки. На копии -m 2 отменил проверку
DATABASE_URL из main и оставил повторы, а такое нужно редко. Кнопка Revert у влитого
merge request в GitLab делает тот же revert слияния сразу или через новый merge request, на GitHub через новый
pull request.
Файлы вернулись к состоянию до слияния, а граф нет:
$ git branch -a --merged
* main
retry
remotes/origin/HEAD -> origin/main
remotes/origin/main
remotes/origin/retry
$ git merge retry
Already up to date.
8f664fc и dd31b37 по-прежнему предки main. Revert отменил данные,
но не историю, и для git ветка остаётся влитой.
Повторное слияние и revert the revert
В пятницу Аня чинит ветку: вставку заказа повторять нельзя, повторы остаются только у чтения.
$ git diff
diff --git a/store.go b/store.go
index 86587bf..0f3069b 100644
--- a/store.go
+++ b/store.go
@@ -24,9 +24,7 @@ func (s *Store) Find(ctx context.Context, id int) (Order, error) {
}
func (s *Store) Create(ctx context.Context, o Order) error {
- return withRetry(ctx, func() error {
- _, err := s.db.ExecContext(ctx,
- "INSERT INTO orders (id, total) VALUES ($1, $2)", o.ID, o.Total)
- return err
- })
+ _, err := s.db.ExecContext(ctx,
+ "INSERT INTO orders (id, total) VALUES ($1, $2)", o.ID, o.Total)
+ return err
}
$ git commit -qam "Не повторять вставку заказа"
$ git push -q
Коммит 9abef44 ушёл на сервер. Аня переключается на main, забирает вчерашние
слияние и revert и вливает исправленную ветку:
$ git merge-base main retry
dd31b374e95f3c1a03c25897fe42ee46bb934672
$ git merge --no-edit retry
Auto-merging store.go
Merge made by the 'ort' strategy.
$ git diff --stat HEAD^1 HEAD
$ git grep -n withRetry
Слияние прошло без конфликтов и не поменяло ни строчки. Общий предок у веток dd31b37, вершина
retry до починки: в ней есть и retry.go, и обе обёртки. Со стороны
main revert удалил файл и обе обёртки. Со стороны retry убрана одна обёртка, у
Create, и ровно так же, как её убрал revert. Трёхстороннее слияние (глава 3.1) берёт
изменения обеих сторон, а они сходятся в одно: повторов нет нигде. Сборка проходит, ветка «влита», а фичи в
main нет. Так тихо бывает не всегда (вопрос 3).
Слияние ещё не отправлено, поэтому Аня убирает его reset-ом, отменяет revert и только потом вливает ветку:
$ git reset --hard ORIG_HEAD
HEAD is now at acc2005 Revert "Merge branch 'retry'"
$ git revert --no-edit acc2005
[main f81fac8] Reapply "Merge branch 'retry'"
Date: Fri Sep 11 10:40:00 2026 +0300
2 files changed, 30 insertions(+), 5 deletions(-)
create mode 100644 retry.go
$ git grep -n withRetry
retry.go:11:func withRetry(ctx context.Context, op func() error) error {
store.go:19: err := withRetry(ctx, func() error {
store.go:27: return withRetry(ctx, func() error {
$ git merge --no-edit retry
Merge made by the 'ort' strategy.
store.go | 8 +++-----
1 file changed, 3 insertions(+), 5 deletions(-)
$ git grep -n withRetry
retry.go:11:func withRetry(ctx context.Context, op func() error) error {
store.go:19: err := withRetry(ctx, func() error {
$ git push
To ../srv/shop.git
acc2005..0bff74a main -> main
Revert отката вернул в main всё, что приносило первое слияние, вместе с ошибкой. Теперь со
стороны main обёртки на месте, и новое слияние честно применяет правку из
9abef44. Сообщение Reapply "…" вместо Revert "Revert "…"" git пишет с
версии 2.43, и документация просит переписать его своими словами: через месяц цепочка «вернули, откатили,
вернули» без объяснения не читается. Тот же случай разобран в документации git, в заметке «How to revert
a faulty merge» Линуса Торвальдса и Джунио Хамано.
main ветку уже убрал revert, починка совпала с частью его правки,
и добавить нечего.
Справа revert отката возвращает код ветки, и слияние накладывает на него только починку.
Если пустое слияние уже отправлено, reset не годится, и revert отката придётся делать поверх него. На
копии так и сделали: git revert acc2005 вернул обе обёртки, у Create тоже.
Починка 9abef44 для git уже влита, git log main..retry пуст, и ошибка вернулась
в main молча. Починку пришлось применить ещё раз: git cherry-pick 9abef44
снова убрал обёртку у Create.
Что можно отменять после push
Разница между reset и revert видна, когда коммит уже у других. Вернёмся в среду, на копии репозиториев. Коммит Бори с лимитом соединений лежит на сервере, Аня его забрала и закоммитила поверх свою правку, но не отправила. Боря решает убрать коммит reset-ом:
$ git reset --hard HEAD~
HEAD is now at f8d1ed3 Не хранить бинарник в репозитории
$ git push
To ../srv/shop.git
! [rejected] main -> main (non-fast-forward)
error: failed to push some refs to '../srv/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 push --force-with-lease
To ../srv/shop.git
+ 024f0d6...f8d1ed3 main -> main (forced update)
У Ани:
$ git pull
From ../srv/shop
+ 024f0d6...f8d1ed3 main -> origin/main (forced update)
Already up to date.
$ git push
To ../srv/shop.git
f8d1ed3..42c9c72 main -> main
$ git log --oneline -3 origin/main
42c9c72 (HEAD -> main, origin/main, origin/HEAD) Отвечать 404 только на sql.ErrNoRows
024f0d6 Поднять лимит соединений до 100
f8d1ed3 Не хранить бинарник в репозитории
Для ветки Ани origin/main просто отъехал назад: всё, что там есть, в её main уже
лежит, вливать нечего. Её push оказался обычной перемоткой и вернул 024f0d6 на сервер. С
pull.rebase=true вышло бы наоборот: rebase по reflog origin/main понял бы, что
коммит пришёл с сервера (выноска про fork-point в главе 4.1), и молча выбросил бы его из ветки Ани. В обоих
случаях судьбу коммита решила настройка у коллеги, а не тот, кто отменял. Revert такой развилки не
создаёт: отмена приходит к каждому обычным коммитом при следующем pull.
| Отмена | Что делает с историей | До push | После push в общую ветку |
|---|---|---|---|
reset --soft, --mixed, --hard на коммит назад | ветка уходит назад, коммиты выпадают | да | нет: push отклонён, а силой расходится с клонами |
commit --amend, rebase | заменяет коммиты копиями | да | нет (главы 2.1 и 3.3) |
reset --hard ORIG_HEAD после merge | убирает слияние | да | нет, вместо него revert -m 1 |
revert, revert -m 1 | добавляет обратный коммит | можно, но reset проще | да |
reset -- путь, restore, stash | не трогает, меняет индекс и файлы | да | да |
Из последнего столбца есть исключение: свою ветку merge request, на которой никто не строит, переписывают
и отправляют через --force-with-lease (глава 4.1).
Вопросы
4ORIG_HEAD и reflog. soft и mixed файлы
на диске не трогают, hard затирает незакоммиченные правки в отслеживаемых файлах.Что видно после каждого режима
Опыт со схемы из раздела про --hard, git reset HEAD~ на трёх копиях:
| Режим | git diff --staged | git diff | Новые файлы коммита |
|---|---|---|---|
--soft | изменения коммита и тест | правка handler.go | в индексе, A |
--mixed | пусто | store.go и handler.go | неотслеживаемые, ?? |
--hard | пусто | пусто | удалены с диска |
Что теряется при --hard
- Правки в отслеживаемых файлах, которых не было в индексе: копии нет нигде.
- Добавленное в индекс: пропадает из индекса и с диска, новый файл удаляется, но блоб остаётся в
базе, его находит
git fsck(глава 5.2). - Неотслеживаемый файл, если путь с тем же именем есть в целевом коммите: перезаписывается молча.
- Сами коммиты не теряются:
git reset --hard ORIG_HEADили запись reflog возвращают ветку.
Когда какой
soft склеивает последние коммиты в один. mixed разбирает коммит на правки, чтобы собрать заново, а без
аргументов снимает всё с индекса. hard выбрасывает и коммиты, и правки. --keep выбрасывает
коммиты, но откажет, а не затрёт правку в файле, который различается в двух коммитах.
В git лежат коммиты и то, что проходило через git add. Правка, которую не добавляли,
живёт только в файле, и после reset --hard её не найдут ни reflog, ни fsck. Перед hard
с незакоммиченной работой делают git stash -u.
git revert. Он добавляет коммит с обратной правкой,
история только растёт, push проходит перемоткой, и отмена приходит к каждому при следующем pull. reset с
принудительным push убирает коммит с сервера, но не из клонов: коллега вернёт его обычным push или
молча выбросит при rebase, и решит это его настройка.Как отменить
git revert --no-edit 024f0d6 создал 53b4670, и push прошёл перемоткой
42c9c72..53b4670. Если отменяемые строки меняли позже, будет конфликт: разрешить,
git add, git revert --continue. Слияние отменяют с -m 1.
Что случилось бы после reset и force push
Боря убрал свой отправленный коммит через reset --hard HEAD~ и
push --force-with-lease. У Ани, которая уже забрала этот коммит и работала поверх,
git pull ответил Already up to date, а её следующий push вернул коммит на
сервер. С pull.rebase=true коммит, наоборот, исчез бы из её ветки без предупреждения. К
тому же защищённую ветку сервер по умолчанию силой переписать не даст (глава 4.2).
Когда переписывать всё же можно
Коммит ещё не отправлен: reset, amend, rebase. Отправлен в свою ветку merge request, на которой никто не
строит: переписать и отправить с --force-with-lease (глава 4.1).
Отменённый коммит остаётся в истории, в каждом клоне и на сервере. Для ошибки в коде это нормально,
для пароля или ключа нет: git show 024f0d6 покажет всё, что было в коммите. Утёкший
секрет отзывают и меняют, а про переписывание истории рассказано в главе 5.2.
main. Новое слияние берёт общим предком вершину ветки до починки и приносит
только правки после неё. Если ветку чинили новыми коммитами, сначала отменяют сам revert, потом вливают.
Если ветку пересобрали с новыми хешами, её просто вливают.Откуда пустое слияние
git merge-base main retry дал dd31b37, прежнюю вершину ветки. Со стороны
main после revert нет ни retry.go, ни обёрток, со стороны ветки убрана одна
обёртка. Изменения совпали, и слияние 682978e прошло без конфликтов, ничего не поменяв. Это частный
случай: новые коммиты ветки ложатся на main без её старого кода. Задели они убранное revert,
будет конфликт, а правка самого retry.go даст modify/delete: в main
файл удалён. Не задели, слияние пройдёт, но код, зовущий withRetry, не соберётся. Revert
отката перед слиянием на копиях всякий раз давал верный результат.
Ветку чинили новыми коммитами
Сначала отменяют revert: git revert acc2005 возвращает в main код ветки вместе
со старой ошибкой. Потом git merge retry, и слияние приносит только починку. Если пустое
слияние уже отправлено, порядок нарушен: revert отката вернёт ошибку поверх влитой починки, и починку придётся
применить ещё раз через git cherry-pick 9abef44.
Ветку пересобирают
Второй путь из документации git: переписать все коммиты ветки поверх той же точки ветвления, чтобы у них
появились новые хеши. Обычный rebase неизменённые коммиты пропустит, их заставляет переписать
--no-ff. Опыт снят с копии до повторного слияния, где main стоит на
acc2005:
$ git rebase --no-ff 53b4670
Current branch retry is up to date, rebase forced.
Successfully rebased and updated refs/heads/retry.
$ git switch main
Switched to branch 'main'
Your branch is up to date with 'origin/main'.
$ git merge --no-edit retry
Merge made by the 'ort' strategy.
retry.go | 21 +++++++++++++++++++++
store.go | 6 ++++--
2 files changed, 25 insertions(+), 2 deletions(-)
create mode 100644 retry.go
Общим предком стал 53b4670, и слияние приносит всю ветку с починкой. Ветку на сервере после этого
обновляют через git push --force-with-lease. Revert отката здесь не нужен: для переписанных
коммитов документация git предупреждает, что он столкнёт старые версии правок с новыми.
git rebase main переносит только коммиты, которых в main нет. Старые коммиты
ветки там есть, остаётся одна починка, а её правка уже совпадает с main. В опыте rebase
выбросил 9abef44 с пометкой patch contents already upstream, и ветка
превратилась в main.
git commit --amend, а путь дописывают в .gitignore. После push amend уже переписывает
общую историю, и файл удаляют новым коммитом. Из истории он при этом не исчезает, и если это секрет, его
меняют.До push
Как с бинарником в разделе про reset с путём: git reset HEAD~ -- shop,
git commit --amend --no-edit, строка в .gitignore. Вместо reset можно
git rm --cached, и разница появляется у файла, который был и раньше, а коммит его изменил:
| Перед amend | Файл появился в этом коммите | Файл был, коммит его изменил |
|---|---|---|
git reset HEAD~ -- f | коммит без файла, на диске он неотслеживаемый | коммит без этой правки, git show HEAD:f даёт прежнюю версию, правка осталась на диске |
git rm --cached f | то же самое | коммит удаляет файл из репозитория, на диске он неотслеживаемый |
Файл в коммите поглубже
Коммит не последний, но не отправлен: интерактивный rebase с edit на нём, те же две команды
и git rebase --continue (глава 3.3).
После push
git rm --cached shop, строка в .gitignore, обычный коммит и push. Бинарник
останется в истории и в каждом клоне, репозиторий от него не похудеет (глава 1.4). Если в файле был
пароль или токен, новый коммит ничего не спасает: секрет считают утёкшим и меняют, а историю
переписывают по главе 5.2.
5.2Спасение
Git теряет работу реже, чем кажется. Каждое перемещение ветки и HEAD он дописывает в локальный журнал, а
объект, на который не ведёт ни одна ссылка, ещё какое-то время лежит в базе. Поэтому можно вернуть
коммиты после reset --hard и неудачного rebase, удалённую ветку и выброшенный stash. С
утёкшим секретом те же свойства работают против тебя: старые коммиты живут в клонах коллег и в служебных
ссылках сервера, поэтому ключ сначала отзывают, а историю чистят потом.
- Что записывает reflog, где он лежит и чем
HEAD@{1}отличается отmain@{1}и@{1}. - Как вернуть коммиты после
reset --hard, удалённую ветку и ветку до неудачного rebase. - Как
git fsckнаходит выброшенный stash и файл, который успел побывать в индексе. - Что не вернуть ничем и сколько в git 2.54 живут записи журнала и недостижимые объекты.
- Почему после
git filter-repoи force push токен всё ещё достаётся и что делать по порядку.
Reflog: журнал каждой ссылки
У главы свой демо-репозиторий, Go-сервис платежей pay. Сервер поднят на
http://localhost:8766 тем же способом, что в главе 4.1. Первого сентября Аня создала модуль и
клиента шлюза и отправила их, Боря склонировал репозиторий в 11:00. Второго сентября Аня закоммитила в
main таймаут и отправила его, завела ветку refund с возвратом платежа и
вернулась в main:
$ git reflog
0f9b137 (HEAD -> main, origin/main) HEAD@{0}: checkout: moving from refund to main
ed99e9c (refund) HEAD@{1}: commit: Возврат платежа
0f9b137 (HEAD -> main, origin/main) HEAD@{2}: checkout: moving from main to refund
0f9b137 (HEAD -> main, origin/main) HEAD@{3}: commit: Таймаут клиента 5 секунд
9b2e236 HEAD@{4}: commit: Клиент платёжного шлюза
348428b HEAD@{5}: commit (initial): Создать модуль pay
$ git reflog show main
0f9b137 (HEAD -> main, origin/main) main@{0}: commit: Таймаут клиента 5 секунд
9b2e236 main@{1}: commit: Клиент платёжного шлюза
348428b main@{2}: commit (initial): Создать модуль pay
Reflog (reference log, журнал ссылки) хранит прежние значения ссылки. Когда ссылка меняется, git
дописывает строку: старый и новый хеш, кто, когда и почему. git reflog без аргументов
показывает журнал HEAD, свежие записи сверху, и HEAD@{n} читается как «куда HEAD указывал
n перемещений назад». HEAD сдвигается при каждом коммите и каждом переключении, а ветка main
только когда меняется сама, поэтому походов в refund в её журнале нет. Документация называет
git reflog show псевдонимом git log -g --abbrev-commit --pretty=oneline: тот же
log, только идёт по журналу, а не по родителям.
$ find .git/logs -type f
.git/logs/HEAD
.git/logs/refs/heads/main
.git/logs/refs/heads/refund
.git/logs/refs/remotes/origin/main
$ tail -1 .git/logs/refs/heads/main
9b2e2366e66c2ab0c6f100126d75f4605b1d6659 0f9b137938c1e188b617223b857f3ccfba47c5b2 Аня <anya@example.com> 1788332400 +0300 commit: Таймаут клиента 5 секунд
Свой журнал есть у HEAD, у каждой локальной ветки и у remote-tracking веток: у
origin/main записи вида «update by push» и «fetch: …». Только те из них, что завёл clone,
живут без журнала, пока их не сдвинет fetch или push. В формате хранения files
это текстовые файлы в .git/logs, в формате reftable журналы лежат в тех же таблицах, что и
ссылки (глава 1.2), а команды одни и те же.
Журнал локальный, его не переносят ни push, ни clone. У Бори в журнале одна запись, о клонировании, а в репозитории на сервере журналов нет вовсе:
$ git -C ../borya reflog
9b2e236 (HEAD -> main, origin/main, origin/HEAD) HEAD@{0}: clone: from http://localhost:8766/pay.git
$ ls ../srv/pay.git
config HEAD info refs
description hooks objects
Каталога logs там нет, потому что core.logAllRefUpdates по умолчанию включён в
репозитории с рабочей копией и выключен в bare. Коммит, который потерял коллега, в твоём журнале не
найти, и в bare-репозитории на сервере тоже.
Старые записи стирает git reflog expire, его запускает git gc. Документация
обещает записям 90 дней (gc.reflogExpire), а записям о коммитах, недостижимых из нынешней
вершины ветки, 30 (gc.reflogExpireUnreachable). В git 2.54 без настроек через 30 дней
уходят любые записи, и о достижимых коммитах тоже (глава 1.4). Хочешь запас побольше, пропиши сроки в
конфиге:
[gc]
reflogExpire = 90.days
reflogExpireUnreachable = 30.days
Журнал пропадает и без всяких сроков. git branch -D удаляет журнал ветки, но не журнал
HEAD, git fetch --prune убирает remote-tracking ветку вместе с журналом (глава 4.1), а
git reflog drop из git 2.50 стирает журнал ссылки целиком.
Адреса: @{n}, @{-n} и @{дата}
Суффикс @{…} после имени ссылки читает её журнал:
$ git rev-parse --short HEAD@{1}
ed99e9c
$ git rev-parse --short main@{1}
9b2e236
$ git rev-parse --short @{1}
9b2e236
$ git rev-parse --abbrev-ref @{-1}
refund
HEAD@{1} и main@{1} разошлись, хотя HEAD указывает на main. Прежде
чем вернуться в main, HEAD стоял на коммите refund, а сама main до
последнего коммита указывала на 9b2e236. @{1} без имени читает журнал
текущей ветки, а не HEAD. @{-1} устроен по-другому: это ветка, на которой ты был перед
текущей. Git находит её по записям «checkout: moving from» в журнале HEAD, и на этом работает
git switch -.
Вместо номера можно указать время: main@{yesterday}, main@{2.hours.ago},
main@{2026-09-02 09:00}. Git найдёт в журнале, куда указывала ветка в тот момент:
$ git reflog show --date=iso main
0f9b137 (HEAD -> main, origin/main) main@{2026-09-02 10:00:00 +0300}: commit: Таймаут клиента 5 секунд
9b2e236 main@{2026-09-01 10:30:00 +0300}: commit: Клиент платёжного шлюза
348428b main@{2026-09-01 10:00:00 +0300}: commit (initial): Создать модуль pay
$ git rev-parse --short 'main@{2026-09-02 09:00}'
9b2e236
$ git -C ../borya rev-parse --short 'main@{2026-09-01 10:40}'
warning: log for 'main' only goes back to Tue, 1 Sep 2026 11:00:00 +0300
9b2e236
В девять утра второго числа main у Ани стояла на 9b2e236, таймаут появился в
десять. Журнал Бори начинается с клонирования в 11:00, о более раннем времени он ничего не знает, поэтому
git предупреждает и отдаёт самое старое известное значение.
@{yesterday} не значит «вчерашние коммиты»
Дата в скобках спрашивает, где стояла ссылка в этом клоне. Если ты неделю не двигал main,
main@{yesterday} вернёт её нынешнюю вершину, сколько бы коммитов ни появилось за это время на
сервере. Коммиты за период ищут через git log --since (глава 2.3).
Коммиты после reset --hard
Третьего сентября Аня добавила в main два коммита с повтором запросов, положила в индекс новый
тест и взялась править retry.go. Потом решила, что коммиты уже на сервере, и выровняла ветку
по origin/main:
$ git status --short
M retry.go
A retry_test.go
$ git log --oneline -3
8a812cd (HEAD -> main) Логировать отказ шлюза
55a06ec Повторять запрос к шлюзу
0f9b137 (origin/main) Таймаут клиента 5 секунд
$ git reset --hard origin/main
HEAD is now at 0f9b137 Таймаут клиента 5 секунд
$ git status --short
$ git reflog -3
0f9b137 (HEAD -> main, origin/main) HEAD@{0}: reset: moving to origin/main
8a812cd HEAD@{1}: commit: Логировать отказ шлюза
55a06ec HEAD@{2}: commit: Повторять запрос к шлюзу
main на
0f9b137, и на два последних коммита не ведёт ни одна ветка. Объекты целы, а записи журнала
HEAD хранят их хеши, поэтому ветку можно вернуть на прежнюю вершину.
reset --hard переставил ветку и переписал индекс и рабочую копию (режимы разобраны в главе 5.1).
Объекты коммитов он не трогал: 8a812cd лежит в базе, ветки на него не ведут, но ведёт запись
HEAD@{1}. Сразу после reset ветку вернёт и ORIG_HEAD, а журнал помнит коммит и
через неделю:
$ git reset --hard 8a812cd
HEAD is now at 8a812cd Логировать отказ шлюза
$ git reflog -2
8a812cd (HEAD -> main) HEAD@{0}: reset: moving to 8a812cd
0f9b137 (origin/main) HEAD@{1}: reset: moving to origin/main
$ git status --short
$ grep 'maxAttempts =' retry.go
const maxAttempts = 3
Коммиты вернулись, а тест и правка нет. retry_test.go побывал в индексе, значит
git add уже записал его содержимое блобом (глава 1.3), и его найдёт git fsck.
Правку maxAttempts = 5 в индекс не добавляли, в базе её нет, и никакая команда git её не вернёт.
В команде стоит хеш, а не номер: возврат сам стал записью, и HEAD@{1} теперь указывает на
0f9b137 (об этом предупреждала глава 1.2). Не уверен, что делать дальше, сначала повесь на
найденный коммит ветку, git branch rescue 8a812cd: коммит, на который ведёт ветка, не удалит
никакой срок. Через журнал отменяют и случайный git commit --amend: git reset --soft
HEAD@{1} ставит ветку на коммит до amend, а всё, что amend добавил, остаётся в индексе.
Удалённая ветка
Четвёртого сентября Аня убирала лишние ветки и заодно удалила refund, которую ещё никуда не
отправляла:
$ git branch -D refund
Deleted branch refund (was ed99e9c).
$ git reflog show refund
fatal: ambiguous argument 'refund': unknown revision or path not in the working tree.
Use '--' to separate paths from revisions, like this:
'git <command> [<revision>...] -- [<file>...]'
$ git reflog | grep -A1 'moving from refund'
0f9b137 HEAD@{4}: checkout: moving from refund to main
ed99e9c HEAD@{5}: commit: Возврат платежа
$ git branch refund ed99e9c
Хеш вершины git печатает сам, в скобках (was ed99e9c). Если строка уже уехала из терминала,
журнал ветки не поможет: branch -D удалил его вместе с веткой. Остаётся журнал HEAD. Запись
«moving from refund» git сделал, когда HEAD уходил с ветки, а строка под ней показывает, где HEAD стоял
перед этим, то есть вершину refund. Заходил на ветку несколько раз, и grep найдёт
несколько таких мест: нужно самое верхнее.
Журнал HEAD знает только ветки, на которые ты переключался. Коммит remote-tracking ветки, которую убрал
fetch --prune, а ты её не открывал, ищут через git fsck: в проверочном прогоне он
сразу стал висячим.
Неудачный rebase: журнал ветки
В тот же день Аня завела ветку limits с тремя проверками платежа, перенесла её на свежий
origin/main через git rebase -i и в плане случайно удалила строку «Проверять
валюту». Rebase отчитался об успехе. Наутро Аня сделала ещё коммит и только тогда заметила, что
currency.go пропал:
$ git log --oneline origin/main..limits
960f174 (HEAD -> limits) Подключить проверки
de977fc Ключ идемпотентности
36e7dfd Ограничить сумму платежа
$ git reflog show limits
960f174 (HEAD -> limits) limits@{0}: commit: Подключить проверки
de977fc limits@{1}: rebase (finish): refs/heads/limits onto 788a5ad0b5533afad46e5888a0307d8b92a515d5
ff1a058 limits@{2}: commit: Ключ идемпотентности
762752a limits@{3}: commit: Проверять валюту
b6f22ca limits@{4}: commit: Ограничить сумму платежа
8a812cd (main) limits@{5}: branch: Created from HEAD
В журнале ветки весь rebase занял одну запись, «rebase (finish)», и строка под ней хранит ветку до
переноса. В журнале HEAD тот же перенос расписан по шагам, с записью «rebase (pick)» на каждый коммит, и на
длинной ветке нужное место тонет в десятках строк.
ORIG_HEAD сейчас тоже указывает на ff1a058, но его перезапишет первый же
reset, merge, stash или следующий rebase (глава 3.3), а журнал ветки
прежние вершины помнит.
$ git log --oneline origin/main..limits@{2}
ff1a058 Ключ идемпотентности
762752a Проверять валюту
b6f22ca Ограничить сумму платежа
$ git cherry-pick 762752a
[limits c95233e] Проверять валюту
Date: Fri Sep 4 11:30:00 2026 +0300
1 file changed, 12 insertions(+)
create mode 100644 currency.go
Старая версия ветки цела, выброшенный коммит Аня забрала через cherry-pick. Не будь нового коммита,
хватило бы git reset --hard limits@{1}: ветка встала бы туда, где была до rebase. После
такого reset номера в журнале ветки съедут так же, как у HEAD.
git fsck: объекты без ссылок
Журнал помогает, пока в нём есть строка. git stash drop убирает запись вместе со строкой в
журнале refs/stash, а у блоба из индекса строки не было вовсе. Пятого сентября Аня спрятала
правку README и по ошибке выбросила запись:
$ git stash
Saved working directory and index state WIP on limits: c95233e Проверять валюту
$ git stash drop
Dropped refs/stash@{0} (6652b810034279c5763c88e9f421894cd18392c7)
$ git fsck
Checking ref database: 100% (1/1), done.
Checking object directories: 100% (256/256), done.
dangling blob 02b9e4bf907f4e255d4d65b7f0796ced6299bdc2
dangling commit 6652b810034279c5763c88e9f421894cd18392c7
git fsck проверяет целостность базы и обходит всё, до чего можно дойти от ссылок, индекса
и журналов. Объект, до которого дойти нельзя, называется unreachable (недостижимый). Если на
недостижимый объект не ссылается даже другой недостижимый, он dangling (висячий), это верхушка
потерянного. По умолчанию fsck печатает висячие, все недостижимые покажет
--unreachable:
$ git fsck --unreachable --no-progress
unreachable blob 02b9e4bf907f4e255d4d65b7f0796ced6299bdc2
unreachable tree 8eef48ebd857d6866c17d0a2caf05d1da72ef819
unreachable blob 201b37652a04d502f048a3755de5e2dda7c85064
unreachable commit c7fbd1546ec7ecaf6f137ba11dd044914c153b44
unreachable commit 6652b810034279c5763c88e9f421894cd18392c7
Запись stash состоит из нескольких коммитов (глава 2.2). Коммит рабочей копии 6652b81
ссылается на коммит индекса c7fbd15 и на своё дерево 8eef48e с новым блобом
README, поэтому висячий из них один. Второй висячий объект, блоб 02b9e4b, остался от теста из
раздела про reset. Когда висячих коммитов много, записи stash отбирают по рецепту из документации
git stash: у записи больше одного родителя, а в сообщении есть «WIP»:
$ git fsck --unreachable --no-progress | grep commit | cut -d' ' -f3 | xargs git log --merges --no-walk --grep=WIP
commit 6652b810034279c5763c88e9f421894cd18392c7
Merge: c95233e c7fbd15
Author: Аня <anya@example.com>
Date: Sat Sep 5 11:00:00 2026 +0300
WIP on limits: c95233e Проверять валюту
$ git stash apply 6652b81 # вывод git status опущен
git stash apply принимает хеш так же, как имя записи, а git stash store вернёт
коммит в список. С блобом сложнее, имени у него нет (глава 1.1):
$ git fsck --lost-found
Checking ref database: 100% (1/1), done.
Checking object directories: 100% (256/256), done.
dangling blob 02b9e4bf907f4e255d4d65b7f0796ced6299bdc2
dangling commit 6652b810034279c5763c88e9f421894cd18392c7
dangling commit ff1a0586d1dbd2710e3bfd7710cd20326bde153c
$ grep -l TestRetry .git/lost-found/other/*
.git/lost-found/other/02b9e4bf907f4e255d4d65b7f0796ced6299bdc2
$ cp .git/lost-found/other/02b9e4bf907f4e255d4d65b7f0796ced6299bdc2 retry_test.go
$ go test ./...
ok example.com/pay 0.397s
--lost-found раскладывает висячие объекты по каталогам: хеши коммитов в
.git/lost-found/commit, содержимое блобов файлами в .git/lost-found/other.
Нужный файл узнают по содержимому, и grep тут удобнее всего.
--lost-found не смотрит в журналы
Третий висячий объект, коммит ff1a058, простой git fsck не показывал. Это вершина
limits до rebase, её держат только журналы. С --lost-found fsck
перестаёт считать журналы ссылками, как с --no-reflogs: в builtin/fsck.c так
записано явно, а документация молчит. Лишний коммит в выводе ещё не значит, что он потерян: сначала
поищи его в журналах.
Срок зависит от того, кто держит объект. Коммит из журнала живёт, пока жива запись. Выброшенный stash,
блоб из индекса и коммит ветки, убранной fetch --prune, в журналах не значатся, и их бережёт
только отсрочка gc.pruneExpire: git gc удаляет недостижимое старше двух недель
(глава 1.4). В проверочном прогоне блоб с временем изменения 15 дней назад исчез после первого же
git gc, а свежий остался.
Что не спасти
| Что потеряно | Где искать | Когда поздно |
|---|---|---|
Коммит, с которого ушла ветка: reset, rebase, --amend, branch -D | журнал HEAD или ветки | запись старше 30 дней (git 2.54), и прошёл gc |
Выброшенный stash, ветка после fetch --prune | fsck --unreachable | объект старше двух недель, и прошёл gc |
| Файл, который побывал в индексе | fsck --lost-found и поиск по содержимому | так же |
| Правка, которую ни разу не добавляли в индекс | нигде | сразу |
Неотслеживаемый файл после git clean -f | нигде | сразу |
| Коммит, который был только в чужом клоне | в том клоне | когда клон удалят |
Правку maxAttempts = 5 из раздела про reset не находит даже перебор всех объектов базы:
$ git cat-file --batch-all-objects --batch | grep -c 'maxAttempts = 5'
0
Правка попадает в базу, когда проходит через add, commit или stash.
Отсюда привычка: перед reset --hard, restore и checkout . сделать
git stash, и правка станет коммитом. Свежий клон не
выручит. С сервера, через file:// или с --no-local приезжает только достижимое, а
локальный git clone путь копирует базу целиком, с теми же висячими объектами. А после
git reflog expire --expire=now --all и git gc --prune=now (глава 1.4) искать уже
нечего: уходят и журналы, и всё недостижимое.
Утёкший секрет: сначала ключ
Седьмого сентября Аня закоммитила в limits README и тест, завела от origin/main ветку
gateway для настроек шлюза, и git add . забрал в коммит .env с боевым
токеном:
$ git show --stat --oneline HEAD
764065c (HEAD -> gateway) Настройки шлюза из окружения
.env | 2 ++
config.go | 8 ++++++++
2 files changed, 10 insertions(+)
Ветка ушла на сервер. Merge request №1 на неё изображает ссылка, которую там создали руками: наш сервер не GitLab. Боря забрал ветку на ревью, и токен теперь есть и у него, и у всех, кто может читать репозиторий:
$ git show gateway:.env
PAY_URL=https://gw.example.com
PAY_TOKEN=pt_live_7Qk2mX9vR4sT8wZ1
Начинать надо не с git: отозвать токен в кабинете шлюза, выпустить новый и проверить журнал
шлюза, не пользовался ли кто-то старым. Отозванный токен ничего не открывает, сколько бы копий ни
осталось. Документация GitHub и руководство git filter-repo пишут то же и добавляют, что после
ротации переписывать историю бывает уже незачем.
Если чистить всё же надо (например, в файле персональные данные, их ротацией не отзовёшь), берут
git filter-repo: отдельный скрипт на Python, который ставят пакетным менеджером, через pip
или копированием файла. Флаг --sensitive-data-removal есть с версии 2.47. Встроенный
git filter-branch документация git не рекомендует и сама отправляет к filter-repo. А
filter-repo без --force работает только в свежем клоне:
$ git filter-repo --invert-paths --path .env
Aborting: Refusing to destructively overwrite repo history since
this does not look like a fresh clone.
(expected at most one entry in the reflog for HEAD)
Please operate on a fresh clone instead. If you want to proceed
anyway, use --force.
Одна из проверок смотрит в тот же журнал HEAD: у свежего клона в нём одна запись. Переписывание необратимо, и в свежем клоне это не страшно: оригинал цел на сервере.
$ git clone -q http://localhost:8766/pay.git pay-clean
$ cd pay-clean
$ git filter-repo --sensitive-data-removal --invert-paths --path .env
NOTICE: Fetching all refs from origin to make sure we rewrite
all history that may reference the sensitive data, via
git fetch -q --prune --update-head-ok --refmap "" origin +refs/*:refs/*
Parsed 8 commits
New history written in 0.11 seconds; now repacking/cleaning...
You rewrote 2 (of 8) commits.
NOTE: First Changed Commit(s) is/are:
764065c75528c02fa4f03960f46a7b8a9ccec33c
NOTE: LFS object orphaning not checked (LFS not in use)
# дальше вывод repack и список следующих шагов
$ git log --all --oneline -- .env
$ git for-each-ref
ff75a81305a231c90529ef9b37b376731adc139e commit refs/heads/gateway
788a5ad0b5533afad46e5888a0307d8b92a515d5 commit refs/heads/main
ff75a81305a231c90529ef9b37b376731adc139e commit refs/merge-requests/1/head
--invert-paths --path .env выбрасывает файл из каждого коммита. --sensitive-data-removal
сначала забирает с сервера все ссылки, +refs/*:refs/*, и вместе с ветками приезжает
refs/merge-requests/1/head, которую GitLab заводит на каждый merge request (глава 4.1).
Переписаны два коммита из восьми, .env добавил первый из них, и хеши у обоих новые
(глава 1.1). Старый хеш первого, 764065c, filter-repo называет First Changed Commit, он
понадобится поддержке сервера и коллегам. Журналы и старые объекты своего клона filter-repo стирает сам, а пустой
git log --all -- .env подтверждает, что файла нет ни в одной ссылке.
$ git push --force --mirror origin # строки прогресса опущены
To http://localhost:8766/pay.git
+ d12e555...ff75a81 gateway -> gateway (forced update)
! [remote rejected] refs/merge-requests/1/head -> refs/merge-requests/1/head (deny updating a hidden ref)
error: failed to push some refs to 'http://localhost:8766/pay.git'
--mirror отправляет все ссылки разом и удаляет на сервере те, которых в клоне нет;
--force при нём лишний, руководство filter-repo пишет его как напоминание, что чужие коммиты
на сервере затрутся. Ветку сервер принял, ссылку merge request нет. Этот отказ мы имитируем. Ссылку создали через
update-ref, и без настроек push перезаписал бы и её (проверено), поэтому в серверном репозитории
включена receive.hideRefs = refs/merge-requests/. Так делает GitLab: его хранилище Gitaly прячет
служебные ссылки, когда принимает push, и читать их можно, а менять нельзя. GitHub так же держит
refs/pull/* только для чтения, и его документация предупреждает, что push --mirror
на них споткнётся.
Где токен остался после force push
Ветка на сервере чистая, но старые коммиты по-прежнему достаются. Первый путь: ссылку merge request заберёт любой, у кого есть доступ на чтение, а удалить её push не даёт:
$ git ls-remote origin
788a5ad0b5533afad46e5888a0307d8b92a515d5 HEAD
ff75a81305a231c90529ef9b37b376731adc139e refs/heads/gateway
788a5ad0b5533afad46e5888a0307d8b92a515d5 refs/heads/main
d12e5558ed66f78eabee7a5171d1e453759d0ccf refs/merge-requests/1/head
$ git fetch -q origin refs/merge-requests/1/head
$ git show FETCH_HEAD:.env
PAY_URL=https://gw.example.com
PAY_TOKEN=pt_live_7Qk2mX9vR4sT8wZ1
$ git push origin --delete refs/merge-requests/1/head
To http://localhost:8766/pay.git
! [remote rejected] refs/merge-requests/1/head (deny deleting a hidden ref)
error: failed to push some refs to 'http://localhost:8766/pay.git'
Второй путь: клон Бори. Fetch честно передвинул origin/gateway, но журнал remote-tracking
ветки сохранил прежнюю вершину, а локальная gateway Бори так и стоит на старых коммитах:
$ git fetch # строки прогресса опущены
From http://localhost:8766/pay
+ d12e555...ff75a81 gateway -> origin/gateway (forced update)
$ git reflog show origin/gateway
ff75a81 (origin/gateway) refs/remotes/origin/gateway@{0}: fetch: forced-update
d12e555 refs/remotes/origin/gateway@{1}: fetch: storing head
$ git show origin/gateway@{1}:.env
PAY_URL=https://gw.example.com
PAY_TOKEN=pt_live_7Qk2mX9vR4sT8wZ1
Этим же журналом спасают коммиты, которые затёр чужой force push: origin/ветка@{1} хранит
вершину сервера до него. Третий путь опаснее всех: старые коммиты возвращаются на сервер сами. У Бори в
gateway лежит свой коммит поверх старой истории, и он делает то, что делает каждый день:
$ git pull --no-rebase --no-edit
Merge made by the 'ort' strategy.
$ git log --oneline --graph -6
* 3208bcc (HEAD -> gateway) Merge branch 'gateway' of http://localhost:8766/pay into gateway
|\
| * ff75a81 (origin/gateway) Создавать клиента шлюза при старте
| * 620b106 Настройки шлюза из окружения
* | a890ec9 Описать настройки шлюза
* | d12e555 Создавать клиента шлюза при старте
* | 764065c Настройки шлюза из окружения
|/
$ git push # строки прогресса опущены
To http://localhost:8766/pay.git
ff75a81..3208bcc gateway -> gateway
Слияние соединило старую и новую историю, и .env остался в дереве: в одной ветви файл
добавили, в другой его не было вовсе. Push прошёл перемоткой, без force, и запрет force push на
сервере его бы не остановил. В клоне Ани git show origin/gateway:.env снова печатает токен.
Поэтому GitHub требует от коллег rebase вместо merge, а руководство filter-repo советует удалить старые
клоны и склонировать заново.
.env, а служебная ссылка merge request осталась на старом. В клоне Бори старый коммит держат
журнал origin/gateway и локальная ветка: Боря сделал в ней a890ec9 поверх старого. Форки, кэши веб-интерфейса и логи git не
чистит.И это только копии в git. По документации GitHub коммит останется доступен в форках, чистить их придётся с владельцами. Кэш веб-интерфейса и ссылки pull request убирает только поддержка GitHub, и берётся она за это, лишь когда риск нельзя снять ротацией ключа. GitLab хранит данные коммитов, включая содержимое файлов, ещё и в своей базе, поэтому для секретов советует не filter-repo с push, а встроенные Remove blobs и Redact text (роль Owner), а после них housekeeping и Prune unreachable objects. В проекте с форками они не работают. Публичные репозитории, как предупреждает GitHub, ещё и индексируют поисковики и сканеры утечек. Порядок действий собран в вопросе 4.
Вопросы
4git reflog покажет прежнюю вершину, git reset --hard <хеш> или
git branch rescue <хеш> вернёт её. Файл, который был в индексе, найдёт
git fsck --lost-found. Правка, которую ни разу не добавляли, пропала.Почему коммиты целы
reset --hard меняет ссылку, индекс и рабочую копию, объектов в базе он не трогает.
Каждое перемещение HEAD записано в журнал, и пока запись жива, gc считает коммит
достижимым. В git 2.54 без настроек это 30 дней (глава 1.4).
Как вернуть
$ git reflog -3
0f9b137 (HEAD -> main, origin/main) HEAD@{0}: reset: moving to origin/main
8a812cd HEAD@{1}: commit: Логировать отказ шлюза
55a06ec HEAD@{2}: commit: Повторять запрос к шлюзу
$ git reset --hard 8a812cd
HEAD is now at 8a812cd Логировать отказ шлюза
Сразу после reset то же сделает git reset --hard ORIG_HEAD. В команду лучше ставить хеш:
HEAD@{1} сдвигается с каждой новой записью.
Что было в индексе и в рабочей копии
Файл, прошедший через git add, лежит в базе блобом без имени. git fsck --lost-found
выложит содержимое висячих блобов в .git/lost-found/other, нужный узнают через
grep. Правку, которую в индекс не добавляли, git не хранил нигде: перебор всех объектов
через git cat-file --batch-all-objects --batch её не находит.
git gc --prune=now «на всякий случай» сразу удаляет висячие объекты. Коммит после
reset это переживёт, его держит журнал, а блоб из индекса пропадёт. Вместе с
git reflog expire --expire=now --all пропали бы и коммиты.
refs/stash, лежит в
.git/logs и никуда не передаётся. В git 2.54 записи живут 30 дней. Потерянный коллегой
коммит ищут в его клоне.Что записано
В .git/logs/refs/heads/main каждая строка хранит старый и новый хеш, коммитера (того, кто сдвинул ссылку), время и причину.
Журнал HEAD видит и коммиты, и переключения,
журнал ветки только её собственные сдвиги. Отсюда разные ответы у HEAD@{1},
main@{1} и @{1}: последний читает журнал текущей ветки.
Сколько живёт
Записи стирает git reflog expire, его вызывает git gc. По документации срок 90 дней
и 30 для записей о недостижимых коммитах, а в git 2.54 без настроек 30 дней для всех
(глава 1.4). Сразу журнал пропадает при git branch -D,
git fetch --prune для remote-tracking ветки и git reflog drop.
Почему не найдёт чужое
Push и clone журналов не переносят: в свежем клоне одна запись «clone: from …». В bare-репозитории
core.logAllRefUpdates по умолчанию выключен, так что на обычном сервере журнала нет. Если
ветку на сервере затёр force push, прежняя вершина есть у всех, кто получил её раньше: в
origin/ветка, а после fetch в её журнале.
@{yesterday} спрашивает про твою ветку
main@{yesterday} говорит, где стояла main в этом клоне сутки назад, а не
какие коммиты сделаны вчера. Не двигал ветку неделю, получишь её нынешнюю вершину.
git fsck --unreachable с фильтром по merge-коммитам и сообщению «WIP». Висячие объекты
git gc удаляет, когда им больше двух недель.Ветка
$ git reflog | grep -A1 'moving from refund'
0f9b137 HEAD@{4}: checkout: moving from refund to main
ed99e9c HEAD@{5}: commit: Возврат платежа
$ git branch refund ed99e9c
branch -D удаляет журнал ветки, но журнал HEAD остаётся. Если на ветку не переключались
(например, remote-tracking ветку убрал fetch --prune), её коммит окажется среди висячих
в git fsck.
Stash
$ git fsck --unreachable --no-progress | grep commit | cut -d' ' -f3 | xargs git log --merges --no-walk --grep=WIP
commit 6652b810034279c5763c88e9f421894cd18392c7
Merge: c95233e c7fbd15
Author: Аня <anya@example.com>
Date: Sat Sep 5 11:00:00 2026 +0300
WIP on limits: c95233e Проверять валюту
Запись stash устроена как merge-коммит рабочей копии с родителями HEAD и коммитом индекса (глава 2.2),
отсюда --merges. Найденный хеш принимает git stash apply, а
git stash store вернёт запись в список. Спешить стоит: такой коммит держит только отсрочка
gc.pruneExpire.
git stash push -m 'черновик' пишет сообщение «On main: черновик», и фильтр
--grep=WIP такую запись пропускает. Убери --grep и смотри все
merge-коммиты. И учти, что fsck --lost-found игнорирует журналы: коммиты, которые ещё
держит reflog, он тоже покажет висячими.
git filter-repo --sensitive-data-removal в свежем клоне,
git push --force --mirror, поддержка GitHub или Remove blobs в GitLab, новые клоны у всех. Без
ротации переписывание не помогает: токен остаётся в ссылках merge request, клонах, форках и кэшах.Почему мало удалить файл
Коммит с удалением ложится поверх старых, и git show <старый коммит>:.env выведет токен.
Force push переписанной истории чистит ветки сервера, но не refs/merge-requests/* в GitLab и
refs/pull/* в GitHub: push их не меняет. У коллег старые коммиты лежат в ветках и в журнале
origin/ветка@{1}, а git pull со слиянием и git push вернут токен на
сервер без force.
По порядку
- Отозвать токен, выпустить новый, проверить журнал доступа у того, кто токен выдал.
- Решить, нужна ли чистка истории: по словам GitHub, после ротации она может не понадобиться.
- Если нужна: остановить все push, в свежем клоне запустить
git filter-repo --sensitive-data-removal(без флага он удалитorigin), проверитьgit log --all -- .env, отправитьgit push --force --mirror origin. - GitHub: отдать поддержке число затронутых pull request и First Changed Commit, она уберёт ссылки PR и кэш и соберёт мусор. GitLab: Remove blobs или Redact text, housekeeping, Prune unreachable objects.
- Всем склонировать заново. Или перенести свои ветки через rebase, выполнить
reflog expire --expire=now --allиgc --prune=nowи проверить First Changed Commit черезgit cat-file -t.
Как не допустить
.env в .gitignore и проверка секретов в хуке pre-commit (как устроены хуки — глава 5.3). У GitHub push protection
для пользователей включена по умолчанию и не пускает известные форматы ключей в публичные репозитории, а
secret scanning публичных репозиториев сообщает о ключах партнёров прямо выпустившим их сервисам. В GitLab
secret push protection есть на тарифе Ultimate, pipeline secret detection на всех. Сканеры надёжнее всего
ловят известные форматы ключей, самодельный токен может проскочить.
Кто склонировал репозиторий до чистки, уже унёс токен, и до его диска никакая команда не дотянется. Поэтому ключ отзывают первым, а не последним.
5.3Инструменты
git bisect делит историю пополам, пока не останется коммит, сломавший тест, а bisect run
делает это сам, по коду выхода go test. Хуки проверяют код до коммита и до push, worktree открывает
вторую ветку рядом с первой, а подмодули, go.work и LFS решают, как держать чужой репозиторий, соседний
модуль и файл на семь мегабайт. Почти каждый из них умеет ошибиться молча.
- Как
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
Хуком (hook) называют исполняемый файл с именем события, который 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]
Новая копия (linked worktree) получила ветку 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 через три месяца.
Подмодуль: коммит другого репозитория в дереве
Подмодулем (submodule) называют репозиторий, вложенный в каталог другого. Семнадцатого сентября Боря вынес описание 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 1.18) объявляет рабочую
область: перечисленные в нём модули становятся основными, и импорт между ними идёт с диска.
$ 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 (Large File Storage) кладёт в 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.