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

本記事はプロモーションを含みます。
作業ツリーをブランチごと消してしまった。英語で書いてはいけないコミットメッセージを英語で書いた。デバッグ用のログをコミットに入れた。確認せずに force push した。消したはずのブランチがリモートに戻ってきた。
どれも筆者がこの 1 週間で踏んだ事故だ。半分は AI エージェントに作業させている途中で起きた。結論は次のとおり。
git fsck --lost-found で拾える。ステージしていないファイルは戻らないgit reset --soft で作り直す。入れてはいけないファイルは fixup コミットで元のコミットから外す--force-with-lease --force-if-includes。消したリモートブランチは、手元に残った同名のブランチから push すると作り直される以下、どれも git 2.55.0 の使い捨てのリポジトリで再現して確かめた。
記事を自動で書く仕組みでは、記事ごとに git worktree で作業ツリーを分けている。ある日、書き上げてステージまでした記事が、作業ツリーとブランチごと消えていた。
本文は Git の参照の無いオブジェクトから取り出せた。一方で、記事に入れていた挿絵 9 本は戻せなかった。何が戻り、何が戻らないかは、消える前にどこまで Git に渡していたかで決まる。
コミットまでしていれば、まず 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> で確かめてからブランチを付ける。
コミットしていなくても、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 つある。
memo.txt (??) は Git に渡していないので、どこにも残らないちなみに git worktree remove は、変更や未追跡のファイルがある作業ツリーを消さない。
fatal: '../wt' contains modified or untracked files, use --force to delete it
このエラーを --force で押し切ると、未追跡のファイルは戻せない。消す前に git -C <作業ツリー> status --short を見て、?? の行が残っていないかを確かめる。
参照の無いオブジェクトは、いつまでも残るわけではない。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 を打つ。
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 コミットで元のコミットから外す。
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 つに畳む。新しいコミットを足して打ち消すのと違い、履歴のどこにもファイルが残らない。
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 で無視されている。
fixup で畳んだブランチは、既に push していれば force push しないと送れない。筆者は別のリポジトリで、PR の lint の指摘を直して畳み込んだ後、確認を取らずに force push してしまった。PR の手順では、force push の前に確認を取ると決めていた。force push は、リモートにしか無いコミットを上書きしうる操作だからだ。
素の --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
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 (リック・ウマリ 著、吉川邦夫 訳)
--lost-found)--prune、gc.pruneExpire)remove)--soft)--autosquash)--force-with-lease、--force-if-includes)