9.1 Что вообще можно отменить
Любая отмена в Git - это попытка ответить на один из вопросов:
- «Я ещё не закоммитил - как откатить незакоммиченные правки?»
- «Я закоммитил, но не запушил - как откатить локальный коммит?»
- «Я запушил - как откатить публичный коммит, не сломав другим?»
- «Я случайно стёр данные - как их вернуть?»
Под каждый вопрос своя команда. Перепутать их - и можно потерять работу, хотя сам Git к этому почти не подталкивает.
| Что отменить | Команда |
|---|---|
| Правку в файле, ещё не застейдженную | git restore <file> |
| Стейдж файла | git restore --staged <file> |
| Все правки в рабочем дереве (опасно) | git reset --hard HEAD |
| Последний коммит, оставив правки в индексе | git reset --soft HEAD~1 |
| Последний коммит, оставив правки в рабочем дереве | git reset --mixed HEAD~1 |
| Последний коммит со всеми правками | git reset --hard HEAD~1 |
| Любой коммит в публичной истории | git revert <sha> |
| Слишком резкий reset (вернуть состояние) | git reflog + git reset |
| Удалённую ветку с несмерженными коммитами | git reflog + git branch <name> <sha> |
Дальше - детали по каждой команде.
9.2 git reset: три режима, три зоны
Главу 5 мы уже разбирали reset через призму трёх зон Git. Повторим, потому что это самая опасная и самая часто встречающаяся команда отмены.
git reset <commit> двигает текущую ветку на <commit>. Что
произойдёт с индексом и рабочим деревом - зависит от флага.
| Флаг | Working tree | Index | HEAD |
|---|---|---|---|
--soft | не трогает | не трогает | двигает |
--mixed (по умолчанию) | не трогает | переписывает | двигает |
--hard | переписывает | переписывает | двигает |
--soft - самый безопасный. Двигает только указатель ветки.
Все правки, которые были после <commit>, оказываются в
индексе - готовы к новому коммиту. Используется для «хочу
переделать последний коммит, сообщение или содержимое»:
git reset --soft HEAD~1
# ... поправь файлы или сообщение ...
git commit -m "новое сообщение"
--mixed (по умолчанию) - двигает ветку, переписывает
индекс на состояние из <commit>, рабочее дерево не трогает.
Все правки, которые были, оказываются как unstaged. Используется
для «отменить последние N коммитов, но не выкидывать правки»:
git reset HEAD~3 # без флага = --mixed
git status # увидишь три коммита правок как modified
--hard - переписывает всё. Все правки после <commit>
исчезают из рабочего дерева. Используется для «полностью
откатить состояние к этому коммиту, потери не страшны»:
git reset --hard HEAD # выкинуть всё незакоммиченное
git reset --hard origin/main # синхронизироваться с remote
--hard стирает незакоммиченные правки в tracked-файлах
безвозвратно. Untracked-файлы при этом остаются на месте -
reset их не видит; вычищаются они git clean -fd. Если не
уверен - сначала git stash или git tag backup как
страховка. Сам коммит, на который ветка указывала до reset,
остаётся в reflog. Незакоммиченные правки - нет, их никогда не
существовало как объектов Git.
9.3 git revert: отмена для публичной истории
git reset подходит для своей локальной истории. Для публичной
- нет: после reset нужно force-push, а force-push на общую ветку запрещён (см. главу 8).
Для публичной истории есть git revert <sha>. Эта команда не
удаляет коммит, а создаёт новый коммит, который вносит
обратные правки. Старый коммит остаётся в истории, к нему
просто добавляется коммит-отменитель.
git revert a1b2c3d
Git откроет редактор для сообщения (по умолчанию Revert "..."),
сохрани и выходи. История получит новый коммит:
до: A → B → C → D (HEAD)
после: A → B → C → D → D' ← D' - это revert D
Содержимое после revert идентично содержимому до коммита D.
Эта команда безопасна для публичной истории: ни один SHA не
переписывается, force-push не нужен.
Полезные опции:
git revert <sha1>..<sha3> # отменить серию коммитов
git revert --no-commit <sha> # применить отмену, но не коммитить
# (можно собрать с другими в один)
git revert -m 1 <merge-sha> # отменить merge-коммит (см. ниже)
Отмена merge-коммита
Merge-коммит имеет двух родителей, и Git не знает, к какому
«возвращаться». Опция -m <N> указывает «оставайся на стороне
родителя N»:
git revert -m 1 <merge-sha>
-m 1 - оставайся на стороне первого родителя (того, который
был на main до merge). -m 2 - на стороне второго (из ветки).
В 95% случаев нужен -m 1 - «откатить merge, как будто фичу
не вливали».
Подвох: после revert merge-коммита та фича считается уже смерженной, и при повторной попытке merge той же ветки Git решит, что вливать нечего. Нужно либо revert revert'а, либо rebase ветки на свежий main.
9.4 git restore: точечная отмена файлов
Главу 6 мы уже видели git restore. Тут - про роль команды в
«отмене».
Сценарии:
# Откатить незакоммиченные правки в файле
git restore file.txt
# Откатить незакоммиченные правки во всём дереве
git restore .
# Убрать файл из стейджа, оставить правки в рабочем дереве
git restore --staged file.txt
# Достать версию файла из старого коммита (текущий не двигается)
git restore --source=HEAD~3 file.txt
git restore --source=v1.0 README.md
# Достать в рабочее дерево И в индекс
git restore --source=HEAD~3 --staged --worktree file.txt
restore отличается от reset тем, что не двигает ветку. Это
команда «верни мне эту версию файла», а не «верни мне состояние
репозитория».
Все варианты restore для незакоммиченных правок - необратимы.
Файл в рабочем дереве перезаписывается без подтверждения. Если
боишься - git stash сначала.
9.5 git stash: отложить незавершённое
Stash - это специальная зона, куда можно временно отправить незакоммиченные правки. Используется, когда:
- нужно срочно переключиться на другую ветку, а у тебя на текущей грязное рабочее дерево;
- попробовал что-то, не понравилось, не хочется коммитить;
- нужно
git pull, но pull отказывается из-за локальных правок.
git stash # отправить правки в stash
# ... переключиться, поработать, вернуться ...
git stash pop # вытащить и применить, удалить из stash
По умолчанию stash берёт только отслеживаемые файлы.
Untracked не попадут, и можно потом удивиться, что они никуда
не делись и продолжают мешать. Решение - флаг -u:
git stash -u # включая untracked
git stash -a # включая ещё и игнорируемые (.gitignore)
Полезные команды:
git stash list # список всех stash
# stash@{0}: WIP on main: a1b2c3d initial commit# stash@{1}: WIP on feat: e4f5a6b add logingit stash show # diff верхнего stash
git stash show stash@{1} # конкретный stashgit stash show -p stash@{0} # с полным патчемgit stash apply # применить, НЕ удалять из stash
git stash pop # применить И удалить
git stash drop stash@{1} # удалить, не применяяgit stash clear # удалить все stash
git stash branch feat-recovery stash@{0}# создать новую ветку из stash
apply vs pop - частая путаница. pop равен apply + drop.
Если боишься, что применение пойдёт не так - используй apply
и drop отдельно после проверки.
Физически stash - это два-три обычных коммита (один для index,
один для working tree, один для untracked если -u),
привязанных к специальной ссылке refs/stash. Поэтому stash
переживает gc и хранится столько, сколько ты ему позволишь.
См. stash.
9.6 Reflog: спасательный круг
Reflog - самая недооценённая команда Git. Это журнал всех движений HEAD: записи для достижимых состояний по умолчанию хранятся 90 дней, для недостижимых - 30 (см. подробности ниже). Каждый раз, когда что-то двигает HEAD (commit, checkout, reset, merge, rebase, pull), Git добавляет запись.
git reflog
# a1b2c3d HEAD@{0}: reset: moving to HEAD~3# 4d5e6f7 HEAD@{1}: commit: feat: add login# 8a9b0c1 HEAD@{2}: commit: docs: update README# ...
Каждая строка - предыдущее состояние HEAD, со SHA коммита и причиной перехода. Это значит, что любое состояние, в котором ты был за последний месяц, восстановимо.
Главный сценарий - после катастрофического reset:
git reset --hard HEAD~10 # ой, не туда
git reflog
# вверху - текущее состояние (после reset)
# дальше - состояния до. Найди SHA правильного
git reset --hard <sha-правильного>
# вернулся
Reflog работает для:
reset --hard- самый частый случай. SHA состояния до reset в reflog`е первой строкой.commit --amend- старый коммит остался;HEAD@{1}обычно указывает на него.rebase- длинная серия записей с подробностями. До и после.merge-HEAD@{0}после,HEAD@{1}до.- Удалённая ветка через
branch -D- её последний SHA тоже в reflog (потому что когда-то ты на ней был). - Случайный коммит в detached HEAD - отмеченный как
checkout: moving to ...иcommit:.
Конкретные синтаксы:
git reflog show feature # reflog конкретной ветки
git reflog show HEAD # reflog HEAD (то же что просто reflog)
git reset --hard HEAD@{2} # вернуться к третьему состоянию назадgit checkout HEAD@{1} # просто посмотретьReflog хранится в .git/logs/. Срок хранения по умолчанию - 90
дней для достижимых коммитов и 30 для недостижимых. Если хочешь
изменить:
git config --global gc.reflogExpire "180 days"
git config --global gc.reflogExpireUnreachable "90 days"
См. reflog.
9.6.1 Подводный камень: когда reflog не поможет
Reflog хранит только локальные движения HEAD на твоей машине. На коллегиной машине у его reflog'а свои записи - твои действия там не видны.
Что reflog не покрывает:
- Незакоммиченные правки в рабочем дереве. Если ты сделал
git reset --hard HEADилиgit restore file.txt, некоммиченные правки потеряны. Они никогда не существовали как Git-объекты, reflog их не помнит. - Untracked-файлы, которые попали под
git clean -fd. Та же история. - Состояния ветки на чужой машине. Если коллега force-push'нул и переписал твою публичную ветку - его reflog'е есть твои старые коммиты, в твоём - нет (если ты не клонил после force-push).
- Состояния старше срока reflog'а. По умолчанию 30 дней для
недостижимых веток. После этого
git gcподметает.
Также reflog очищается явными командами:
git reflog expire --all --expire=now
git gc --prune=now
Не запускай это без острой нужды - это сжигание спасательного
круга. Случаев, когда reflog реально мешает, мало (диск
кончается на больших репах, и то лучше через git repack).
Правило к запоминанию: gc --prune=now через несколько
минут после катастрофы - это всё. Если что-то пошло не так
- не запускай
gc, сначала разберись.
Уроки в sandbox
lab-9.1. Случайный reset --hard и восстановление через reflog
Цель - спровоцировать «катастрофическую» потерю коммитов и восстановить их через reflog. После лабы становится понятно, что reset --hard - не «удаление навсегда», а «двинул указатель ветки».
Создай репозиторий, сделай пять коммитов:
mkdir undo-lab && cd undo-lab && git init && for i in 1 2 3 4 5; do echo "line $i" >> file.txt && git add file.txt && git commit -m "commit $i"; done.Посмотри историю:
git log --oneline. Должно быть 5 коммитов. Запомни SHA верхнего (HEAD).Сделай «катастрофу»:
git reset --hard HEAD~3. Это сбросит ветку на третий коммит снизу. Проверьgit log --oneline- два верхних коммита исчезли. Проверьfile.txt- там только 3 строки.Посмотри reflog:
git reflog. Увидишь запись о reset:... HEAD@{0}: reset: moving to HEAD~3и до неё - запись о последнем commit с SHA, который ты «потерял».Восстанови: возьми SHA из
HEAD@{1}(или явный SHA из reflog) и сделайgit reset --hard HEAD@{1}(илиgit reset --hard <sha>). Проверьgit log --oneline- все 5 коммитов снова на месте.Эксперимент с untracked: создай
temp.txt(echo "draft" > temp.txt), НЕ делайgit add. Запустиgit clean -fd. Файл удалён. Запустиgit reflog- там его нет, его никогда не было в reflog. Untracked, не закоммиченное, через clean удалённое - это потеряно. Урок: коммить чаще, даже если не уверен.Эксперимент с amend: сделай коммит (
echo "line 6" >> file.txt && git add file.txt && git commit -m "line 6"). Потомgit commit --amend -m "line 6 (corrected)". Запустиgit reflog- увидишь обе версии: одну какcommit (amend):, другую какcommit:. Если хочется откатить amend -git reset --hard HEAD@{1}.Эксперимент с удалением ветки: создай feature (
git switch -c feature && echo "feat" >> feat.txt && git add feat.txt && git commit -m "feature work"), запомни SHA, переключись на main и удали:git switch main && git branch -D feature. Запустиgit reflog- последний SHA feature там есть. Восстанови ветку:git branch feature <sha>.
sandbox с автопроверкой - открыть в песочнице
Резюме
- Перед любой опасной операцией думай: «что я отменяю - незакоммиченное или закоммиченное?». Закоммиченное почти всегда восстановимо. Незакоммиченное - нет.
- `git reset` двигает ветку. Три флага: `--soft` (только HEAD), `--mixed` (HEAD+index, по умолчанию), `--hard` (всё). `--hard` стирает незакоммиченные правки.
- `git revert <sha>` - создаёт новый коммит с обратными правками. Безопасно для публичной истории, force-push не нужен. Merge-коммит - `revert -m 1 <sha>`.
- `git restore file.txt` - откатить файл к версии из индекса/HEAD. `--staged` - убрать из стейджа. `--source=<commit>` - достать версию из коммита.
- `git stash` - отложить незакоммиченные правки в специальную зону. `-u` для untracked, `pop` чтобы применить и удалить, `apply`+`drop` если хочется проверить перед удалением.
- Reflog - журнал движений HEAD за последние 30+ дней. Главный инструмент восстановления после `reset --hard`, `--amend`, `rebase`, удаления ветки.
- Reflog не покрывает: незакоммиченные правки, untracked после `clean`, состояния старше срока, чужие машины. Не запускай `git gc --prune=now` сразу после катастрофы.
Контрольные вопросы
Я случайно сделал `git reset --hard HEAD~5` и хочу вернуть последние 5 коммитов. Что делать?
Показать ответ
Спокойно. Запусти
git reflog- первая или вторая строка будетHEAD@{1}: commit: ...с SHA состояния до reset. Скопируй SHA, сделайgit reset --hard <тот-SHA>(или эквивалентgit reset --hard HEAD@{1}). Все 5 коммитов снова на месте, ветка восстановлена. Reflog хранит записи 30 дней, так что времени достаточно - главное, не запускайgit gc --prune=nowдо восстановления. Имей в виду: если незакоммиченные правки были в рабочем дереве на момент reset - они потеряны, reflog их не помнит.В чём принципиальная разница между `git reset` и `git revert`?
Показать ответ
resetдвигает указатель ветки назад - то есть «делает вид, что последних коммитов не было». Это переписывает историю. Безопасно только на личных ветках до push.revertсоздаёт новый коммит, который вносит обратные правки. История остаётся как была, но добавляется коммит-отменитель. Безопасно в любом контексте, потому что не переписывает существующие SHA. Правило: до push -reset(если хочется чистой истории), после push -revert(чтобы не ломать другим работу).Сделал `git stash`, потом сделал ещё несколько правок и коммитов. Хочу вернуть тот stash. Как?
Показать ответ
Stash не «привязан» к ветке или коммиту - он живёт отдельно в
refs/stash. Запустиgit stash list- увидишь свой stash какstash@{0}(или дальше, если делал несколько stash). Чтобы применить -git stash apply stash@{0}(илиpop, если хочешь сразу удалить). Если в текущем рабочем дереве есть правки, пересекающиеся со stash, может возникнуть конфликт - разруливай как обычный конфликт черезgit addпосле правки. На случай «слишком много стало stash'ей» -git stash listпокажет их все с описанием, какой когда был сделан и на какой ветке.У меня в команде кто-то запушил плохой merge на main. Как откатить, чтобы не ломать всем?
Показать ответ
git revert -m 1 <sha-merge-коммита>. Это создаст новый коммит, откатывающий весь merge, оставаясь на стороне main (родитель 1). Запушить - обычнымgit push, никаких force не нужно. После revert все, кто склонит main, естественно получат это исправление. Подвох: если потом понадобится снова влить ту же feature, простымgit merge featне получится - Git решит, что ветка уже смержена. Нужен либо revert revert'а, либо rebase feature на новый main с пересборкой коммитов.Что произойдёт с моими коммитами на удалённой ветке, если я её удалю через `git branch -D`?
Показать ответ
Локально ветка пропадёт из
.git/refs/heads/. Коммиты, на которые она указывала, останутся в.git/objects/. Если ни одна другая ветка/тег/HEAD на них не указывают - они станут недостижимыми. Reflog при этом запомнит SHA вершины ветки в течение 30 дней. Восстановить -git branch <name> <sha-из-reflog>. Если же ветка была запушена в origin - на сервере она остаётся, пока не удалишь её явно черезgit push origin --delete <name>. Так что «полное» удаление - это два шага: локально и на remote.