Gitの事故から復旧する手順:消えた作業・誤コミット・誤push

worktree ごと消えた作業、push 前の直したいコミット、入れてはいけないファイル、force push、消したはずのブランチの復活。筆者が 1 週間で踏んだ 5 つの事故と、git 2.55.0 で確かめた戻し方を解説する。

Gitの事故から復旧する手順:消えた作業・誤コミット・誤push

本記事はプロモーションを含みます。

はじめに: 消えたように見えても、Git の中に残っていることが多い

worktree の消失、push 前のコミット、誤って入れたファイル、force push、消したブランチの復活の 5 つの事故と、戻すときに使うコマンド

作業ツリーをブランチごと消してしまった。英語で書いてはいけないコミットメッセージを英語で書いた。デバッグ用のログをコミットに入れた。確認せずに force push した。消したはずのブランチがリモートに戻ってきた。

どれも筆者がこの 1 週間で踏んだ事故だ。半分は AI エージェントに作業させている途中で起きた。結論は次のとおり。

  • 消えた作業は、コミットかステージをしていれば git fsck --lost-found で拾える。ステージしていないファイルは戻らない
  • push 前のコミットは git reset --soft で作り直す。入れてはいけないファイルは fixup コミットで元のコミットから外す
  • 履歴を書き換えて push するなら --force-with-lease --force-if-includes。消したリモートブランチは、手元に残った同名のブランチから push すると作り直される

以下、どれも git 2.55.0 の使い捨てのリポジトリで再現して確かめた。

worktree ごと消えた作業を取り戻す

記事を自動で書く仕組みでは、記事ごとに git worktree で作業ツリーを分けている。ある日、書き上げてステージまでした記事が、作業ツリーとブランチごと消えていた。

本文は Git の参照の無いオブジェクトから取り出せた。一方で、記事に入れていた挿絵 9 本は戻せなかった。何が戻り、何が戻らないかは、消える前にどこまで Git に渡していたかで決まる。

コミット済みなら dangling commit から戻す

worktree とブランチを消すと reflog にも残らないが、git fsck --lost-found で dangling commit として見つかり、ブランチを付け直せる

コミットまでしていれば、まず git branch -D の出力を探す。消したときに (was 6eaebb4) と先端のコミットを出しているので、端末の履歴に残っていればそれでブランチを作り直せる。

git branch article 6eaebb4

出力が残っていなくても、コミットそのものは残っている。ただし git reflog には出てこない。ブランチの reflog は git branch -D で一緒に消え、HEAD は worktree ごとに持つ (man git-worktree: 「all pseudo refs are per-worktree」) ので、元の作業ツリーの HEAD の記録には別の worktree でのコミットが入らない。上の図でも、元の作業ツリーの git reflog には最初のコミットしか無い。

どの参照からも辿れなくなったコミットは git fsck --lost-found が dangling commit として出し、.git/lost-found/commit/ に書き出す。中身を git log --oneline -1 <id> で確かめてからブランチを付ける。

ステージしただけのファイルは中身だけ戻る

ステージしたファイルは dangling blob として中身が残り、grep で探せる。ファイル名とステージしていないファイルは戻らない

コミットしていなくても、git add した時点でファイルの中身は Git のオブジェクト (blob) になる。作業ツリーを消してもこの blob は残るので、git fsck --lost-found で拾える。

git fsck --lost-found
cd .git/lost-found/other
grep -l 'CD が緑' *     # 本文にあったはずの言葉で探す

man git-fsck の --lost-found には、blob ならファイルの中身をそのまま書き出すとある。

If the object is a blob, the contents are written into the file, rather than its object name.

戻らないものが 2 つある。

  • ファイル名: blob は中身だけを持ち、名前はコミットのツリー側が持つ。ステージしただけではツリーが作られないので、名前は自分で付け直す
  • ステージしていないファイル: 上の図の memo.txt (??) は Git に渡していないので、どこにも残らない

