Skip to content

필사 모드: Gitはデータをどう保存しているか

日本語
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

はじめに — 毎日使うのに誰も中を見ない

Gitは開発者が毎日使うツールですが、その内部が実際どうなっているかを知る人は意外と少ないものです。ほとんどの人はgit addgit commitgit pushを呪文のように覚えて使い、何かこじれるとStack Overflowからコピーしたコマンドでどうにか抜け出します。

ところがGitの内部は驚くほど単純で優雅です。実はGitの中核データモデルは、数時間あれば完全に理解できるほど小さいのです。そして一度そのモデルを理解すれば、これまで魔法のように見えていたGitの挙動がすべて論理的に説明できます。なぜブランチを作るのがあれほど速いのか、git checkoutが実際に何をしているのか、なぜコミットハッシュがあんな形なのかが、すべて一つの絵の中で理解できます。

この記事はGitを「コマンドの寄せ集め」ではなく「データ保存システム」として見ます。Gitがあなたのファイルを実際にどんな構造で保存するのか、その構造がなぜそう設計されているのかを底から掘り下げます。読み終わるころには、Gitがずっと怖くなくなっているはずです。

核心的な洞察 — Gitはコンテンツアドレスストアだ

Gitを理解する唯一の鍵があるとすれば、これです。Gitは根本的にコンテンツアドレスストア(content-addressable store)だ。

コンテンツアドレス保存とは、データを「名前」や「場所」ではなく**「内容そのもの」でアドレス付けする**方式です。具体的にGitは、ある内容を保存するとき、その内容に対してSHAハッシュを計算し、そのハッシュ値をその内容のアドレス(=名前)として使います。

このアイデアの帰結は強力です。

  • 同じ内容は常に同じアドレスを持つ。 二つのファイルの内容が完全に同じなら、ハッシュも同じで、したがってGitリポジトリの中で一度だけ保存されます。
  • 内容が少しでも変わればアドレスが完全に変わる。 ハッシュ関数の性質上、バイト一つ変わっても全く違うハッシュが出ます。
  • 整合性がタダでついてくる。 アドレスがすなわち内容のハッシュなので、データが破損すればハッシュが合わず即座に検知されます。

このコンテンツアドレス方式の上に、Gitは四種類のオブジェクト(object)を積み上げます。この四つのオブジェクトがGitデータモデルのすべてです。

四つのオブジェクト — blob、tree、commit、tag

Gitが保存するすべては、結局四種類のオブジェクトのいずれかです。それぞれ見ていきましょう。

blob — ファイルの内容

blobはファイル一つの内容を収めます。ここで重要なのは、blobが内容だけを収めるという点です。ファイル名も、パスも、権限もblobの中にありません。ただのバイトの塊です。だから名前が違っても内容が同じ二つのファイルは、同一のblob一つを指します。

tree — ディレクトリ構造

treeはディレクトリに相当します。treeは名前とオブジェクト参照のリストを収めます。各項目は「この名前の項目はあのblob(ファイル)だ」あるいは「この名前の項目はあのtree(サブディレクトリ)だ」を指します。つまりtreeがblobたちに名前と構造を与えます。blobがファイルの内容なら、treeはその内容をファイルシステムのように組織します。

commit — スナップショットと歴史

commitは私たちがよく「コミット」と呼ぶものです。一つのcommitオブジェクトは次を収めます。

  • 最上位のtree一つへの参照 — このコミット時点のプロジェクト全体のスナップショット
  • 一つ以上の親コミットへの参照(最初のコミットは親がなく、マージコミットは親が二つ以上)
  • 作者とコミッターの情報、タイムスタンプ
  • コミットメッセージ

ここで核心はcommitがスナップショットを指すことです。よくある誤解は「Gitは差分(diff)を保存する」というものですが、実際には各コミットはその時点の全体ツリーを丸ごと指します。diffは必要なときに二つのスナップショットを比較して計算するものであり、保存の基本単位ではありません。(後で見るパックファイルが保存効率のためにデルタを使いますが、それは論理モデルではなく保存の最適化です。)

tag — 名札

tagオブジェクトは特定のオブジェクト(たいていコミット)に永続的な名前を付けるのに使います。v1.0.0のようなリリースタグが代表例です。タグにはタグを付けた人、日付、メッセージ、そして署名が入りえます。

この四つのオブジェクトの関係を絵で見るとこうなります。

  commit  ──parent──▶  commit  ──parent──▶  commit
    │                    │                    │
    ▼ (tree)             ▼                    ▼
  tree ──────────────▶ blob  (README.md の内容)
    ├──────────────▶ blob  (main.py の内容)
    └──────────────▶ tree ──────▶ blob (src/util.py の内容)
                    (サブディレクトリ)

