git のコミットとブランチ、実体は何なのか?rebase が「歴史を書き換える」とはどういうことか
あなたも昨日 git rebase を叩いて、コンフリクトを解消し、git push --force したはずだ。あのとき git は具体的に何をしていたのか、説明できるだろうか。commit は本当に「差分」を保存しているのか、branch とは一体何者なのか。
- git のオブジェクトはすべて中身から計算したハッシュで住所が決まる「内容アドレス型」のストレージであり、commit は差分ではなくスナップショット全体(tree)への参照と親コミットへの参照でできている
- branch は commit を指す 41 バイトの小さなファイルにすぎず、HEAD は今いる branch を指す別のポインタ。だから branch の作成・切り替えは軽い
- rebase は既存のコミットを書き換えるのではなく、同じ変更内容を新しい親の上に適用し直した「別のコミットオブジェクト」を作り、branch の参照先をそちらへ貼り替えているだけだ
コミットの正体:差分ではなく、スナップショットへの参照
git は .git/objects 以下に置かれた「内容アドレス型」のオブジェクトストレージだ。ファイルの中身を保存するブロブ(blob、ファイル内容そのものを保存するオブジェクト)、ディレクトリ構造とファイル名を保存するツリー(tree、ディレクトリの階層構造を表すオブジェクト)、そしてスナップショット全体を指し示すコミットオブジェクトの3種類が主な構成要素になる。
それぞれの保存先は、中身そのものから計算したSHA-1ハッシュ(内容を1バイトでも変えると別の40桁16進数になる、内容そのものを住所にする方式)で決まる。同じ内容のファイルは何回コミットしても同じ blob が1つ保存されるだけで、変更されなかったファイルは前のコミットと同じ blob をそのまま指す。コミットオブジェクトが保存しているのは差分ではなく、tree への参照・親コミットへの参照(マージコミットは複数持つ)・author/committer 情報・メッセージだけであり、ファイルの中身そのものは tree と blob の側にある。
┌─────────────────────────┐
│ commit (SHA: 8f3a2c1..) │
├─────────────────────────┤
│ tree → d8e4f90.. │
│ parent → 5b1c8aa.. │
│ author, message │
└───────────┬─────────────┘
│ tree が指す先
▼
┌─────────────────────────┐
│ tree (ディレクトリ構造) │
│ blob file.txt → a1b2c..│
│ blob README → 9f0e1..│
└─────────────────────────┘
ブランチの正体:41 バイトのポインタファイル
branch は commit を指す小さなファイルにすぎない。.git/refs/heads/<ブランチ名>という1ファイルの中身は、その branch が指す最新コミットの40桁 SHA-1 と改行だけで、合計41バイトしかない。ref(リファレンス、commit を指す名前付きポインタの総称)という言葉は branch や tag などこの手のポインタ全般を指す。
今どの branch にいるかは HEAD という別のポインタが示しており、通常は ref: refs/heads/main のように branch 名を指す中身になっている。新しくコミットすると、HEAD が指す branch ファイルの中身が新しい SHA-1 に書き換わるだけで、ファイルやディレクトリがまるごとコピーされるわけではない。branch を1本作る操作が、集中型 VCS でリポジトリを複製するのと違って一瞬で終わるのはこのためだ。
私書箱がずらりと並んだ倉庫を思い浮かべるとよい。荷物(内容)を預けると、倉庫はその内容だけから一意な棚番号(ハッシュ)を計算して棚に仕舞う。同じ内容の荷物は何度預けても同じ棚番号に収まるので、二度預けても棚は増えない。branch 名はこの棚を指す付箋にすぎず、「main」という付箋をどの棚に貼るかはいつでも貼り替えられる。rebase をすると、倉庫は元の荷物には一切手を触れず、内容を少し変えた荷物を新しい棚番号の場所に詰め直して並べ、付箋だけをそちらへ貼り替える。古い棚は空にはならず、ただ誰の付箋からも指されなくなって忘れられていくだけだ。
rebase が実際にやっていること:複製と付け替え
ここまでを読むと、rebase は「昔のコミットの中身を書き換えている」ように聞こえるかもしれない。しかし実際に起きているのは、既存のコミットオブジェクトには一切触れず、同じ変更内容を新しい親の上に適用し直した「別のコミットオブジェクト」を新規に作ることだ。git は rebase 元の branch と rebase 先の共通祖先を探し、そこから先の各コミットが持つ差分を1つずつ取り出して、rebase 先の最新コミットの上に順番に適用しながら新しいコミットを作っていく。
新しいコミットは元のコミットと同じ変更内容・同じメッセージを持っていても、親コミットの SHA-1 が変わったぶんコミットオブジェクト自体のハッシュも変わる。同じ差分でも「別人」のコミットが生まれるわけだ。branch の参照はこの新しいコミット列の先頭に貼り替えられ、元のコミット列はどの branch からも指されなくなる。git log に見えるのが新しい列だけになるので、最初から一直線に作業していたかのように見える、これが「歴史を書き換える」という表現の指す中身だ。元のコミットは即座に消えるわけではなく、reflog(HEAD や branch が過去に指していた場所を記録した操作ログ)からしばらく辿れるが、いずれ git gc の回収対象になる。
共有済みのコミットを rebase してはいけない理由
「rebase すれば手元できれいな一直線の履歴にできる」と考えがちだが、それが安全なのは自分だけがそのコミット列を触っている間に限られる。一度 push して他の人がそれを pull し、その上に自分の作業を積んでいたとする。そこで push 済みのコミットを rebase して force push すると、リモートの branch は新しいコミット列に置き換わるが、相手のローカルには古いコミット列がそのまま残る。次に相手が pull した瞬間、同じ変更内容なのにハッシュの違う2つのコミット列が両方存在することになり、手作業でのマージが必要になる。
この落とし穴を避ける実務的な線引きはシンプルだ。push 前のローカルの作業を rebase で整理するのは自分しか参照していないので安全だが、一度 push して他人が触った可能性のあるコミットは rebase の対象から外し、直したい場合は新しいコミットを積み増す。
PR を出す前に git rebase -i でコミットを整理して見やすくする、というのはローカルのみの作業なので問題ない。一方 git pull --rebase を習慣にしていると、すでに他の人が pull 済みの共有 branch(release branch や main)に対して知らずに rebase をかけてしまい、force push でチーム全体の履歴が乱れる事故につながる。force push が必要になった時点で、それが自分専用の branch なのか共有 branch なのかを一度立ち止まって確認する価値がある。
実際に object と ref を覗き、rebase が commit の SHA-1 を変えることを確認する。
mkdir -p /tmp/git-internals-demo && cd /tmp/git-internals-demo git init -q -b main git commit -q --allow-empty -m "base" cat .git/refs/heads/main git cat-file -p main git switch -q -c feature git commit -q --allow-empty -m "feature work" echo "rebase前のSHA: $(git rev-parse HEAD)" git switch -q main git commit -q --allow-empty -m "main が進んだ" git switch -q feature git rebase -q main echo "rebase後のSHA: $(git rev-parse HEAD)"
cat .git/refs/heads/main で40桁のハッシュ1行だけが表示され、git cat-file -p main で tree/parent/author が並ぶコミットオブジェクトの中身が見えるはずだ。最後の2行では、同じ "feature work" というメッセージのコミットなのに、rebase 前後で表示される SHA が別物に変わっていることが確認できる。
- commit は変更差分(diff)を保存している — 実際に保存しているのはスナップショット全体を指す tree への参照であり、変更のないファイルは親コミットと同じ blob をそのまま共有している。
- rebase は元のコミットの中身を書き換えている — 実際は元のコミットオブジェクトには一切触れず、新しいコミットオブジェクトを作って branch の参照先を貼り替えているだけであり、元のコミットは reflog からしばらく辿れる。
- branch を切るのは重い操作で、ファイルがコピーされる — 実際は commit を指す41バイトのポインタファイルを1つ作るだけで、作業ツリーの複製は発生しない。
- blob
- ファイルの中身そのものを保存するオブジェクト。同じ内容なら1つしか存在しない。
- tree
- ディレクトリの階層構造とファイル名・モードを保存するオブジェクト。
- コミットオブジェクト
- tree への参照・親コミットへの参照・author/message を保存するオブジェクト。差分そのものは持たない。
- SHA-1
- オブジェクトの中身から計算する40桁16進数のハッシュ値。内容が変われば必ず値も変わる。
- ref
- commit を指す名前付きポインタの総称。branch や tag の実体はこれ。
- reflog
- HEAD や branch が過去に指していたコミットを記録する操作ログ。rebase 前のコミットを辿る手掛かりになる。
- Git Internals - Git Objects | Pro Git — blob/tree/commit オブジェクトの構造と SHA-1 の計算方法を、git cat-file / hash-object の実例つきで解説する公式リファレンス。
- Git Branching - Branches in a Nutshell | Pro Git — branch が refs/heads 以下の1ファイルにすぎないこと、HEAD の役割を定義する公式ドキュメント。
- Git Branching - Rebasing | Pro Git — rebase が共通祖先から差分を再適用する手順と、共有済みコミットを rebase してはいけない理由を定義する公式ドキュメント。