ちなみに git worktree remove は、変更や未追跡のファイルがある作業ツリーを消さない。

fatal: '../wt' contains modified or untracked files, use --force to delete it

このエラーを --force で押し切ると、未追跡のファイルは戻せない。消す前に git -C <作業ツリー> status --short を見て、?? の行が残っていないかを確かめる。

戻せるのは gc に消されるまで

参照の無いオブジェクトは、いつまでも残るわけではない。git gc は既定で 2 週間より古いものを消す (man git-gc の --prune: 「default is 2 weeks ago, overridable by the config variable gc.pruneExpire」)。

消えたと気付いたら、git gc --prune=now のような掃除をする前に git fsck --lost-found を打つ。

push 前のコミットを作り直す

git reset --soft HEAD~2 は HEAD だけを戻し、2 つのコミットの変更はステージに残るので、1 回の commit で作り直せる

AI エージェントに作業させていたリポジトリで、コミットメッセージを英語で書かれた。そのリポジトリは「コミットは日本語で」と決めている。push 前だったので、作り直すことにした。

直すのが直前の 1 つなら git commit --amend で済む。複数あるなら git reset --soft で戻してからコミットし直す。man git-reset の --soft には次のようにある。

Leave your working tree files and the index unchanged. For example, if you have no staged changes, you can use git reset –soft HEAD~5; git commit to combine the last 5 commits into 1 commit.

git log --oneline origin/main..HEAD   # 作り直す範囲が push 前のコミットだけか確かめる
git reset --soft HEAD~2
git commit -m 'a と b を追加'

筆者はエージェントの権限設定で git reset --soft を禁止している。このときもエージェントは自分では打てず、筆者が打って作り直した。履歴を書き換える操作は、push 前でも人が打つ形にしておける。

入れてはいけないファイルを履歴から外す

作業ブランチを rebase で追従させている途中で、git add -A がエミュレータのデバッグログを拾い、コミットに入った。.gitignore にそのログを足す行は、ブランチの後ろのコミットで入るので、途中のコミットではまだ無視されていなかった。

まだ push していない (あるいは自分しか使っていない) ブランチなら、fixup コミットで元のコミットから外す。

fixup コミットと –autosquash で元のコミットに畳む

git rm --cached でファイルを外す fixup コミットを作り、rebase --autosquash で元のコミットに畳むと、どのコミットにもファイルが残らない
git show --stat --oneline HEAD~1        # どのコミットに入ったかを見る
git rm --cached debug.log               # 作業ツリーには残して、追跡だけ外す
echo debug.log >> .gitignore && git add .gitignore
git commit --fixup=a735f5e
git rebase --autosquash origin/main
git log --oneline -- debug.log          # 何も出なければ外れている

git commit --fixup=<コミット> は fixup! <元の件名> というコミットを作る。--autosquash を付けた rebase は、これを元のコミットの直後に並べて 1 つに畳む。新しいコミットを足して打ち消すのと違い、履歴のどこにもファイルが残らない。

外したファイルは作業ツリーからも消える

履歴から外したファイルは rebase の途中で作業ツリーからも消えるので、reflog で書き換え前のコミットを探し、git show で中身を取り出して戻す

git rm --cached で作業ツリーに残したはずの debug.log が、rebase の後で消えていた。rebase は書き換え前のコミット (ファイルあり) を取り出してから、fixup (ファイルを外す) を当てる。外す側の変更が作業ツリーにも反映されて、ファイルが消える。

要るファイルなら、書き換える前のコミットから取り出す。rebase で履歴を書き換えても、前のコミットは reflog に残っている。

git log -g --oneline                    # 書き換える前の「c を追加」を探す
git show a735f5e:debug.log > debug.log

git status --short --ignored で !! debug.log と出れば、ファイルは戻り、.gitignore で無視されている。

書き換えた履歴を push するときは –force-if-includes を足す

