- Published on
gitの巻き戻し — 状況別に選ぶrestore、reset、revert、reflog
- Authors

- Name
- Youngju Kim
- @fjvbn20031
はじめに — 巻き戻す前に問うべき質問が1つ
巻き戻しの命令を丸暗記すると、毎回また検索することになります。resetとrevertとrestoreとcheckoutが全部似た顔をしていて、検索結果の上位には状況を特定しない命令の羅列が並んでいます。その中から1つ選んで貼り付け、1日分の作業を飛ばすのが最もよくある事故の経路です。
命令を選ぶ前に2つだけ確定させれば、選択肢は1つに絞られます。第一に、戻したいものがどこにあるのか。ワーキングツリーなのか、ステージングエリアなのか、コミットなのか。第二に、そのコミットがすでに他人に渡っているのか。1つ目の問いが命令の種類を決め、2つ目の問いが履歴を書き換えてよいかを決めます。
3つのツリー — 戻せるものと戻せないもの
Gitはファイルの状態を3か所に持っています。直前のコミットが指すスナップショット、次のコミットに入る内容を収めたインデックス、そして今ディスク上に置かれているワーキングツリーです。巻き戻しの命令はすべて、この3つのどこに触るかで区別されます。
$ git status --short
M pricing.ts # インデックスもワーキングツリーもコミットと違う
M coupon.ts # ワーキングツリーだけがコミットと違う
?? refund.ts # どこにもない
ここからこの記事全体を貫く規則が1つ出てきます。コミットされたことがあるものはほぼ必ず復旧でき、コミットされたことがないものはほぼ復旧できません。あとで見るリファレンスログはコミットを指していた記録であって、ワーキングツリーのバックアップではありません。ですから危険度は命令の名前ではなく、「この命令はコミットされていない変更を上書きするか」で測るべきです。
まだコミットしていないとき — restoreとstashの罠
最もよく検索される命令がここにあります。そして最も古い助言が残っている場所もここです。
# ステージングだけ解除(ファイルの内容はそのまま)
$ git restore --staged pricing.ts
# ワーキングツリーの修正を捨てる(元に戻せません)
$ git restore pricing.ts
# 特定のコミット時点のファイルだけ取り出す
$ git restore --source=HEAD~2 --staged --worktree pricing.ts
検索結果の上位には今でも下の形が多く見られます。
$ git checkout -- pricing.ts
$ git reset HEAD pricing.ts
動作は同じですが勧めません。git checkoutはブランチの切り替えとファイルの復元というまったく別の2つの仕事を1つの名前でこなしており、ブランチと同じ名前のファイルがあると意図と違う動きをします。Git 2.23でこの2つをgit switchとgit restoreに割ったのは、まさにそれが理由です。命令が1つしかなかった時代の助言に今から従う理由はありません。
追跡されていないファイルはrestoreの対象ではありません。消すには別の命令であり、こちらはリファレンスログもfsckも助けてくれません。必ず先に確認してください。
$ git clean -nd
Would remove refund.ts
Would remove tmp/
$ git clean -fd
Removing refund.ts
Removing tmp/
少しよけておきたいときに使うstashには罠が3つあります。
$ git status --short
M pricing.ts
?? coupon.ts
$ git stash push -m "쿠폰 작업 중"
Saved working directory and index state On main: 쿠폰 작업 중
$ git status --short
?? coupon.ts
第一に、追跡されていないファイルはそのまま残ります。新しく作ったファイルまでよけるには別のオプションが必要で、無視リストに引っかかるファイルまで含めるにはさらに別のオプションが必要です。
$ git stash push -u -m "쿠폰 작업 중" # 追跡されていないファイルを含める
$ git stash push -a -m "빌드 산출물까지" # 無視されたファイルまで含める
第二に、取り出すときにコンフリクトが起きるとstashは消えずにリストに残ります。成功したときだけ削除されるからで、コンフリクトを解決したあとに削除しないと同じ変更が二重に積み上がります。
$ git stash list
stash@{0}: On main: 쿠폰 작업 중
$ git stash drop
第三に、これが実務で最もよくかかる罠ですが、stashはブランチに縛られません。別のブランチで取り出せばそのまま適用されます。便利な機能ですが、間違って取り出すと関係のないブランチに他人の変更が載ってしまいます。
コミットしたがまだプッシュしていないとき — amendとreset
メッセージだけを直すのか、コミットそのものをなくすのかで分かれます。
# メッセージだけ修正
$ git commit --amend -m "쿠폰 정책 반영 — 만료 검사 추가"
# 直前のコミットに入れ忘れたファイルを載せる(メッセージはそのまま)
$ git add src/order/coupon.ts
$ git commit --amend --no-edit
--amendも新しいコミットオブジェクトを作ります。ハッシュが変わるので、すでにプッシュしたコミットにはリベースとまったく同じ規則が適用されます。
コミットそのものを取り消すときがresetの出番です。3つのオプションは、3つのツリーのどこまでを一緒に動かすかで分かれます。
$ git log --oneline
08daaa8 쿠폰 정책 반영
dd596de 할인율 적용
fc945f2 가격 계산기 초안
$ git reset --soft HEAD~2
$ git status --short
M pricing.ts
$ git reset --mixed
Unstaged changes after reset:
M pricing.ts
$ git status --short
M pricing.ts
--softはコミット2つをなくしながらその内容をステージングされたまま残し、--mixedはステージングまで解除しました。ファイルの内容はどちらでも消えていません。
| 命令 | ブランチのリファレンス | インデックス | ワーキングツリー | 失いうるもの | 使う場面 |
|---|---|---|---|---|---|
git reset --soft | 移動 | そのまま | そのまま | なし | コミット複数を1つにまとめ直すとき |
git reset (既定値のmixed) | 移動 | 対象コミットへ | そのまま | ステージング状態だけ | コミットとステージングを解いてファイルは保つとき |
git reset --hard | 移動 | 対象コミットへ | 対象コミットへ | コミットされていないすべての変更 | 確実に捨てるときだけ |
git reset --keep | 移動 | 対象コミットへ | 重なれば中断 | なし | ローカルの変更を守りながら戻すとき |
git reset --merge | 移動 | 対象コミットへ | マージ状態を整理 | ステージング済みの変更 | 失敗したマージを取り消すとき |
表のなかで実務的に最も重要な行は最後の2つです。戻したいけれど作業中の修正は失いたくないとき、人は習慣的に--hardを打ちますが、その状況にぴったり合うオプションが別にあります。--keepは戻す範囲とローカルの修正が重なると、何もせずに止まります。上書きする前に拒否してくれるほうが、上書きしてから後悔するよりましです。
すでにプッシュしたとき — revertが唯一安全な答えである理由
ここからは規則が変わります。コミットが他人のリポジトリにすでにあるなら、そのコミットをなくすあらゆる方法は他人の履歴を壊します。履歴を直すのではなく履歴に足すのが答えです。
$ git revert 08daaa8
[main 5b21c4e] Revert "쿠폰 정책 반영"
1 file changed, 1 deletion(-)
revertは変更を逆に適用した新しいコミットを1つ積み増します。どのコミットも消えないので、他の人はいつも通り取得すれば済みます。複数をまとめて戻してコミットは1つだけ作りたいなら、こうします。
$ git revert --no-commit dd596de^..08daaa8
$ git commit -m "쿠폰 정책 롤백"
マージコミットは親が2つあるので、どちらを基準に戻すのかを伝える必要があります。ふつうは対象ブランチ、つまり1番目の親です。
$ git revert -m 1 40600e3
[main 3c06ce3] Revert "Merge branch 'feature/checkout'"
1 file changed, 2 deletions(-)
delete mode 100644 retry.txt
ここにあまり知られていない罠があります。マージをrevertしたあとにそのブランチを直して再びマージすると、戻した変更は戻ってきません。Gitから見ればそのブランチはすでにマージ済みであり、マージは履歴の到達可能性だけで判断されるからです。実際のファイルはrevertコミットが消した状態のまま残ります。正解は、再びマージする前にrevertコミットをもう一度revertすることです。
$ git revert 3c06ce3 # revertをrevert
$ git merge feature/checkout
Gitの文書にもこの状況を扱う別項があるほど、よくある事故です。マージを取り消すときは「後でこのブランチをまた入れるのか」をまず判断し、また入れるつもりならマージではなくブランチ側の問題コミットだけをrevertするほうがきれいです。
逆に絶対にやってはいけない助言も明確です。共有ブランチからコミットをなくすと言って巻き戻し、強制的に押し込むやり方です。
# 共有ブランチではやらないでください
$ git reset --hard HEAD~1
$ git push --force origin main
すでに取得した人たちのローカルはそのままで、彼らが次にプッシュすれば消したはずのコミットがよみがえります。消そうとしたものがシークレットだったならもっと深刻です。強制プッシュではサーバー上のオブジェクトが即座に消えるわけではなく、その間にCIのログやフォークにはすでに複製されています。その場合の手順は.gitignoreが効かないときの回の最終節に別途まとめました。
reflog — 消したものはたいていまだそこにある
ブランチを消したり--hardでコミットを飛ばしたりしたとき、真っ先に見る場所です。GitはHEADと各ブランチがどのコミットを指していたかをローカルに記録し続けています。
$ git reflog
fc945f2 HEAD@{0}: reset: moving to HEAD~2
08daaa8 HEAD@{1}: commit: 쿠폰 정책 반영
dd596de HEAD@{2}: commit: 할인율 적용
fc945f2 HEAD@{3}: commit (initial): 가격 계산기 초안
$ git reset --hard HEAD@{1}
HEAD is now at 08daaa8 쿠폰 정책 반영
ブランチを消した場合は1つ知っておく必要があります。ブランチを削除すると、そのブランチのリファレンスログも一緒に削除されます。ですからブランチ名で照会しても何も出てきません。代わりにHEADのリファレンスログには、そのブランチで作ったコミットがそのまま残っています。
$ git branch -D tmp/lost
Deleted branch tmp/lost (was 990a949).
$ git reflog
08daaa8 HEAD@{0}: checkout: moving from tmp/lost to main
990a949 HEAD@{1}: commit: 환불 규칙 추가
08daaa8 HEAD@{2}: checkout: moving from main to tmp/lost
$ git branch tmp/lost 990a949
削除メッセージそのものにコミットハッシュが入っている点も覚えておく価値があります。ターミナルを少し上へスクロールするだけで復旧が終わる場合が多いのです。
リファレンスログの限界も正確に知っておくべきです。これはローカルのファイルであり、クローンには付いてこず、永遠でもありません。既定値は到達可能な項目が90日、到達不能な項目が30日です。
$ git config gc.reflogExpire
90 days
$ git config gc.reflogExpireUnreachable
30 days
そして下の2つの命令はこの安全網を即座に取り払います。リポジトリの容量を減らせるという理由で検索結果によく登場しますが、実行した瞬間に復旧の可能性が消えることを知って使うべきです。
$ git reflog expire --expire=now --all
$ git gc --prune=now
リファレンスログにもないとき — fsckで孤児オブジェクトを拾い上げる
リベースがこじれたり、stashを誤って捨てたり、リファレンスログに記録が残らない形でコミットが外れてしまった場合があります。そのときはオブジェクトデータベースを直接なめます。
$ git fsck --full --unreachable --no-reflogs
Checking object directories: 100% (256/256), done.
unreachable blob ed7ce12274bc71c66cb596feaaf9f02ce91b820e
unreachable tree 970df65883de43d243ce919be12841c5b9f01857
unreachable commit 990a9490f7069940e9d425bde9d1f5bc44048b1d
$ git show --stat 990a949
commit 990a9490f7069940e9d425bde9d1f5bc44048b1d
Author: Demo <d@e.com>
환불 규칙 추가
pricing.ts | 1 +
見つけたら名前を付けて生き返らせます。
$ git branch recovered/refund 990a949
ここでよく見落とされる事実がもう1つあります。出力にblobが一緒に出てくる理由は、ステージングしただけでコミットしていない内容もすでにオブジェクトとして保存されるからです。git addを一度でもしていれば、その時点のファイルの内容はリポジトリの中に入っています。--hardで飛ばしたあとでも、下のように取り出せます。
$ git cat-file -p ed7ce12 > pricing.recovered.ts
ファイルの形でまとめて受け取りたいなら専用のオプションがあります。コミットとそれ以外のオブジェクトをそれぞれのディレクトリに展開してくれます。
$ git fsck --lost-found
$ ls .git/lost-found/commit .git/lost-found/other
この方法にも期限があります。整理作業は既定で2週間を過ぎた到達不能オブジェクトを削除するので、古い事故はここでも拾えないことがあります。
$ git config gc.pruneExpire
2.weeks.ago
おわりに — 覚えるべきは命令ではなく境界線
巻き戻しの命令を全部覚える必要はありません。事故が起きたとき自分に問うべきことは2つだけです。この変更はコミットされたことがあるか、そしてこのコミットは他人に渡ったか。
コミットされたことがあるなら、リファレンスログとfsckがほぼ必ず拾い上げてくれます。コミットされたことがないなら、どの命令も助けてくれません。そして他人にすでに渡っているなら、履歴を直す代わりにrevertで足すのが唯一安全な道です。巻き戻しの実力は命令を知っていることではなく、危険な命令を打つ前にコミットを1つ作っておく習慣です。
命令がなぜこう分かれているのかが気になるなら、Gitの内部構造で理解する命令の回でリファレンスとインデックスの実体を扱いました。