commitはtreeを指し、treeはblobと別のtreeを指し、commitは親commitを指します。この単純な参照構造がGitのすべてです。

SHAハッシュ — なぜあの長い文字列なのか

Gitを使っていると、a1b2c3d4...のような40桁(あるいは省略された7桁)の16進文字列をずっと見ることになります。これがまさにオブジェクトのアドレス、すなわちSHAハッシュです。

Gitは各オブジェクトを保存するとき、オブジェクトのタイプと内容を合わせてSHAハッシュを計算します。そのハッシュがオブジェクトの一意な名前になります。コミットハッシュがあのような形なのはここに理由があります。それは恣意的に付けた番号ではなく、そのコミットの内容(指すtree、親、メッセージ、作者など)全体から計算された指紋です。

ここで美しい性質が出てきます。コミットハッシュはそのコミットが指すtreeを含み、treeハッシュはそれが指すblobたちを含み、コミットは親コミットのハッシュを含みます。つまり各オブジェクトのハッシュは、それが到達できるすべてに依存します。

その結果、歴史のどこか奥深くの古いコミットでファイルの1バイトだけ変わっても、そのコミットのハッシュが変わり、それを親とするすべての子孫コミットのハッシュが連鎖的に変わります。これがGitが整合性を保証する方式です。歴史をこっそり改ざんすることは不可能です。ハッシュが全部食い違うからです。こうした構造をマークル木(Merkle tree)またはハッシュDAGと呼びます。

(歴史的にGitはSHA-1を使ってきて、衝突の懸念からSHA-256への移行が進められてきました。しかしデータモデルの原理は、どのハッシュ関数を使っても同じです。)

コミットDAG — 歴史はグラフだ

多くの人はGitの歴史を「一列に並んだコミットたち」として想像します。しかし正確には**DAG(有向非巡回グラフ、Directed Acyclic Graph)**です。

  • 有向(Directed): 各コミットは親を指します。矢印は過去を向きます。
  • 非巡回(Acyclic): コミットは自分の子孫を親に持てません。巡回がありません。

ブランチが分かれて合わさると、このグラフは一列ではなく本物のグラフになります。

                  o---o---o   (feature ブランチ)
                 /         \
  o---o---o---o-------------o---o   (main ブランチ)
      │           │              │
    過去 ─────────────────────▶ 現在

マージコミット(上の絵で二本の線が出会う地点)は親が二つです。一つはmainの前のコミット、もう一つはfeatureの最後のコミットです。こうしてコミットが複数の親を持てるので、歴史がグラフになります。

このDAGの視点はGitの多くの挙動を明快に説明します。git logはこのグラフを現在の地点から親の方向へ遡って走査することであり、git mergeは二つの地点の共通祖先を見つけてそこからの変更を合わせることであり、rebaseはコミットたちを剥がして別の地点の上に付け直すことです。すべてグラフ上の操作です。

ブランチはただのポインタだ — Gitの最も解放的な事実

Gitを学ぶうえで最大の気づきの瞬間は、たいていこれです。ブランチは重く複雑な何かではなく、ただコミット一つを指すポインタにすぎない。

正確に言えば、ブランチはコミットハッシュ一つを収めた小さなテキストファイルです。mainブランチは「mainが指す最新コミットのハッシュはこれだ」と書かれた41バイトのファイルにすぎません。新しいブランチを作るというのは、ただ同じコミットを指す新しいポインタファイルをもう一つ作ることです。

この事実がいろいろなことを説明します。

  • なぜブランチ作成が即座なのか。 ファイル一つを書くだけなので、リポジトリがいくら大きくてもブランチ作成は一瞬です。重いコピーは起こりません。
  • コミットすると何が起こるのか。 現在のブランチでコミットすると、新しいコミットオブジェクトが作られ、ブランチポインタがその新しいコミットを指すよう更新されます。ポインタが一つ前へ動くのです。
  • HEADとは何か。 HEADは「今自分がどのブランチにいるか」を指すもう一つのポインタです。ふつうブランチを指し、そのブランチがまたコミットを指します。

この関係を絵で見るとこうなります。

  HEAD ──▶ main ──▶ commit(f9a2...) ──▶ commit(3c1d...) ──▶ ...
                        (最新)              (その前)

  git commit すると:
  HEAD ──▶ main ──▶ commit(新しいもの!) ──▶ commit(f9a2...) ──▶ ...
                     ポインタが前へ動いた