fixup で畳んだブランチは、既に push していれば force push しないと送れない。筆者は別のリポジトリで、PR の lint の指摘を直して畳み込んだ後、確認を取らずに force push してしまった。PR の手順では、force push の前に確認を取ると決めていた。force push は、リモートにしか無いコミットを上書きしうる操作だからだ。

–force-with-lease は fetch の後だと他人のコミットを消す

fetch した後の --force-with-lease は他人のコミットを上書きするが、--force-if-includes を足すと rejected で止まる

素の --force は、リモートに他の人が足したコミットがあっても上書きする。--force-with-lease は「最後に見たリモートの先端」と今のリモートの先端が違えば止まる。他の人が push した後に試すと、確かに (stale info) で止まった。

 ! [rejected]        feature -> feature (stale info)

ところが、その前に git fetch を打つと止まらない。fetch で「最後に見た先端」が他人のコミットに更新され、取り込んでいないのに上書きが通る。エディタやツールが裏で fetch していると、自分では気付かない。

これを止めるのが --force-if-includes だ。man git-push には次のようにある。

Force an update only if the tip of the remote-tracking ref has been integrated locally.

fetch で取っただけで、自分のブランチに取り込んでいないコミットがあれば、(remote ref updated since checkout) で止まる。force push するなら両方を付ける。

git push --force-with-lease --force-if-includes

毎回打つなら設定に入れておく。push.useForceIfIncludes を true にすると、--force-with-lease だけで --force-if-includes も効く (man git-config)。

git config --global push.useForceIfIncludes true

消したリモートブランチが復活する

リモートで消されたブランチも、手元に残った同名のブランチから git push すると new branch として作り直される

PR をマージしてリモートのブランチを消した後、別の worktree からそのブランチを push してしまい、消したはずのブランチがリモートに戻ってきた。その worktree には、同じ名前のローカルブランチが残っていた。

git push は、リモートのブランチが消された後かどうかを見ない。手元にブランチがあれば * [new branch] として作り直す。git fetch --prune で消えたことを知った後でも同じだ。

作り直してしまったら、リモートの側を消す。

git push origin --delete feature

防ぐには、push する前に git fetch --prune と git branch -vv を打ち、[origin/feature: gone] と出るブランチを push しない。マージ済みのローカルブランチは、溜めずに消しておくのが一番確実だ。まとめて消す方法は「Gitのマージ済みブランチを一括削除する方法」に書いた。

まとめ

事故戻す・防ぐ方法
worktree ごと作業が消えたgit fsck --lost-found。コミットは dangling commit、ステージしたファイルは dangling blob (名前は戻らない)
push 前のコミットを直したいgit reset --soft HEAD~N で戻してコミットし直す
入れてはいけないファイルをコミットしたgit rm --cached + git commit --fixup + git rebase --autosquash。消えたファイルは reflog から git show
書き換えた履歴を push する--force-with-lease --force-if-includes
消したブランチがリモートに戻ったgit push origin --delete。push 前に git branch -vv の gone を見る

共通するのは、Git はコミットやステージで渡されたものをオブジェクトとして残し、参照を書き換えた跡を reflog に残すことだ。消えたように見えたら、慌てて掃除や force push をする前に、git fsck --lost-found と git log -g を打つ。

上流を取り込むときの rebase の落とし穴は「Gitで上流の変更を取り込む前に確かめる3つのこと」に、main に積んでしまったコミットを git reset --keep で逃がす方法は「Gitのマージ済みブランチを一括削除する方法」に書いた。

オブジェクト・参照・reflog の関係を一度通して学んでおくと、事故のたびに検索しなくても、どこに残っているかの見当が付く。Git の仕組みから順に学ぶなら次の本がよい。

独習Git (リック・ウマリ 著、吉川邦夫 訳) 画像: 楽天市場

独習Git (リック・ウマリ 著、吉川邦夫 訳)

参考