5.1Отмена
Сравнишь reset и revert по тому, что они меняют в branch, index и working tree. Разберёшь mainline при откате merge и выберешь безопасный способ отмены уже опубликованного изменения.
- Что
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
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.
Что стоит запомнить
- reset двигает текущую ссылку и в зависимости от режима меняет index и working tree.
- revert создаёт новый commit с обратным изменением.
- Для revert merge-коммита нужно выбрать mainline parent через -m.
Частые ошибки
- reset --hard может удалить незакоммиченные изменения.
- Revert merge влияет на будущие повторные слияния этой ветки.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Сравни три режима reset
История линейная: B - родитель C. В трёх отдельных учебных копиях HEAD, index и working tree содержат C, незакоммиченных правок нет. В каждой копии выполняют одну из команд. Что окажется в HEAD, index и working tree после неё?
# copy 1
git reset --soft HEAD~1
# copy 2
git reset --mixed HEAD~1
# copy 3
git reset --hard HEAD~1
Ответ и разбор
Soft даёт HEAD=B, index=C, working tree=C. Mixed даёт HEAD=B, index=B, working tree=C. Hard даёт HEAD=B, index=B, working tree=B. Поэтому soft оставляет разницу staged, mixed оставляет её незастейдженной, hard удаляет её из обоих состояний. Здесь копии чистые; в рабочем репозитории hard также может стереть незакоммиченные изменения.
Код-ревью
2. Не откатывай общую ветку reset-ом
Плохой commit уже запушен в main. В инструкции предлагается:
git reset --hard HEAD~1
git push --force origin mainКак отменить изменение без переписывания истории?
Ответ и разбор
Reset перепишет общую ветку и может убрать commits коллег. Создай обратный commit через git revert <bad-commit> и отправь обычным push. Перед откатом проверь зависимости от изменения и миграции: обратный patch не отменит внешние side effects.
Применение
3. Выбери parent для revert merge
Merge commit влил feature в main, где он первый parent. Нужно отменить это слияние, сохранив историю. Какую команду применить и что она меняет?
Ответ и разбор
Используй git revert -m 1 <merge-commit>. Первый parent задаёт mainline, относительно которого строится обратный patch. Команда добавит новый commit, не удаляя merge из графа; проверь результат и влияние на последующие merge feature.
Применение
4. Убери файл из последнего commit
Последний commit локальный, ещё не отправлен. В него попал лишний debug.log, который должен остаться на диске. Как изменить commit?
Ответ и разбор
Убери файл из index, не удаляя рабочую копию, затем пересобери последний commit: git rm --cached debug.log, добавь нужное правило ignore и выполни git commit --amend --no-edit. Проверь staged diff до amend, чтобы не переписать лишние изменения.