「ブランチはポインタ」という事実を体に染み込ませると、Gitが急に怖くなくなります。ブランチを消してもコミットオブジェクト自体は(他の場所で参照される限り)消えず、誤って動かしたブランチもポインタを元のコミットに戻せば済みます。タグもブランチに似たポインタですが、ブランチがコミットのたびに前へ動くのに対し、タグは一つのコミットに固定されて動かないという違いがあります。

.git ディレクトリの中を覗いてみよう

ここまでの概念はすべて.gitディレクトリの中に実際に存在します。プロジェクトルートの.gitフォルダを開いてみると、抽象的にだけ話していたものの実物が見えます。おおよそこんな構造です。

  .git/
  ├── HEAD              # 今チェックアウトされたブランチを指す (例: ref: refs/heads/main)
  ├── config            # リポジトリ設定
  ├── objects/          # すべてのオブジェクト(blob, tree, commit, tag)がここに保存される
  │   ├── 3c/
  │   │   └── 1d8f...   # ハッシュ先頭2文字がフォルダ、残りがファイル名
  │   ├── f9/
  │   │   └── a2b7...
  │   └── pack/         # パックファイル (後で説明)
  ├── refs/
  │   ├── heads/        # ローカルブランチたち — 各ファイルがコミットハッシュ一つを収める
  │   │   ├── main
  │   │   └── feature
  │   └── tags/         # タグたち
  └── logs/             # reflog — 参照がどう動いたかを記録

核心を押さえましょう。

  • objects/ディレクトリが実際のデータストアです。すべてのblob、tree、commit、tagが、それぞれのハッシュをファイル名にしてここに保存されます。ハッシュ先頭2文字をフォルダ名として切り出すのは、一つのフォルダにファイルが多くなりすぎないようにする実用的な工夫です。
  • refs/heads/の中の各ファイルがまさにブランチです。mainファイルを開くとコミットハッシュ一行が入っています。先に述べた「ブランチはポインタ」がここで文字通り確認できます。
  • HEADファイルはふつうref: refs/heads/mainのような内容を収めて、今どのブランチにいるかを指します。
  • logs/のreflogはブランチとHEADが時間とともにどう動いたかを記録します。誤ってコミットを失ったとき、reflogで取り戻せる理由がこれです。

Gitの内部を実際に手で触りながら身につけたいなら、Gitプレイグラウンドでコミットを積みブランチを作りながら、この構造がどう変わるかを目で確認できます。概念を文章だけで読むのと、グラフが実際に育っていくのを見るのとでは、理解の深さが違います。

同じ内容は一度だけ保存される — 重複排除の優雅さ

コンテンツアドレス方式の最も実用的な帰結の一つが**自動重複排除(deduplication)**です。

先に述べたように、blobの名前はその内容のハッシュです。ですからリポジトリの中に内容が完全に同じファイルが複数あっても、それらは全部同一のblob一つを指します。物理的には一度だけ保存されます。

この原理はコミットの間でも強力に働きます。コミットを100回したのに、そのうちあるファイルが一度も変わらなかったとしましょう。するとそのファイルのblobは100個のコミットにわたってちょうど一つだけ存在します。各コミットのtreeたちが皆同じblobハッシュを指すだけです。同様に、あるコミットでファイル一つだけ変えたなら、変わったファイルの新しいblobとそれを収めるtreeだけが新しく生まれ、残りの変わらないblobとtreeは前のコミットのものをそのまま再利用します。

これがGitが膨大な歴史を収めてもリポジトリが思ったほど膨れない理由です。各コミットが全体スナップショットを「指す」けれど、変わらない部分は物理的に共有するからです。スナップショットモデルの概念的な単純さと、重複排除による保存効率を同時に得るのです。

パックファイル — 保存をもう一度圧縮する

ここまでは各オブジェクトがobjects/の下に個別のファイルとして保存されると説明しました(これをルーズオブジェクト、loose objectと呼びます)。これだけでも重複排除のおかげでかなり効率的ですが、Gitはここからもう一歩進みます。**パックファイル(packfile)**です。

リポジトリが大きくなると、個別のオブジェクトファイルが数十万個に増えることがあります。ファイルが多すぎるとファイルシステムの負担も大きく、各オブジェクトを別々に圧縮するより一緒に圧縮するほうが効率的です。そこでGitは定期的に(あるいはgit gc実行時に)多くのオブジェクトを一つのパックファイルにまとめます。

