Claude Codeのgit reflog復旧 — reset --hard事故の戻し方と防ぎ方
Claude Codeが実行したgit reset --hardやbranch -Dで消えたコミットを、git reflogから戻す手順です。戻す前の退避、戻せないもの、permissionsのdenyとhookで防ぐ書き方もまとめます。
Claude Codeに任せた作業の途中でgit reset --hardやgit branch -Dが走り、コミットがgit logから消えた。そんなときは、慌てて別の操作を重ねる前にgit reflogを開きます。消えたコミットの多くは、まだリポジトリの中に残っています。
戻す手順は短く、先にブランチを1本切って退避してから戻すのが安全です。ただし、コミットされていなかった変更はreflogに出ません。ここは戻せない側に入ります。
後半では、同じ事故をpermissions.denyとPreToolUseフックで防ぐ書き方を、permissionsとhooksの仕様に沿って示します。
事故の直後に何が起きているか
git reset --hard HEAD~2を実行すると、ブランチの先頭が2つ前に動き、作業ツリーとステージも揃えて書き換えられます。--hardは作業ツリーのファイルを指定コミットの内容で上書きし、そのコミットに無い追跡済みファイルを削除します。
動いたのはブランチの指し先だけです。消えたように見えるコミットのオブジェクト自体は、すぐには消えません。
手元のgit 2.50.1で、3コミットのリポジトリにreset --hard HEAD~2をかけて確かめた出力が次です。
git reset --hard HEAD~2
git reflogHEAD is now at 42a9378 commit 1
42a9378 HEAD@{0}: reset: moving to HEAD~2
d62d78d HEAD@{1}: commit: commit 3
36fef29 HEAD@{2}: commit: commit 2
42a9378 HEAD@{3}: commit (initial): commit 1git logにはcommit 1しか出ません。reflogには、リセット直前のd62d78d(commit 3)がHEAD@{1}として残っています。HEAD@{2}は「HEADが2回前の移動の時点で指していた場所」を表します。
reflogから戻す手順
reset --hard後の復旧手順
- 1
reflogで戻り先を探す
git reflogの出力から、事故の直前の行を探します。reset: moving toやcheckout: moving fromの1行下が、たいてい戻り先です。 - 2
退避ブランチを切る
戻り先のハッシュに、まずブランチ名を付けます。
git branch rescue d62d78dのように切れば、あとで操作を間違えても指し先を失いません。 - 3
中身を確かめる
git log --oneline rescueで、消えたコミットが並んでいるか見ます。差分が必要ならgit diff main rescueも使えます。 - 4
元のブランチを動かす
確認できたら、元のブランチに切り替えて
git reset --hard rescueで戻します。コミットを足しただけの別ブランチへ取り込むなら、git merge rescueでも足ります。
コマンドにまとめると次の形です。ハッシュは自分のreflogの値に読み替えます。
git reflog # 事故の直前の行とハッシュを探す
git branch rescue d62d78d # まず退避。ここでは何も壊れない
git log --oneline rescue # 消えたコミットが並ぶか確認
git switch main
git reset --hard rescue # 確認できたら元のブランチを戻す退避ブランチを先に切るのは、git reset --hard自体が次の事故になりうるからです。reflogの行を見つけた勢いでgit reset --hard HEAD@{1}を打つと、作業ツリーの未コミットの変更をもう一度上書きします。ブランチを切るだけなら作業ツリーは動きません。
直前の1手だけならORIG_HEADでも戻せる
git resetは、実行前のブランチ先頭をORIG_HEADに記録します。リセット直後ならgit reset --hard ORIG_HEADで1手戻せます。先ほどのリポジトリでもORIG_HEADはd62d78dを指していて、そこへ戻すと3コミットが再び並びました。
ただしORIG_HEADは、次にresetやmerge、rebaseなどが走ると上書きされます。Claudeが事故のあとに修復を試みて別のgit操作を重ねたなら、reflogを読むほうが確実です。
ブランチ削除とrebase失敗の場合
git branch -Dは--delete --forceの別名です。マージされていないコミットを持つブランチも消せます。ブランチのreflogも一緒に削除されます。つまりそのブランチ名でfeature@{1}のように探す方法は使えません。
手がかりになるのはHEADのreflogです。ブランチ上でコミットしていたなら、そのコミットはHEADの履歴に残っています。先ほどの検証でも、featureを-Dで消したあとのreflogに次の行が残りました。
d62d78d HEAD@{0}: checkout: moving from feature to main
a1a157b HEAD@{1}: commit: feature work
d62d78d HEAD@{2}: checkout: moving from main to featurea1a157bが消えたブランチの先頭です。git branch rescue a1a157bで名前を付け直せば、ブランチは元どおりに近い形で戻ります。
rebaseが途中で失敗した場合も同じで、git reflogの中からrebase (start)の行の1行下(1つ古い行)を探します。そのハッシュが、rebase開始前のブランチの先頭です。
行が多いときの探し方
日時付きで表示すると、事故の時刻で絞れます。
git reflog show --date=iso HEAD # 各行に日時が付く日時の列で、事故が起きた時刻の前後の行を探します。
復旧をClaudeに頼むときの指示
reflogを読んで退避ブランチを切るところまでは、Claudeに任せられます。ただし復旧の途中で別の破壊的な操作が混ざらないよう、頼む範囲を書き換えを伴わない操作に絞ります。
git reflog を読んで、先ほどの reset --hard で消えたコミットを探してください。
見つけたら git branch rescue/<日付> <ハッシュ> で退避ブランチを作るだけにして、
reset --hard、checkout -f、clean は実行しないでください。
作ったら git log --oneline で中身を報告してください。要点は、ブランチを作る操作で止めることです。git branchは作業ツリーに触れません。元のブランチを戻すreset --hardは、報告を読んで中身を確かめたあとに、自分で打つか改めて許可します。
次の節のdenyでgit reset --hardを止めている環境でも、この流れは詰まりません。退避ブランチを切るgit branch <名前> <ハッシュ>は、denyに入れたgit branch -Dとは別のコマンドだからです。
戻せないものと、期限
reflogで戻せるのは、一度でもコミットされた内容までです。次のものは対象外になります。
- コミットしていなかった作業ツリーの変更。上の検証でも、ステージしただけで未コミットだった
g.txtはreset --hardで消え、reflogにも出ませんでした - Bashのコマンドで変えたファイルの巻き戻し。
/rewindは、Claudeが編集ツールで触れたファイルが対象で、Bashだけが変えたファイルは追跡されません。そうしたファイルはGitなどのバージョン管理で戻します。詳しい境界はcheckpointの制限をまとめた記事にあります git cleanで消した未追跡ファイル。そもそもGitの履歴に入っていません
どれに当たるかは、事故の直後にgit statusで作業ツリーの状態を見ておくと切り分けやすくなります。
reflogの項目には期限もあります。gc.reflogExpireの既定は90日、どのブランチからも辿れなくなった項目を消すgc.reflogExpireUnreachableの既定は30日です。reset --hardで外れたコミットは後者に当たるので、30日を目安にその前に退避ブランチを切ります。期限の判定と掃除はgit gcが行い、通常は直接呼びません。
事故を防ぐdenyの書き方
戻せる場合があっても、手間と未コミットの損失は残ります。起きる前に止める設定を、.claude/settings.jsonに入れておけます。
{
"permissions": {
"deny": [
"Bash(git reset --hard *)",
"Bash(git branch -D *)",
"Bash(git clean *)"
]
}
}*は空白を含む任意の文字列に当たり、末尾に空白と*を置いたルールは、引数なしのコマンドにも一致します。そのためBash(git reset --hard *)は、引数なしのgit reset --hardも止めます。*はサブコマンドの後ろに置くのが基本です。Bash(git * --hard)のように前へ置くと、別のコマンドまで巻き込みます。
git cleanには、止めるのではなく都度確認にするaskも使えます。Bash(git clean *)をaskに入れれば、複合コマンドの中に入っていても確認が出ます。
denyとaskの使い分け
deny
reset --hardやbranch -Dのように、事故の影響が大きく、人が手元で打つほうが安全な操作向きです。
ask
git cleanのように、使う場面はあるが確認したい操作向きです。確認のたびに中身を読む運用が前提になります。
denyは、どの設定の階層でも他の許可に勝ちます。ユーザー設定のdenyはプロジェクト設定のallowを上回り、どれかの階層で拒否されたツール呼び出しは、他の階層から許可できません。個人の設定に入れておけば、プロジェクト側の設定で外されません。
denyが止められない書き方がある
ただし、Bashのdenyはコマンドの文字列に対する照合です。permissionsのページは、Bash(git push *)がgit push origin mainを止めても、git -C . push origin mainやgit -c push.default=current push origin mainは止めないと明示しています。同じ理屈で、git -C . reset --hardのような書き方は上の3行をすり抜ける可能性があります。denyは、プログラムを囲む安全境界ではなく、Claudeが普通に出す書き方を止める仕組みです。
すり抜けを減らすには、PreToolUseフックで完全なコマンド文字列を検査します。フックは標準入力のJSONからtool_input.commandを取り出し、permissionDecisionに"deny"を返せます。rm -rf向けのこの形をreset --hardに読み替えたのが次です。
#!/bin/bash
# .claude/hooks/block-git-destructive.sh
COMMAND=$(jq -r '.tool_input.command')
if echo "$COMMAND" | grep -Eq 'git .*(reset +--hard|branch +-D|clean)'; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "履歴を消す可能性があるgit操作はブロックしました"
}
}'
else
exit 0
fi判定のgrepは、gitとreset --hardの間に何が入っていても拾う雑な正規表現です。誤検知で止めすぎる場合は、パターンを調整してください。フックを設定ファイルに登録する書き方は、フックの全体像をまとめた記事で扱っています。
フックが許可を返してもdenyルールには勝てず、一致するdenyは常に呼び出しを止めます。逆にフックがexit 2で終わると、allowルールがあってもツール呼び出しはその前に止まります。denyルールとフックは、どちらか一方ではなく併用できます。
それでも消えない場合の備え
denyもフックも、Claudeが出すBashコマンドの話です。人が手で打つgit reset --hardや、IDEの操作は対象外です。守りたい履歴は、リモートへのpushや退避ブランチで二重にしておくと、reflogの期限に左右されません。
作業ツリーを分けて動かす構成では、別タスクのブランチ履歴が混ざることがあります。reflogを証拠に読んだ実例はworktreeのセッション再利用バグの検証記事にあります。ファイル単位でdenyを書く考え方は、kintoneのppkを読ませない設定の記事が同じ型の応用です。
まとめ
消えたコミットは、事故の直後ならreflogにあります。先にブランチを切って退避し、中身を確かめてから戻す順序なら、戻す操作で二重に壊すことがありません。
戻せないのは未コミットの変更です。reset --hardとbranch -Dは、denyで入口を狭め、フックで書き方の揺れを拾う二段にしておくと、事故の確率を下げられます。reflogの期限は、外れたコミットで30日が目安です。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。