「うわ...間違えてreset --hardしちゃった…!」「さっきまであったはずのコミットはどこへ?」
Gitの時空を旅するあなたなら,誰しも一度はそんな冷や汗をかく瞬間を経験したことがあるでしょう。しかし、ご安心を.あなたの「やらかし」を救ってくれる魔法の呪文,それがgit reflogです!
この記事では,Gitのコミット履歴を参照するgit logと,操作離席を遡るgit reflogの決定的な違いを解き明かします.HEAD~1とHEAD@{1}という二つの座標を頼りに,安全に過去へ戻れるようになりましょう!
gitに関しては以下のチートシートで...
git log vs git reflog:つの違い
1. 記録対象:コミット履歴 vs ポインタの移動履歴
-
git log:
コミットの履歴を記録する. 新しいコミットが作成されるたびに, そのコミットオブジェクトへの参照が記録されていく. これはプロジェクトの公式な変更履歴である. -
git reflog:
HEADやブランチなど参照 (ref) が指す先のコミットハッシュが変更された操作の履歴を記録する.commit,reset,checkout,mergeなど, ポインタが移動するほぼすべての操作が対象となる. これは開発者個人のローカルな操作履歴である.
2. 共有範囲:パブリック vs プライベート
-
git log:
git pushによってリモートリポジトリに送信され, 他の開発者と共有されるパブリックな履歴である. -
git reflog:
各開発者のローカルリポジトリにのみ存在するプライベートな記録であり,git pushで共有されることはない. 他の開発者があなたのreflogを見ることはできない.
3. 永続性:半永続的 vs 一時的
-
git log:
git rebaseやgit reset --hardなどで意図的にコミットを履歴から消さない限り, コミット履歴は半永続的に残る. -
git reflog:
安全装置としての役割を持つため, 履歴は永遠には保存されない. 設定された期間(デフォルトは90日)が経過すると,古いものから自動的に削除される一時的な記録である.
4. 主な用途:変更履歴の確認 vs 操作の復旧
-
git log:
プロジェクトがどのように変更されてきたか, 誰がいつどのような変更を加えたかといった開発の文脈を理解するために使用する. -
git reflog:
誤ってブランチを削除したり,resetでコミットを消してしまったりした場合に, 失われたコミットを探し出して状態を復旧するために使用する. まさに「最後の頼みの綱」である.
5. 表示内容の違い
git logはコミットそのものに焦点を当てる.
$ git log --oneline
a1b2c3d (HEAD -> main) feat: 新機能を追加
f4g5h6i chore: 不要なファイルを削除
c7d8e9f fix: バグを修正
一方, git reflogはどのような操作でHEADが移動したかを表示する.
$ git reflog
a1b2c3d HEAD@{0}: commit: feat: 新機能を追加
f4g5h6i HEAD@{1}: reset: moving to HEAD~1
c7d8e9f HEAD@{2}: commit: feat: 新機能を追加(これは後でresetされた)
...
6. 参照方法:~ vs @{}
-
git log:
コミットを遡るには,HEAD~<n>のようにチルダ (~) を使用する. これはコミットの親子関係をn世代遡ることを意味する. -
git reflog:
操作履歴を遡るには,HEAD@{<n>}のようにアットマークと波括弧 (@{}) を使用する. これはreflogにおけるn番前の状態を指す.
7. ブランチ削除時の挙動
-
git log:
マージされていないブランチを削除すると, そのブランチからのみ到達可能だったコミットはgit logの出力には現れなくなり, 見失ったように見える. -
git reflog:
ブランチが削除されても, そのブランチが指していたコミットへの参照はreflog内に残っているため,reflogを辿ることで失われたコミットを救出できる可能性がある.
resetの仕組み:HEAD~1 と HEAD@{1}
git resetコマンドでHEAD~1とHEAD@{1}の両方を使用できるが, これらは参照する対象が異なるため, 結果も変わってくる.
HEADとは何か
HEADは, 現在作業中のブランチの先頭を指す特別なポインタである. つまり, 「今いる場所」を示している.
コミットハッシュの生成とHEADの移動
Gitでは, 新しいコミットが作成されるたびに以下の手順でコミットハッシュが生成され, HEADが移動する.
1. コミットオブジェクトの作成
git commitを実行すると, Gitは以下の情報を組み合わせてコミットオブジェクトを作成する:
- ファイルの内容(tree オブジェクト)
- 親コミットのハッシュ
- 作成者情報(名前、メールアドレス、タイムスタンプ)
- コミッター情報
- コミットメッセージ
2. SHA-1ハッシュの計算
これらの情報からSHA-1ハッシュ値(40文字の16進数)が計算され, これがコミットハッシュとなる.
3. HEADとブランチポインタの更新
新しいコミットが作成されると, HEADが指すブランチのポインタが新しいコミットハッシュに更新される.
この一連の流れを具体例で見てみよう.
# 初期状態
$ git log --oneline
a1b2c3d (HEAD -> main) Initial commit
# ファイルを編集してステージング
$ echo "Hello World" > hello.txt
$ git add hello.txt
# コミットを作成
$ git commit -m "Add hello.txt"
[main f4e5d6c] Add hello.txt
1 file changed, 1 insertion(+)
create mode 100644 hello.txt
# HEADが新しいコミットを指すようになった
$ git log --oneline
f4e5d6c (HEAD -> main) Add hello.txt
a1b2c3d Initial commit
# さらにもう一つコミットを作成
$ echo "Updated content" >> hello.txt
$ git add hello.txt
$ git commit -m "Update hello.txt"
[main 7g8h9i0] Update hello.txt
1 file changed, 1 insertion(+)
$ git log --oneline
7g8h9i0 (HEAD -> main) Update hello.txt
f4e5d6c Add hello.txt
a1b2c3d Initial commit
このHEADの移動を視覚化してみよう.
1. 初期状態(1つのコミットのみ)
2. 2つ目のコミット作成後(HEADがf4e5d6cに移動)
3. 3つ目のコミット作成後(HEADが7g8h9i0に移動)
HEADの詳細な動作
HEADは実際には.git/HEADファイルに格納されており, 通常は現在のブランチへの参照を含んでいる.
# HEADの内容を確認
$ cat .git/HEAD
ref: refs/heads/main
# mainブランチが指すコミットハッシュを確認
$ cat .git/refs/heads/main
7g8h9i0a1b2c3d4e5f6789012345678901234567
# HEADが間接的に指すコミットハッシュを確認
$ git rev-parse HEAD
7g8h9i0a1b2c3d4e5f6789012345678901234567
このように, HEADは「現在のブランチ」を指し, そのブランチが「最新のコミットハッシュ」を指すという二段階の参照構造になっている. 新しいコミットが作成されるたびに, ブランチポインタが更新され, 結果としてHEADが指す先も新しいコミットに移動する.
detached HEAD状態
特定のコミットハッシュに直接チェックアウトすると, HEADがブランチではなく直接コミットを指す「detached HEAD」状態になる.
# 特定のコミットにチェックアウト
$ git checkout f4e5d6c
Note: switching to 'f4e5d6c'.
You are in 'detached HEAD' state...
$ cat .git/HEAD
f4e5d6ca1b2c3d4e5f6789012345678901234567
# この状態でコミットすると、ブランチから切り離された状態になる
detached HEAD状態の図解
この状態では, HEADがf4e5d6cを直接指しており, mainブランチは7g8h9i0を指したままになっている.
HEAD~1:親コミットを辿る
~1は現在のコミットの1つ親のコミットを指す. これはコミットが持つ親子関係の情報を辿る.
したがって, git reset --hard HEAD~1を実行すると, HEADポインタ(およびカレントブランチのポインタ)を, 現在のコミットの親コミットに移動させる.
# 状況: C3 (HEAD) -> C2 -> C1
# コミットログ
$ git log --oneline
C3 (HEAD -> main) Commit 3
C2 Commit 2
C1 Commit 1
# resetを実行
$ git reset --hard HEAD~1
# 結果: HEADはC2を指すようになる
$ git log --oneline
C2 (HEAD -> main) Commit 2
C1 Commit 1
この操作を視覚的に見てみよう. まず, reset前の状態は以下の通りである.
git reset --hard HEAD~1を実行すると, HEADとmainブランチがC3の親であるC2へ移動し, C3は履歴から外れる.
図のようにコミットC3はブランチの履歴から外れる.
HEAD@{1}:操作履歴を辿る(間違えてreset --hardしてしまった場合)
@{1}は reflogにおける1つ前のHEADの状態を指す. これは時間的な操作履歴を辿る.
例えば, 間違えてresetしてしまった状況を考えてみよう.
# 状況: C2 (HEAD -> main) <- 先ほどのreset直後
# reflogを確認
$ git reflog
C2 HEAD@{0}: reset: moving to HEAD~1
C3 HEAD@{1}: commit: Commit 3
C2 HEAD@{2}: commit: Commit 2
...
# 直前の操作(`reset`)を取り消したい
# `reset`する前のHEADはC3を指していた (reflogのHEAD@{1})
$ git reset --hard HEAD@{1}
# 結果: HEADは再びC3を指すようになり, resetが取り消された
$ git log --oneline
C3 (HEAD -> main) Commit 3
C2 Commit 2
C1 Commit 1
この例では, reflogが記録していた「resetする前の状態 (HEAD@{1})」, すなわちコミットC3に戻している. この復元の様子を視覚化する.
これがresetでC3を見失った状態である.
ここでgit reset --hard HEAD@{1}を実行すると, reflogの情報をもとにHEADとmainブランチがC3へ戻る.
このように, ~はコミットの親子関係という空間的な関係を遡り, @{}はreflogに記録された時間的な操作を遡る, という明確な違いがあるのだ.
HEAD@{n}の移動を見てみよう
reflogは各操作でHEADがどのコミットを指していたかを時系列で記録している. HEAD@{n}のnは「n回前の操作時点でのHEADの位置」を意味する.
具体的な例でHEAD@{n}の移動を追跡してみよう.
# 初期状態: 3つのコミットがある
$ git log --oneline
C3 (HEAD -> main) Third commit
C2 Second commit
C1 Initial commit
# 現在のreflog状態
$ git reflog
C3 HEAD@{0}: commit: Third commit
C2 HEAD@{1}: commit: Second commit
C1 HEAD@{2}: commit: Initial commit
この状態から様々な操作を行い, HEAD@{n}がどのように変化するかを見てみよう.
操作1: reset --hard HEAD~1を実行
$ git reset --hard HEAD~1
$ git reflog
C2 HEAD@{0}: reset: moving to HEAD~1
C3 HEAD@{1}: commit: Third commit
C2 HEAD@{2}: commit: Second commit
C1 HEAD@{3}: commit: Initial commit
操作2: 新しいコミットを作成
$ echo "new content" > new.txt
$ git add new.txt
$ git commit -m "New commit"
$ git reflog
D1 HEAD@{0}: commit: New commit
C2 HEAD@{1}: reset: moving to HEAD~1
C3 HEAD@{2}: commit: Third commit
C2 HEAD@{3}: commit: Second commit
C1 HEAD@{4}: commit: Initial commit
この一連の操作におけるHEAD@{n}の変化を視覚化してみよう.
HEAD@{4} (最初の状態)
HEAD@{3} (2つ目のコミット後)
HEAD@{2} (3つ目のコミット後)
HEAD@{1} (reset後、C3が見失われた状態)
HEAD@{0} (新しいコミットD1を作成後)
HEAD@{n}を使った柔軟な復旧
この履歴を使って、任意の時点に戻ることができる.
# HEAD@{3}の時点(C3コミット直後)に戻る
$ git reset --hard HEAD@{3}
$ git log --oneline
C3 (HEAD -> experiment) Third commit
C2 Second commit
C1 Initial commit
# HEAD@{1}の時点(D1コミット直後)に戻る
$ git reset --hard HEAD@{1}
$ git log --oneline
D1 (HEAD -> experiment) New commit
C2 Second commit
C1 Initial commit
# HEAD@{2}の時点(reset直後)に戻る
$ git reset --hard HEAD@{2}
$ git log --oneline
C2 (HEAD -> experiment) Second commit
C1 Initial commit
このように, HEAD@{n}を使うことで, 操作履歴の任意の時点にピンポイントで戻ることができる. これはHEAD~n(親コミットを辿る)とは全く異なる概念で, 時間軸に沿った操作の巻き戻しが可能になる.
なぜHEADの操作履歴を辿れるのか:reflogの仕組み
reflogがHEADの操作履歴を辿れる理由は, Gitが内部的にすべてのHEADの変更を専用のログファイルに記録しているからである. この仕組みを詳しく見てみよう.
reflogファイルの構造
Gitリポジトリの.git/logs/ディレクトリには, 各参照(ref)の変更履歴が記録されている.
# .git/logs/ディレクトリの構造を確認
$ find .git/logs -type f
.git/logs/HEAD
.git/logs/refs/heads/main
.git/logs/refs/heads/feature
HEADの変更履歴は.git/logs/HEADファイルに記録される.
# HEADの変更履歴を直接確認
$ cat .git/logs/HEAD
0000000000000000000000000000000000000000 a1b2c3d4e5f6789012345678901234567890abcd User Name <user@example.com> 1640995200 +0900 commit (initial): Initial commit
a1b2c3d4e5f6789012345678901234567890abcd f4e5d6c7a8b9012345678901234567890123cdef User Name <user@example.com> 1640995260 +0900 commit: Add hello.txt
f4e5d6c7a8b9012345678901234567890123cdef 7g8h9i0j1k2l345678901234567890123456789ef User Name <user@example.com> 1640995320 +0900 commit: Update hello.txt
7g8h9i0j1k2l345678901234567890123456789ef f4e5d6c7a8b9012345678901234567890123cdef User Name <user@example.com> 1640995380 +0900 reset: moving to HEAD~1
各行の構造は以下の通りである:
[変更前のハッシュ] [変更後のハッシュ] [作成者情報] [タイムスタンプ] [操作の説明]
reflogの記録メカニズム
HEADが変更されるたびに, Gitは以下の手順でreflogを更新する:
1. 操作の検出
2. エントリの作成と記録
各操作でreflogエントリが作成される様子を視覚化してみよう.
初期状態: 最初のコミット
2つ目のコミット後
reset操作後
reflogによる復旧の仕組み
git reset --hard HEAD@{1}を実行すると, Gitは以下の手順で復旧を行う:
1. reflogエントリの検索
2. 復旧後の状態
reflogの自動管理
reflogは以下の特徴を持つ:
1. 自動的な記録
- HEADが変更されるすべての操作を自動記録
- ユーザーが意識する必要がない
2. 期限付きの保存
# reflogの保存期間設定を確認
$ git config --get gc.reflogExpire
90.days
$ git config --get gc.reflogExpireUnreachable
30.days
3. ガベージコレクション
このように, reflogはGitの内部メカニズムとして自動的に動作し, 開発者の操作履歴を安全に保護している. これにより, 誤った操作からの復旧が可能になっているのである.
reflogに記録されるハッシュ値の決定方法
reflogに記録されるハッシュ値は, その操作時点でHEADが指していたコミットのハッシュ値である. これは操作の種類に関係なく, 常にHEADの移動先のコミットハッシュが記録される.
ハッシュ値の記録パターン
1. コミット操作の場合
新しいコミットが作成されると, そのコミットのハッシュ値がreflogに記録される.
# 新しいコミットを作成
$ echo "Hello World" > hello.txt
$ git add hello.txt
$ git commit -m "Add hello.txt"
[main f4e5d6c] Add hello.txt
# reflogを確認
$ git reflog
f4e5d6c HEAD@{0}: commit: Add hello.txt
a1b2c3d HEAD@{1}: commit (initial): Initial commit
# コミットハッシュを直接確認
$ git rev-parse HEAD
f4e5d6ca1b2c3d4e5f6789012345678901234567
この場合, f4e5d6cは新しく作成されたコミットのハッシュ値である.
2. reset操作の場合
reset操作では, 移動先のコミットハッシュが記録される.
# reset前の状態
$ git log --oneline
f4e5d6c (HEAD -> main) Add hello.txt
a1b2c3d Initial commit
# resetを実行
$ git reset --hard HEAD~1
# reflogを確認
$ git reflog
a1b2c3d HEAD@{0}: reset: moving to HEAD~1
f4e5d6c HEAD@{1}: commit: Add hello.txt
a1b2c3d HEAD@{2}: commit (initial): Initial commit
この場合, a1b2c3dはreset後にHEADが指すようになったコミットのハッシュ値である.
3. checkout操作の場合
ブランチ切り替えでは, 切り替え先のコミットハッシュが記録される.
# 新しいブランチを作成して移動
$ git checkout -b feature f4e5d6c
# reflogを確認
$ git reflog
f4e5d6c HEAD@{0}: checkout: moving from main to feature
a1b2c3d HEAD@{1}: reset: moving to HEAD~1
f4e5d6c HEAD@{2}: commit: Add hello.txt
この場合, f4e5d6cはfeatureブランチが指すコミットのハッシュ値である.
ハッシュ値の対応関係を視覚化
reflogのハッシュ値とGitリポジトリの関係を詳しく見てみよう.
コミット作成時のハッシュ値記録
reset操作時のハッシュ値記録
ハッシュ値の一意性と追跡可能性
reflogに記録されるハッシュ値の重要な特徴:
1. 一意性の保証
# 同じハッシュ値は同じコミットを指す
$ git show a1b2c3d
commit a1b2c3d4e5f6789012345678901234567890abcd
Author: User Name <user@example.com>
Date: Fri Dec 31 12:00:00 2021 +0900
Initial commit
$ git reset --hard HEAD@{2} # a1b2c3dに戻る
$ git show HEAD
commit a1b2c3d4e5f6789012345678901234567890abcd # 同じハッシュ値
Author: User Name <user@example.com>
Date: Fri Dec 31 12:00:00 2021 +0900
Initial commit
2. 完全な復元可能性
# reflogのハッシュ値を使って完全に復元
$ git reset --hard f4e5d6c # reflogから取得したハッシュ値
$ git log --oneline
f4e5d6c (HEAD -> main) Add hello.txt
a1b2c3d Initial commit
# ファイルの内容も完全に復元される
$ cat hello.txt
Hello World
3. ハッシュ値の継承
reflogは既存のコミットハッシュを参照するだけで, 新しいハッシュ値を生成することはない. すべてのハッシュ値は, Gitオブジェクトデータベースに存在するコミットオブジェクトのハッシュ値である.
このように, reflogのハッシュ値は既存のコミットオブジェクトのハッシュ値をそのまま記録したものであり, これによって過去の任意の状態への完全な復元が可能になっている.
reflogはブランチ移動も記録する
git reflogはresetだけでなく, ブランチ間の移動(checkout)やマージ(merge)といった操作も記録している. これにより, 複雑なブランチ操作を行った後でも, 以前の状態に戻ることが可能である.
ブランチ移動の記録例
以下のような一連の操作を行った場合を考えてみよう.
# 初期状態: mainブランチにいる
$ git log --oneline
C3 (HEAD -> main) Main branch commit 3
C2 Main branch commit 2
C1 Initial commit
# 新しいブランチを作成して移動
$ git checkout -b feature
Switched to a new branch 'feature'
# featureブランチで作業
$ git commit -m "Feature commit 1"
$ git commit -m "Feature commit 2"
$ git log --oneline
F2 (HEAD -> feature) Feature commit 2
F1 Feature commit 1
C3 (main) Main branch commit 3
C2 Main branch commit 2
C1 Initial commit
# mainブランチに戻る
$ git checkout main
Switched to branch 'main'
# featureブランチをマージ
$ git merge feature
Merge made by the 'recursive' strategy.
# reflogを確認
$ git reflog
M1 HEAD@{0}: merge feature: Merge made by the 'recursive' strategy.
C3 HEAD@{1}: checkout: moving from feature to main
F2 HEAD@{2}: commit: Feature commit 2
F1 HEAD@{3}: commit: Feature commit 1
C3 HEAD@{4}: checkout: moving from main to feature
C3 HEAD@{5}: commit: Main branch commit 3
...
この操作の流れを視覚化してみよう.
1. 初期状態 (mainブランチ)
2. featureブランチを作成して移動後, コミットを追加
3. mainブランチに戻ってマージ後
reflogを使ったマージをreset --hardした際の復旧例
もし間違えてマージを取り消した場合, reflogを使って以前の状態に戻ることができる.
# 間違えてマージを取り消してしまった
$ git reset --hard HEAD~1
$ git log --oneline
C3 (HEAD -> main) Main branch commit 3
C2 Main branch commit 2
C1 Initial commit
# マージが消えてしまった!でもreflogには記録が残っている
$ git reflog
C3 HEAD@{0}: reset: moving to HEAD~1
M1 HEAD@{1}: merge feature: Merge made by the 'recursive' strategy.
C3 HEAD@{2}: checkout: moving from feature to main
F2 HEAD@{3}: commit: Feature commit 2
...
# マージ直後の状態に戻る
$ git reset --hard HEAD@{1}
$ git log --oneline
M1 (HEAD -> main) Merge branch 'feature'
F2 Feature commit 2
F1 Feature commit 1
C3 Main branch commit 3
C2 Main branch commit 2
C1 Initial commit
この復旧プロセスを視覚化してみよう.
1. マージ後の正常な状態
2. 間違えてreset --hard HEAD~1を実行してマージが消えた状態
3. reflogを使ってHEAD@{1}に戻し、マージを復旧した状態
ブランチ削除からの復旧
reflogは削除されたブランチの復旧にも役立つ.
# featureブランチを削除
$ git branch -D feature
Deleted branch feature (was F2).
# ブランチが消えた
$ git branch
* main
# でもreflogには記録が残っている
$ git reflog
M1 HEAD@{0}: reset: moving to HEAD@{1}
M1 HEAD@{1}: merge feature: Merge made by the 'recursive' strategy.
C3 HEAD@{2}: checkout: moving from feature to main
F2 HEAD@{3}: commit: Feature commit 2
F1 HEAD@{4}: commit: Feature commit 1
...
# 削除されたブランチを復活させる
# HEAD@{3}の時点でfeatureブランチのF2にいた
$ git checkout -b feature-recovered HEAD@{3}
Switched to a new branch 'feature-recovered'
$ git log --oneline
F2 (HEAD -> feature-recovered) Feature commit 2
F1 Feature commit 1
C3 (main) Main branch commit 3
C2 Main branch commit 2
C1 Initial commit
このブランチ削除と復旧のプロセスを視覚化してみよう.
1. ブランチ削除前の状態(マージ済み)
2. featureブランチを削除した状態
3. reflogを使ってfeature-recoveredブランチとして復活
このように, reflogはブランチ操作の履歴も詳細に記録しているため, 複雑なGit操作を行った後でも安全に以前の状態に戻ることができる. 特にチーム開発では, ブランチの作成・削除・マージが頻繁に行われるため, reflogの存在は非常に心強い安全装置となる.
git logとgit reflogは, どちらもGitの履歴を扱う上で不可欠なツールであるが, その目的とメカニズムは全く異なる.
-
git logは共有されるプロジェクトの歴史であり, 変更の文脈を理解するために使う. -
git reflogはローカルでの操作の記録であり, 誤った操作からの復旧という安全装置の役割を担う.
また, HEAD~1とHEAD@{1}の違いは, コミットの親子関係を辿るか, 時間的な操作履歴を辿るかの違いである. この違いを理解することで, git resetをより安全かつ効果的に使いこなすことが可能になる.
特にreflogは, resetやcommitだけでなく, ブランチの移動(checkout)やマージ(merge), さらにはブランチの削除からの復旧まで幅広くカバーしている. Git操作に慣れないうちはもちろん, 熟練した開発者にとっても強力な味方となる. 困ったときはまずgit reflogを確認する癖をつけよう!.