voidlearn
 

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 не было нигде, кроме файла, и её ничем не достать.

--hard стирает и то, что было только в индексе

Кажется, что 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~ из состояния перед откатом, по режиму на копию:

Ветка main Индекс Рабочая копия git status --short до reset 90c11e4 кэш заказов как в 90c11e4 и cache_test.go как в индексе и правка в handler.go и неотслеживаемый notes.txt A  cache_test.go  M handler.go ?? notes.txt --soft 1f0321a сдвинута не тронут кэш теперь «добавлен» не тронута A  cache.go A  cache_test.go  M handler.go M  store.go ?? notes.txt --mixed 1f0321a сдвинута переписан из 1f0321a не тронута новые файлы стали неотслеживаемыми  M handler.go  M store.go ?? cache.go ?? cache_test.go ?? notes.txt --hard 1f0321a сдвинута переписан из 1f0321a переписана из 1f0321a cache.go и cache_test.go удалены правки в handler.go больше нет notes.txt на месте ?? notes.txt
Один reset HEAD~ в трёх режимах. Каждый режим идёт на шаг дальше предыдущего: soft переставляет только ветку, mixed ещё и переписывает индекс, hard доводит до коммита и файлы на диске. Правки на диске затирает только последний.

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» Линуса Торвальдса и Джунио Хамано.

Сразу влить исправленную ветку Сначала revert отката, потом слияние слияние revert пустое слияние 66c2b9a acc2005 682978e dd31b37 9abef44 общий предок починка Create дерево 682978e как у acc2005: retry.go нет слияние revert Reapply слияние 66c2b9a acc2005 f81fac8 0bff74a dd31b37 9abef44 общий предок починка Create в 0bff74a retry.go есть, Create без повтора
Почему повторное слияние в примере пустое. Для обоих слияний общий предок один и тот же, вершина ветки до починки. Слева со стороны main ветку уже убрал revert, починка совпала с частью его правки, и добавить нечего. Справа 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).

Вопросы

4
Суть: все три переставляют текущую ветку на указанный коммит. soft на этом останавливается, mixed ещё копирует дерево коммита в индекс, hard копирует его и в рабочую копию. Отброшенные коммиты в любом режиме находятся через ORIG_HEAD и reflog. soft и mixed файлы на диске не трогают, hard затирает незакоммиченные правки в отслеживаемых файлах.

Что видно после каждого режима

Опыт со схемы из раздела про --hard, git reset HEAD~ на трёх копиях:

Режимgit diff --stagedgit 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 выбрасывает коммиты, но откажет, а не затрёт правку в файле, который различается в двух коммитах.

«Откачусь через hard, правки же в git»

В 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).

revert не убирает данные из истории

Отменённый коммит остаётся в истории, в каждом клоне и на сервере. Для ошибки в коде это нормально, для пароля или ключа нет: git show 024f0d6 покажет всё, что было в коммите. Утёкший секрет отзывают и меняют, а про переписывание истории рассказано в главе 5.2.

Суть: revert слияния отменяет данные, но не историю: старые коммиты ветки остаются предками 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 предупреждает, что он столкнёт старые версии правок с новыми.

rebase на свежую main тут не помогает

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, чтобы не переписать лишние изменения.