Картина из жизни. Кто-то влил в main коммит, который роняет прод: или баг, или случайно закоммитили .env с секретами, или удалили миграцию. Пока ты это заметил, десять человек уже сделали git pull. И тут у новичка дрожит рука потянуться к git reset --hard или git rebase, чтобы "стереть как будто не было". Не делай так. Это самый быстрый способ устроить катастрофу всей команде.
Почему? Потому что reset и rebase переписывают историю - меняют хеши коммитов. Локально это норма. Но если коммит уже опубликован и его утянули другие, ты создаёшь две расходящиеся версии истории. Их push упрётся в твою, твой - в их, начнётся война force-push, и кто-то обязательно затрёт чужую работу. Для уже опубликованной истории есть честный, скучный, безопасный инструмент - git revert. Он не врёт и не прячет, он добавляет.
В этом уроке разберём, как git revert отменяет коммит, не ломая ничего у коллег, почему откатить изменения git через revert безопасно, и самое тонкое - как git revert merge коммита работает с выбором родителя и какие там грабли. Опорно это тема главы 7 второй книги, но смотрим глубже и руками.

Что на самом деле делает git revert
Ключевая мысль, которую надо вбить в голову раз и навсегда: git revert не удаляет коммит. Он создаёт новый коммит, который вносит обратные изменения. Если исходный коммит добавил строку, revert-коммит её удаляет. Если исходный удалил файл - revert его вернёт. Старый коммит остаётся в истории на своём месте, его хеш не меняется, ничей pull не сломается.
Механически revert берёт diff целевого коммита (разницу между ним и его родителем), инвертирует его и применяет на твоё текущее состояние (рабочую копию плюс индекс), как обычный патч. По сути это git diff C..C^ примённый поверх HEAD. Поэтому отменить коммит git через revert - это операция вперёд по времени, а не назад: история только растёт.
Код: Выделить всё
* e4a91c2 (HEAD -> main) Revert "Add broken cache layer"
* 9f3bd07 Add unrelated feature
* 1c2d3e4 Add broken cache layer <- остаётся как был
* abc1234 Initial setup
Золотое правило: public -> revert, local -> reset
Запомни границу, она решает 90 процентов вопросов "как откатить изменения git".
- Коммит уже опубликован (запушен в общую ветку, его могли утянуть другие) - только git revert. Историю не трогаем, добавляем компенсирующий коммит.
- Коммит ещё локальный (только у тебя, не пушен или пушен в личную ветку, которую никто не трогает) - можно git reset, git rebase, amend. Переписывай как хочешь, ты никому не мешаешь.
Практика: отменяем обычный коммит
Берём самый частый случай - один плохой коммит в main, который уже у всех. Находим его хеш и реверчим.
Код: Выделить всё
$ git log --oneline -3
9f3bd07 (HEAD -> main, origin/main) Add unrelated feature
1c2d3e4 Add broken cache layer
abc1234 Initial setup
$ git revert 1c2d3e4
Код: Выделить всё
Revert "Add broken cache layer"Код: Выделить всё
This reverts commit 1c2d3e4...Код: Выделить всё
$ git revert 1c2d3e4
[main e4a91c2] Revert "Add broken cache layer"
1 file changed, 0 insertions(+), 14 deletions(-)
$ git push
Полезные флаги:
- git revert --no-edit - не открывать редактор, взять сообщение по умолчанию. Удобно в скриптах и когда правишь второпях.
- git revert --abort - всё пошло не так (конфликт, передумал) - откатить саму операцию revert к исходному состоянию.
- git revert --continue - после ручного разрешения конфликта продолжить.
Самое тонкое: git revert merge и выбор родителя -m
Вот где спотыкаются даже опытные. Обычный коммит имеет одного родителя, и "обратный diff" очевиден. А merge-коммит имеет двух родителей (а octopus-merge и больше). Какой из них считать "линией, к которой возвращаемся"? Git сам решить не может и без подсказки откажется:
Код: Выделить всё
$ git revert 7c0ffee
error: commit 7c0ffee is a merge but no -m option was given.
fatal: reverting is not possible because you have not given a mainline.
- -m 1 - первый родитель. Это ветка, в которую вливали (обычно main). Берёшь её за основу, выкидываешь всё, что принесла влитая ветка. Это нужно в 99 процентах случаев.
- -m 2 - второй родитель, влитая feature-ветка. Используется редко.
Код: Выделить всё
$ git show 7c0ffee --no-patch --format="%h parents: %p"
7c0ffee parents: 9f3bd07 5e1c0de
Код: Выделить всё
$ git revert -m 1 7c0ffee
[main 0bad10a] Revert "Merge feature/cache into main"
3 files changed, 0 insertions(+), 120 deletions(-)
Подводный камень: как потом снова влить ту же ветку
А вот это - грабли, которые портят людям дни. Ты отменил merge через revert -m 1. Позже команда чинит feature-ветку и хочет влить её снова. Делаешь git merge feature - и Git заявляет "Already up to date" или вливает только новые коммиты, а старые исправления feature не появляются. Почему?
Потому что для Git ветка feature уже смерджена - merge-коммит на месте, он в истории. Git считает её содержимое уже учтённым. Но твой revert-коммит затем выкинул это содержимое. Git этого "выкидывания" при следующем merge не учитывает - он смотрит только на граф предков, а по графу feature давно влита. Итог: повторный merge не вернёт откаченные изменения.
Правильный путь - отменить сам revert (revert of a revert) перед новым слиянием:
Код: Выделить всё
$ git revert 0bad10a
[main 2f0 recover] Revert "Revert "Merge feature/cache into main""
Запомни как мантру: git revert merge не "размерживает" ветку, он лишь снимает её эффект. Граф предков остаётся, и Git считает ветку влитой навсегда.
Откат диапазона и пакетная отмена через --no-commit
Иногда плохих коммитов несколько подряд. Реверт умеет диапазоны - синтаксис тот же, что у git log, и порядок отмены Git сам делает обратным (сначала новейший), чтобы патчи легли чисто:
Код: Выделить всё
$ git revert abc1234..9f3bd07
Не хочешь россыпь коммитов, а один общий откат - флаг --no-commit (короткий -n). Он применяет инверсии в индекс и рабочую копию, но коммит не делает - ты сам собираешь один:
Код: Выделить всё
$ git revert --no-commit abc1234..9f3bd07
$ git revert --no-commit 1c2d3e4
$ git status
$ git commit -m "Revert broken cache rollout (3 commits)"
Типичные грабли
- Реверт на публичной ветке через reset. Самая дорогая ошибка. reset --hard на запушенном коммите плюс force-push затирает чужую работу и разносит историю. На общей ветке - только revert.
- Забыли -m на merge. Git честно откажется и подскажет. Не лепи -m 1 наугад - сперва git show ...--format="%p" и убедись, какой родитель main.
- Перепутали -m 1 и -m 2. Реверт "не того" родителя оставит в main мусор вместо чистки. Если уже закоммитил и запушил - реверти этот revert и делай заново с правильным mainline.
- Ждут, что revert merge даст повторно влить ветку. Нет. Нужен revert of revert (см. выше), иначе исправления феатуры не вернутся.
- Реверт не убирает секрет из истории. Если в плохом коммите утёк токен, revert уберёт его из текущего состояния, но сам секрет остаётся в старом коммите навсегда и виден в git log -p. Тут revert недостаточно - нужно переписывание истории через git filter-repo (сторонний инструмент) или BFG, force-with-lease и обязательно ротация утёкшего секрета. Считай засветившийся токен скомпрометированным.
Собери учебный репозиторий и проиграй оба сценария - обычный коммит и merge.
Код: Выделить всё
mkdir revert-lab && cd revert-lab
git init -b main
echo "ok" > app.txt && git add . && git commit -m "init"
# плохой обычный коммит
echo "BROKEN" >> app.txt && git commit -am "broken change"
git log --oneline
git revert --no-edit HEAD
cat app.txt # строки BROKEN уже нет
git log --oneline # init, broken change, Revert "broken change"
# теперь merge-сценарий
git switch -c feature
echo "feature" >> app.txt && git commit -am "feature work"
git switch main
git merge --no-ff feature -m "Merge feature"
git show HEAD --no-patch --format="%h parents: %p" # увидишь двух родителей
git revert -m 1 --no-edit HEAD # откат merge по mainline=main
cat app.txt # строки feature нет
# повторно вливаем feature правильно: revert of revert
git revert --no-edit HEAD # отменяем сам revert merge
cat app.txt # строка feature вернулась
Контрольные вопросы
- Коллега запушил баг в main, его уже все запулили. Почему git reset --hard плюс force-push - плохая идея, а git revert - хорошая?
- Что выведет git log после git revert старого коммита: исходный коммит исчезнет или нет, и сколько коммитов добавится?
- Откатываешь merge-коммит. Что означает -m 1, как узнать, какой родитель main, и что будет, если ошибиться номером?
- Ты отменил merge через revert -m 1. Команда дописала feature-ветку. Почему обычный git merge feature не вернёт изменения и как влить её правильно?
git revert - твой штатный инструмент отмены в общей истории: он не переписывает прошлое, а добавляет коммит-компенсацию, поэтому безопасен для всех, кто уже сделал pull. Золотое правило простое: публичная история - revert, локальная - можно reset или rebase. Для merge-коммита всегда указывай родителя через -m (почти всегда -m 1, это main), а если потом понадобится снова влить ту же ветку - отменяй сам revert, потому что граф предков по-прежнему считает ветку влитой. Диапазоны и --no-commit дают гибкость от поштучного аудита до одного общего отката. И помни: revert чистит текущее состояние, но не стирает прошлое - для утёкших секретов нужны другие инструменты и ротация.