パックファイルの二つの核心的な最適化はこうです。

  • 一緒に圧縮。 複数のオブジェクトを一つのファイルにまとめて丸ごと圧縮すると、個別圧縮より圧縮率がよくなります。
  • デルタエンコーディング。 ここで面白い逆転があります。先に「Gitはスナップショットを保存する」と言いましたが、パックファイルの中では似たオブジェクト同士で差(デルタ)だけを保存できます。たとえばある大きなファイルの複数のバージョンがあれば、一つを丸ごと保存し、残りはそれとの差だけで表現します。

ここで重要なのは層の区別です。論理モデルではGitは依然としてスナップショットベースです。各コミットは概念的に完全なツリーを指します。しかし物理保存ではパックファイルがデルタエンコーディングで空間を節約します。この二つは矛盾ではありません。ユーザーとコマンドが見るのはスナップショットモデルで、デルタはその下の保存層の最適化にすぎません。Gitはデルタで保存されたオブジェクトを読むとき、自動で再構成して完全なオブジェクトとして返します。

この設計は先に見たSQLiteやripgrepの教訓とも通じます。きれいで単純な論理モデルをユーザーに見せつつ、その下では実用的な最適化を隠して行うということです。

全体像 — 一つにつなげてみる

ここまでのピースを一つの絵に合わせると、Gitのデータモデル全体が見えます。

  refs/heads/main  ─┐
                    │ (ポインタ)
  HEAD ─────────────┘
                 commit  ──parent──▶  commit  ──▶ ...
                    ▼ (スナップショット: tree を指す)
                  tree ──────▶ blob   (ファイルの内容、ハッシュでアドレス化)
                    └────────▶ tree ──▶ blob
                              (サブディレクトリ)

  * すべてのオブジェクトは内容のSHAハッシュでアドレス化される (コンテンツアドレス)
  * 同じ内容 = 同じハッシュ = 一度だけ保存 (重複排除)
  * objects/ にルーズオブジェクトとして、後でパックファイルに圧縮

この一枚の絵に、この記事のすべての概念が入っています。ポインタ(ブランチ・HEAD)がコミットを指し、コミットがスナップショット(tree)を指し、treeがファイルの内容(blob)を指し、すべてが内容のハッシュでアドレス化されて重複なく保存されます。

このモデルを知ると何が変わるのか

Gitのデータモデルを理解すると、実務でいろいろなことが変わります。

  • コマンドが論理的に見える。 checkoutは作業ディレクトリを特定のtreeの内容に変えてHEADを動かすこと、mergeは共通祖先を見つけてマージコミットを作ること、resetはブランチポインタを動かすこと。すべてオブジェクトとポインタの操作で説明できます。
  • ミスが怖くなくなる。 コミットオブジェクトは参照される限り消えないので、ブランチを間違って動かしても、reflogで元のコミットハッシュを見つけてポインタを戻せば済みます。「失われたように見える」コミットもたいていそのままあります。
  • 性能が理解できる。 なぜブランチが軽いのか、大きなリポジトリでなぜ特定の操作が速いのかが、データ構造で説明されます。
  • 協働が明確になる。 pushfetchは結局、オブジェクトたちと参照をリポジトリ間でやり取りすることです。何が行き来するのか絵が描ければ、衝突や同期の問題も混乱しにくくなります。

おわりに

Gitが難しく感じられる理由は、たいていその内部モデルを知らないままコマンドだけ覚えるからです。しかしそのモデル自体は驚くほど小さく優雅です。四種類のオブジェクト(blob、tree、commit、tag)、内容でアドレスを付けるSHAハッシュ、コミットたちがなすDAG、そしてその上を指す軽いポインタであるブランチ。これがすべてです。

この絵を一度頭に入れてしまえば、これまで魔法のように見えたり怖く感じたりしたGitの挙動が、すべて同じ論理の上で理解できます。ブランチを作ることも、コミットを戻すことも、歴史を書き換えることも、結局はオブジェクトを作りポインタを動かすことです。

次にGitが予想外に動いて戸惑ったら、コマンドの代わりにデータモデルを思い浮かべてみてください。「今どのオブジェクトが作られて、どのポインタがどこを指しているか?」。この一つの問いでほとんどの混乱がほどけます。そしてGitプレイグラウンドで自分でコミットとブランチを作り、グラフが育つのを目で確認すれば、このモデルは完全にあなたのものになります。

参考資料

현재 단락 (1/123)

Gitは開発者が毎日使うツールですが、その内部が実際どうなっているかを知る人は意外と少ないものです。ほとんどの人は`git add`、`git commit`、`git push`を呪文のように覚えて...

작성 글자: 0원문 글자: 8,629작성 단락: 0/123