「git reset --hard を打ったら、3時間分のコミットが消えました」
このとき本当に起きたことは、41バイトのファイルの中身が書き換わった、それだけです。コミットオブジェクトは1つも削除されていません。
Gitで「消えた」と感じる事故のほとんどは、削除ではなく到達不能化です。そして到達不能なオブジェクトには猶予期間があり、既定で2週間残ります。この構造を知っていれば、事故のときに焦って事態を悪化させる —— git gc を打つ、cloneし直す —— ということがなくなります。
この記事は、Gitの内部構造を前提にした復旧のオペレーション手順書です。「このコマンドを打つ」だけでなく「なぜそれで戻るのか」「戻らないのはどういうときか」まで含めて、公式ドキュメントを一次情報として整理します。実行ログはすべて git version 2.50.1 で実際に取得したものです。
Gitの内部構造そのもの(オブジェクト・参照・インデックス)を先に押さえたい方は、対になる記事を先に読んでください。ここではその知識を前提に、壊れた状態からの戻し方に集中します。
1. 大原則:Gitは「消す」より「見えなくする」ほうが得意
到達可能性(reachability)という一本の軸
Gitのオブジェクトが生きているか死んでいるかは、たった1つの基準で決まります。
どこかの参照から辿れるか。
参照とは、ブランチ(refs/heads/*)、タグ(refs/tags/*)、リモート追跡ブランチ(refs/remotes/*)、HEAD、stash(refs/stash)、そしてreflogのエントリです。ここから辿れるコミット、そのコミットが指すtree、treeが指すblob —— これらが「到達可能(reachable)」。それ以外が「到達不能(unreachable)」です。
git reset --hard、git branch -D、git commit --amend、force push。これらはすべて参照の付け替えです。オブジェクトは一切消していません。
実際に消えるのは gc のときだけ、しかも猶予つき
到達不能オブジェクトが物理削除されるのは、git gc がprune段階まで進んだときです。git-gc(1) はこう定義しています。
--prune=<date>—— date より古いloose objectをpruneする(既定は2週間前、設定変数gc.pruneExpireで上書き可能)。--prune=nowは年齢に関わらずloose objectをpruneするが、他のプロセスが同時にリポジトリへ書き込んでいる場合に破損のリスクを高める。--pruneは既定で有効。
さらに現在のGitは、到達不能オブジェクトをloose objectのまま置かず cruft pack にまとめます。
--cruft—— 到達不能オブジェクトを期限切れにするとき、loose objectとして保存する代わりに、それらを別のcruft packへまとめる。--cruftは既定で有効。
cruft packは、到達不能オブジェクトとその「いつから到達不能か」のタイムスタンプを保持する仕組みです。従来のloose object方式ではファイルの mtime が猶予判定に使われ、repackのたびにmtimeが更新されていつまでも消えないという問題がありましたが、cruft packはこれを解消しています。復旧の観点では「到達不能になったものは、packの中にまとまって2週間残っている」と理解しておけば十分です。
事故直後にやってはいけない3つ
| やってはいけないこと | 何が失われるか |
|---|---|
git gc / git gc --prune=now | 到達不能オブジェクトそのもの。復旧不能になる |
リポジトリを消して git clone し直す | reflog・到達不能オブジェクト・stash・ローカルブランチ。ローカルにしかない情報は全部 |
| 作業ツリーで操作を続ける | 上書きされていない未コミット変更 |
最初にやるべきことは1つです。
# 調査の前に、.git ごとバックアップを取る(数秒で終わる)
$ cp -a .git /tmp/git-backup-$(date +%s)
復旧作業はほぼすべて読み取りで完了します。バックアップさえあれば、途中で判断を誤ってもやり直せます。
2. 道具1:reflog —— 参照が動いた履歴
何が記録されるか
reflogは「参照がいつ、どの値から、どの値へ動いたか」の追記ログです。.git/logs/refs/heads/main のようなテキストファイルとして、参照ごとに存在します。
$ git reflog
b673550 HEAD@{0}: commit (amend): oops overwritten
c3d491e HEAD@{1}: commit: second
f9dc22c HEAD@{2}: commit (initial): important work
HEAD@{1} の c3d491e が、amendで潰される前のコミットです。潰されていません。ログに残り、オブジェクトも残っています。
git reflog show は git log -g --abbrev-commit --pretty=oneline のエイリアスなので、git log のオプションがそのまま使えます。時刻付きで見るのが実務的です。
$ git reflog --date=iso
faf9142 HEAD@{2026-08-16 14:30:28 +0900}: reset: moving to HEAD~2
6aa734d HEAD@{2026-08-16 14:30:28 +0900}: commit: commit 3
49c9b67 HEAD@{2026-08-16 14:30:28 +0900}: commit: commit 2
faf9142 HEAD@{2026-08-16 14:30:28 +0900}: commit (initial): commit 1
「reset: moving to HEAD~2」という操作の理由まで記録されているのが reflog の強みです。何が起きたかをログから再構成できます。
HEAD@{n} と main@{n} は別物
これは混同しやすい点です。
HEAD@{n}—— HEADの移動履歴。ブランチの切り替えも含むmain@{n}—— mainブランチの移動履歴。他のブランチにいた間の出来事は含まれない
「ブランチを切り替えたあと元のブランチで何かが消えた」ケースでは、git reflog show main のほうが早く見つかります。
そして main@{one.week.ago} のような時刻指定も使えます。「先週の金曜の状態」に戻りたいときの最短経路です。
$ git diff main@{2.days.ago} main # 2日前からの差分
$ git switch -c rescue main@{yesterday}
reflogの寿命 —— ここが落とし穴
reflogは永遠には残りません。git-reflog(1) の記載は明確です。
--expire=<time>—— […] 既定は90日。--expire-unreachable=<time>—— […] 現在の先端から到達できないエントリについて […] 既定は30日。
2つの期限がある点が重要です。
| 種類 | 既定 | 設定 |
|---|---|---|
| 到達可能な履歴のエントリ | 90日 | gc.reflogExpire |
| 到達不能なエントリ | 30日 | gc.reflogExpireUnreachable |
事故で捨てたコミットは「到達不能」側なので、猶予は30日です。90日ではありません。
期限を延ばしたければ、設定は1行です。個人の開発マシンなら十分に妥当な判断です。
# 到達不能なreflogエントリも1年残す(ディスクコストはほぼ無視できる)
$ git config --global gc.reflogExpireUnreachable "1.year"
最大の落とし穴:bareリポジトリにreflogはない
core.logAllRefUpdates の既定値について、公式はこう書いています。
この値は、作業ディレクトリを持つリポジトリでは既定で true、bareリポジトリでは既定で false である。
つまり、
- 手元の開発リポジトリ → reflogあり ✅
- 素のGitサーバ、ミラー、
git clone --bareしたもの → reflogなし ❌ - CIのワークスペース → 作業ツリーがあるので有効だが、ジョブ終了で消える ⚠️
「サーバ側でforce pushされて履歴が消えた」ケースは、素のGitでは救えません。 GitHubなどのホスティングは独自にイベントを記録していることがあり、Events APIやサポート経由で失われたコミットのSHAが特定できる場合があります。ただしこれはGitの機能ではなくベンダーの機能なので、当てにした設計にしてはいけません。
サーバ側の備えは、reflogではなくポリシーとバックアップです(→ セクション8)。
3. 道具2:fsck —— reflogに載らないものを拾う
reflogは「参照が動いた」記録です。裏を返すと、参照を経由しなかったものは載りません。
git addはしたがコミットしていない内容git stash dropしたstash- 中断したマージやrebaseの中間状態
- 配管コマンドで作ったが参照を付けなかったオブジェクト
これらを見つける道具が git fsck です。
$ git fsck --unreachable
unreachable blob dbbfe51483ff05f84e7187a835d023be8e496473
$ git cat-file -p dbbfe51483ff05f84e7187a835d023be8e496473
precious uncommitted content
上の実行例は、git add した直後に git reset --hard したという状況を再現したものです。ファイルは作業ツリーから消えていますが、git add の時点でblobがオブジェクトDBに書かれているため、内容はまだそこにあります。reflogには一切出てきません。
--unreachable / --dangling / --lost-found
| オプション | 意味 |
|---|---|
--unreachable | どの参照からも到達できないオブジェクトを列挙 |
--dangling(既定で有効) | 「存在するが直接使われていない」オブジェクトを表示。--no-dangling で抑制 |
--lost-found | dangling なオブジェクトを .git/lost-found/commit/ と .git/lost-found/other/ に書き出す。blobは中身をファイルとして書き出す |
--no-reflogs | reflogを参照点として扱わない。「reflogにも載っていないもの」だけを見たいときに使う |
--connectivity-only | blobの中身を読まず接続性だけ検査。巨大リポジトリで高速 |
--lost-found は実務で強力です。
$ git fsck --lost-found
dangling commit 6aa734d3e7b4fa54de72f05f9fad0eb6f12949b9
$ ls .git/lost-found/commit/
6aa734d3e7b4fa54de72f05f9fad0eb6f12949b9
--no-reflogs は逆に、reflogがまだ生きているうちは何も出ないことに注意してください。「fsckで何も出ないから諦める」のではなく、先に git reflog を見るのが正しい順序です。
候補が大量に出たときの絞り込み
古いリポジトリでは到達不能オブジェクトが数千件出ることがあります。日付とメッセージで絞ります。
# 到達不能なコミットだけを、日付・メッセージ付きで新しい順に
$ git fsck --unreachable --no-progress 2>/dev/null \
| awk '$2 == "commit" { print $3 }' \
| git log --stdin --no-walk --date=iso \
--pretty='%h %ad %an %s' \
| sort -k2 -r \
| head -20
git log --stdin --no-walk は「標準入力で渡したコミットだけを、履歴を辿らずに1行ずつ表示する」という組み合わせです。数千件の候補から目的のものを見つけるのに、これ以上速い方法はありません。
4. ケース別・復旧レシピ
各ケースで「何が起きたか(どの参照が動いたか)」を先に示します。仕組みが分かればコマンドは導出できます。
A. git reset --hard でコミット済みの履歴を飛ばした
起きたこと:ブランチの参照が過去の値に書き換わった。コミットオブジェクトは無傷。
$ git reflog # 直前の値を探す
6aa734d HEAD@{1}: commit: commit 3 ← これが飛ばした先端
$ git branch rescue 6aa734d # まず参照を付けて安全確保
$ git log --oneline rescue # 中身を確認
6aa734d commit 3
49c9b67 commit 2
faf9142 commit 1
$ git switch main && git reset --hard rescue # 納得できたら戻す
ポイント:いきなり reset --hard で戻さず、先に git branch で参照を付ける。参照が付いた瞬間に到達可能になり、gcの対象から外れます。これが最も安全な最初の一手です。
B. git commit --amend で前のコミットを潰した
起きたこと:HEAD が新しいコミットを指すよう更新された。元のコミットは到達不能。
$ git reflog
b673550 HEAD@{0}: commit (amend): oops overwritten
c3d491e HEAD@{1}: commit: second ← amend前のコミット
$ git switch -c before-amend c3d491e # 元の内容をブランチとして取り出す
両方のコミットを見比べたいなら差分が取れます。
$ git diff c3d491e b673550
C. ブランチを git branch -D で消した
起きたこと:refs/heads/<name> というファイルが消えた。それだけ。
# 削除時の値はHEAD reflogに残る
$ git reflog --date=iso | grep -i "checkout\|feature"
# もしくは削除メッセージから拾う(Gitは削除時にSHAを表示する)
$ git branch feature-restored <sha>
削除時に表示される Deleted branch feature (was 6aa734d). の SHA が最短経路です。ターミナルのスクロールバックを先に見てください。
D. rebase が壊れた/途中で迷子になった
起きたこと:rebaseは元の各コミットを新しいコミットとして作り直す。中断状態では HEAD が detached になっている。
# 進行中なら、まず中止(rebase開始前の状態に完全に戻る)
$ git rebase --abort
# すでに完了してしまった後なら、開始前の位置を使う
$ git reset --hard ORIG_HEAD
ORIG_HEAD は、reset / merge / rebase / pull といった「HEADを大きく動かす操作」の直前の値を記録する参照です。直前の1回分だけなので、次の危険な操作をする前に使ってください。
$ git rev-parse ORIG_HEAD
b6735506b23643a633056af01af9afa194ce358e
E. 未コミットの編集を git restore / git checkout . で消した
ここは運命が分かれます。
| 状態 | 復旧可否 | 方法 |
|---|---|---|
git add 済み(ステージされていた) | ✅ 可能 | blobがオブジェクトDBにある。git fsck --unreachable で拾う |
| 一度もaddしていない | ❌ 不可能 | Gitはそのバイト列を一度も見ていない |
# add済みだった内容を探す
$ git fsck --unreachable | grep blob
unreachable blob dbbfe51483ff05f84e7187a835d023be8e496473
$ git cat-file -p dbbfe514 > recovered.txt
教訓が明確です:作業が惜しくなったら、とりあえず git add する。 コミットしなくても、addした瞬間に内容はオブジェクトDBに入り、復旧の射程内に入ります。これは「こまめにコミットする」より習慣にしやすく、効果はほぼ同じです。
エディタのローカル履歴(VS Codeの Timeline、JetBrainsの Local History)はGitの外側にある別の保険です。addしていなかった場合、実質そこが唯一の望みになります。
F. git stash drop してしまった
起きたこと:refs/stash のエントリが1つ消えた。stashコミット自体は残っている。
stashの実体はコミットです。しかもマージコミットです。
$ git log --oneline --graph refs/stash
* 719d02e WIP on main: faf9142 commit 1 ← 作業ツリーの状態
|\
| * 766195b index on main: faf9142 commit 1 ← インデックスの状態
|/
* faf9142 commit 1 ← 元のHEAD
第1親が元のHEAD、第2親がインデックスの状態、そしてstashコミット自身が作業ツリーの状態。3つの木がそのまま3つのコミットに写し取られています。
drop後の復旧は、オブジェクト名さえ分かれば1コマンドです。
$ git fsck --unreachable | grep commit
unreachable commit d0a3490836dcafc8846416cd1bbbae32f83a4421
$ git stash apply d0a3490836dcafc8846416cd1bbbae32f83a4421
On branch main
Changes not staged for commit:
...
git stash apply はstashスタックのエントリでなくても、生のコミットSHAを受け付けます。これを知らずに諦めるケースが非常に多い。
G. force pushでリモートの履歴を消した
起きたこと:リモートの参照が書き換わった。そして前述のとおり、bareリポジトリにreflogはない。
順に試します。
# 1. 自分または誰かのローカルに、古い値が残っていないか
$ git reflog show refs/remotes/origin/main
# 2. 手元に古いコミットが残っていれば、それを押し戻す
$ git push --force-with-lease=main:$(git rev-parse refs/remotes/origin/main) \
origin <old-sha>:main
手元にも残っていない場合、Gitの機能では戻せません。ホスティング側の記録(GitHubのEvents API、PRのコミット一覧、CIのログに残るSHA)を当たることになります。CIのビルドログにチェックアウトしたSHAが出力されていることは多く、実務では有力な手掛かりです。
そして根本対策は復旧手順ではなく、サーバ側でforce pushを禁止することです(→ セクション8)。
H. コンフリクトの解決をやり直したい
# マージ全体をやり直す
$ git merge --abort
# 特定ファイルだけ、コンフリクト状態に戻す(3世代が再びstageに載る)
$ git checkout --merge -- path/to/file
# rerereが誤った解決を記憶してしまった場合、その記憶を消す
$ git rerere forget path/to/file
git checkout --merge(-m)は、解決済みのファイルをコンフリクトマーカー付きの状態に戻します。--conflict=diff3 を付けると共通祖先も一緒に表示され、「相手が何を変えたのか」が判断しやすくなります。
5. 「本当に消したい」とき —— 秘密情報の混入
復旧の裏返しとして、消えないことが問題になるケースがあります。APIキーやパスワードをコミットしてしまった場合です。
まず理解すべきこと:後続コミットでの削除は無意味
「次のコミットで消したから大丈夫」は誤りです。オブジェクトDBには元のblobが残り、git log -p でも git show <old-sha> でも読めます。
正しい順序
1. ただちに鍵をローテーション(無効化)する。
これが唯一の確実な対策です。履歴の書き換えは、以下すべてに届きません。
- すでにcloneした全員のローカルリポジトリ
- ホスティング上のフォーク
- ホスティングのキャッシュ(GitHubは書き換え後も一定期間、直リンクでコミットを返しうる)
- CI・ミラー・バックアップ
- クローラや検索インデックス
漏れた鍵は「漏れた」のであって、「たぶん見られていない鍵」に戻ることはありません。
2. そのうえで履歴から除去する。
公式の git-filter-branch(1) は、自分自身の使用を推奨していません。
git filter-branchには、意図した履歴書き換えに対して非自明な破壊を生む数多くの落とし穴がある(しかも性能が絶望的に悪いため、そうした問題を調査する時間もほとんど残らない)。これらの安全性と性能の問題は後方互換を保ったままでは修正できず、そのためその使用は推奨されない。git-filter-repoのような代替の履歴フィルタリングツールを使ってほしい。
したがって使うのは git filter-repo(別途インストールが必要な公式推奨ツール)です。
$ pip install git-filter-repo
# 特定のパスを全履歴から除去
$ git filter-repo --invert-paths --path config/secrets.yml
# 文字列を全履歴で置換(値そのものを消す)
$ git filter-repo --replace-text <(echo 'AKIAEXAMPLE0000000000==>REDACTED')
同ドキュメントの警告も併せて読むべきです。
警告! 書き換えられた履歴はすべてのオブジェクトについて異なるオブジェクト名を持ち、元のブランチと収束することはない。書き換えたブランチを元のブランチの上に簡単にpushしたり配布したりすることはできない。
チーム全員に再cloneを依頼する必要がある、破壊的な操作です。だからこそ**順序が「ローテーション → 掃除」**であって、逆ではありません。
予防:受け入れ側で止める
# 壊れた/怪しいオブジェクトの送受信を検出する
$ git config --global transfer.fsckObjects true
$ git config --global fetch.fsckObjects true
$ git config --global receive.fsckObjects true
そのうえで、秘密情報の検出はpre-commitフックやCIのシークレットスキャンでコミット前に止めるのが本筋です。事後の履歴書き換えは、常に高くつきます。
6. 復旧の判断フロー
事故が起きたときに上から順に読むための表です。
| 症状 | 最初に見る場所 | 主なコマンド |
|---|---|---|
| コミットが消えた(reset / amend / rebase) | git reflog | git branch rescue <sha> |
| ブランチを消した | git reflog/削除時の出力 | git branch <name> <sha> |
| rebase・mergeの直前に戻りたい | ORIG_HEAD | git reset --hard ORIG_HEAD |
| 進行中のrebase・mergeを取り消したい | — | git rebase --abort / git merge --abort |
| add済みの未コミット変更が消えた | git fsck --unreachable | git cat-file -p <blob> |
| addしていない編集が消えた | エディタのローカル履歴 | (Gitでは不可) |
| stashを消した | git fsck --unreachable | git stash apply <sha> |
| リモートの履歴がforce pushで消えた | ローカルの refs/remotes/* reflog | git push --force-with-lease で押し戻す |
| リポジトリが壊れた(オブジェクト破損) | git fsck --full | 別クローンから該当オブジェクトを補完 |
7. 復旧できる状態を維持する設定
事故は起きます。問題は「起きたときに戻せるか」です。
# 到達不能なreflogエントリの保持を30日→1年に(個人マシンなら妥当)
git config --global gc.reflogExpireUnreachable "1.year"
# 到達可能な履歴のreflogも延長
git config --global gc.reflogExpire "1.year"
# 到達不能オブジェクトのprune猶予を2週間→90日に
git config --global gc.pruneExpire "90.days.ago"
# 壊れたオブジェクトを受け取らない・送らない
git config --global transfer.fsckObjects true
git config --global fetch.fsckObjects true
git config --global receive.fsckObjects true
これらのコストはディスク容量だけで、しかもcruft packにまとまるためオーバーヘッドは小さい。復旧可能期間を延ばす投資として、費用対効果が極めて高い設定群です。
git maintenance を使っている場合も、上の設定は有効です。incremental 戦略では gc タスクが既定で無効なので、そもそも全体prune自体が日常的には走りません。
8. サーバ側:復旧に頼らない設計
ここまでの手段はすべてローカルに情報が残っていることが前提でした。組織として守るなら、サーバ側で事故そのものを起こさせないほうが確実です。
ポリシーで止める
| 対策 | 効果 |
|---|---|
| 保護ブランチでforce pushを禁止 | セクション4-Gの事故が発生しなくなる |
| ブランチ削除の禁止 | 誤削除の防止 |
| 直接pushの禁止(PR必須) | レビューされない履歴改変を排除 |
| 署名コミットの要求 | 誰が書いたかの証明 |
クライアント設定(--force-with-lease を使う等)は規律に依存するため、組織の防御としては弱いです。サーバ側で拒否させてください。
ミラーバックアップ
# 全参照を含む完全なミラーを作る
$ git clone --mirror https://github.com/org/repo.git repo.git
# 定期更新(削除された参照も同期する。バックアップ用途では慎重に)
$ cd repo.git && git remote update --prune
--prune を付けるとリモートで消された参照がバックアップ側でも消えるので、「誤削除からの復旧」目的なら付けないという判断もあります。バックアップの目的(障害復旧か、誤操作復旧か)で挙動を選んでください。
git bundle:単一ファイルのオフラインバックアップ
# 全参照を1ファイルに固める
$ git bundle create backup-$(date +%Y%m%d).bundle --all
# 検証(壊れていないか、前提コミットが揃っているか)
$ git bundle verify backup-20260816.bundle
# 復元(bundleは通常のリモートとして扱える)
$ git clone backup-20260816.bundle restored-repo
bundleはネットワークもサーバも要らない単一ファイルなので、オブジェクトストレージに置くだけでオフサイトバックアップになります。git bundle verify で整合性を検証できる点も、tarでリポジトリを固めるより優れています。
9. まとめ
Gitの復旧は、記憶したコマンドの多さではなく1つのモデルの理解で決まります。
- 参照を消してもオブジェクトは残る。 既定で2週間(
gc.pruneExpire)、cruft packの中に。 - reflogは参照が動いた履歴。 到達不能エントリの寿命は30日で、bareリポジトリには存在しない。
- fsckはreflogに載らないものを拾う。
git add済みの内容、dropしたstash、中断した操作。 - 復旧の第一手は
git branch rescue <sha>。 参照を付ければ到達可能になり、gcの対象から外れる。 - 消えないことが問題になるとき(秘密情報)は、履歴の掃除より先に鍵のローテーション。
そして最も大事なのは、事故直後に何もしないことです。cp -a .git /tmp/backup を打ってから調査を始めれば、ほぼすべてのケースで取り戻せます。
Gitがなぜこう振る舞うのか —— オブジェクト・参照・インデックスという3つのデータ構造がどう組み合わさっているのか —— は、対になる内部構造の記事で扱っています。復旧手順を「暗記」から「導出」に変えたい方は、そちらも併せてどうぞ。