Skip to content

Instantly share code, notes, and snippets.

@podhmo
Last active July 19, 2025 10:10
Show Gist options
  • Save podhmo/5fdfe8b148e279922aaf5edfc7abea0b to your computer and use it in GitHub Desktop.
Save podhmo/5fdfe8b148e279922aaf5edfc7abea0b to your computer and use it in GitHub Desktop.
Gitの深淵:Squash Mergeは本当にリポジトリを軽量に保つのか
target_reader: Gitのブランチ戦略とリポジトリの内部構造に関心を持つ開発者
objective: Squash Mergeがリポジトリのサイズとgit cloneの負荷に与える影響を、具体的なコマンド例とGitオブジェクトの挙動を通じて深く解説し、他のマージ戦略との違いを明確にする

Gitの深淵:Squash Mergeは本当にリポジトリを軽量に保つのか

発端の問い ❓

Gitを利用した開発フローにおいて、Pull Requestは中心的な役割を担う。ある機能開発ブランチで行われた一連の変更を、メインとなるブランチに統合するプロセスだ。この統合方法にはいくつかの戦略が存在するが、その一つに「Squash Merge」がある。これは、Pull Requestに含まれる複数のコミットを一つにまとめてからマージする手法であり、メインブランチの履歴をクリーンに保つ目的で広く採用されている。

ここで、一つの思考実験を具体的に試みたい。あるPull Requestの作業中に、誤って100MBの巨大なバイナリファイル large-binary.dat をコミットしてしまい、その後のコミットでそのファイルを削除したとする。このPull RequestをSquash Mergeした場合、最終的にファイルは存在しないのだから、リポジトリへの影響はないように思える。巨大なファイルは、マージ後のコミット履歴には確かに現れない。

しかし、Gitリポジトリの内部、その不可視の領域では何が起きているのだろうか。見かけ上の履歴から消えたという事実だけで、リポジトリの物理的なサイズへの影響も皆無だと断言できるのだろうか。そしてその影響は、プロジェクトに新しく参加する開発者が git clone を実行する際に、どのような形で現れるのだろうか。この問いこそが、今回の思考探求の出発点である。

思考実験の準備:フィーチャーブランチの作成 🔬

まず、この状況をシミュレートするフィーチャーブランチを作成しよう。mainブランチからfeature/add-and-remove-fileという名前のブランチを作成し、一連の操作を行う。

  1. ブランチを作成して切り替え

    git switch -c feature/add-and-remove-file main
  2. 100MBのダミーファイルを作成してコミット

    # macOS / Linux の場合
    dd if=/dev/urandom of=large-binary.dat bs=1M count=100
    git add large-binary.dat
    git commit -m "feat: Add large binary file for testing"

    この時点で、リポジトリサイズは100MB増加している。

  3. ファイルを削除してコミット

    git rm large-binary.dat
    git commit -m "fix: Remove large binary file"

このブランチの履歴は以下のようになっている。

$ git log --oneline
c2a1b3d (HEAD -> feature/add-and-remove-file) fix: Remove large binary file
a9d8e7f feat: Add large binary file for testing
b4c6d5e (main) Initial commit

このfeature/add-and-remove-fileブランチをmainブランチにマージしていくが、マージ戦略によって未来が大きく変わることになる。

ケース1:Squash Mergeの挙動と結果 🧼

まず、最も関心の高いSquash Mergeから見ていこう。GitHub上でこのPull Requestを「Squash and Merge」でマージしたと想定する。

マージ後、ローカルのmainブランチを更新すると、履歴は次のようになる。

# mainブランチに切り替えて、リモートの変更を取り込む
git switch main
git pull origin main

# 履歴を確認
git log --oneline --graph

表示される履歴は、驚くほどシンプルだ。

* f5e4d3c (HEAD -> main, origin/main) feat: Add and remove file (#1)
* b4c6d5e Initial commit

フィーチャーブランチにあった2つのコミットは完全に消え、代わりに一つの新しいコミットf5e4d3cが作られている。この新しいコミットは、ブランチの最終的な状態、つまりlarge-binary.datが存在しない状態のスナップショットであるため、巨大なファイルは含まれていない。

見えざるオブジェクトの行方とガベージコレクション ⚙️

Gitは、そのデータを「オブジェクト」という単位で管理している。ファイルの内容は「ブロブ」1、ディレクトリ構造は「ツリー」、そして変更の記録は「コミット」というオブジェクトとして、リポジトリ内部の.git/objectsディレクトリに保存される。

a9d8e7f(巨大ファイルを追加したコミット)はmainの履歴から見えなくなったが、オブジェクト自体はまだリポジトリ内に存在している可能性がある。このような、どのブランチの先端からも直接たどれないオブジェクトは「到達不能オブジェクト (Unreachable Object)」2と呼ばれる。

これら不要なオブジェクトを最終的に物理削除するのが「ガベージコレクション (Garbage Collection)」3である。git gcコマンドによって起動されるこのプロセスは、到達不能オブジェクトを特定して恒久的に削除する。GitHubのようなホスティングサービスは、このgcを定期的にバックグラウンドで実行し、リポジトリを最適化している。

参照の鎖を断ち切る儀式:ブランチ削除とgc 🌿

到達不能オブジェクトがgcによって削除されるための絶対条件は、文字通り「どこからも参照されていない」ことである。

Squash Mergeの利点を完全に享受するには、マージ後に元のフィーチャーブランチを削除することが極めて重要だ。ブランチが存在する限り、巨大なファイルを追加したコミットへの参照パスが残り、オブジェクトは「到達可能」なままでgcの対象にならない。

推奨されるワークフロー:

  1. GitHubでブランチを削除: Pull Requestのマージ時に「Automatically delete the head branch」オプションを有効にする。
  2. ローカルで追跡ブランチを掃除: git fetch --prune を実行し、ローカルの参照をクリーンアップする。
  3. gcによる浄化を待つ: これで参照が断ち切られ、いずれgcによって不要なオブジェクトは物理削除される。

補題:クローンという最初の試練 🏋️

さて、ここまでの議論はリポジトリの内部構造に関するものだったが、これが開発者の日常にどう影響するのか。最も顕著なのが、プロジェクト参加の第一歩であるgit cloneだ。

git cloneは、単に最新のソースコードをコピーするコマンドではない。これは、リポジトリの完全な歴史、つまり.gitディレクトリに格納された全ての「到達可能な」オブジェクトをネットワーク経由でダウンロードするプロセスである。

  • Squash Mergeで管理されたリポジトリの場合: mainブランチの歴史には巨大ファイルのコミットが含まれていない。適切なブランチ管理によって巨大ファイルオブジェクトが到達不能になっていれば、clone時にダウンロードされるオブジェクト群にその巨大ファイルは含まれない。結果として、クローンは高速に完了し、ローカルのディスク使用量も最小限に抑えられる。

  • Rebase / Merge Commitで管理されたリポジトリの場合: 巨大ファイルを追加したコミットがmainブランチの歴史に恒久的に刻まれている。これは、その巨大なブロブオブジェクトが常に「到達可能」であることを意味する。したがって、プロジェクトに参加する全ての新規開発者は、git cloneのたびに、今はもう存在しないはずの100MBのデータをダウンロードさせられることになる。これは「歴史の負債」であり、開発者のオンボーディング体験を著しく損なう。CI/CDパイプラインで毎回リポジトリをクローンするような環境では、この無駄なダウンロードが継続的なコストとしてのしかかる。

つまり、ブランチ戦略の選択は、未来のチームメンバーに対する配慮そのものなのである。

ケース2 & 3:異なる歴史の選択肢 🆚

改めて、他の戦略がこの「クローンの負荷」にどう影響するか見てみよう。

Rebase and Merge / Merge Commitの場合

どちらの戦略も、巨大ファイルを追加したコミットa9d8e7f(あるいはその複製)をmainブランチの正史の一部として保存する。

mainブランチの履歴にそのコミットが存在する限り、git cloneする者は例外なく、その巨大なファイルオブジェクトをダウンロードする必要がある。リポジトリは時間と共に肥大化し、新規参加の障壁は高くなっていく。

マージ戦略 途中のコミット履歴 巨大ファイルの履歴 新規クローンへの影響
Squash Merge 1つのコミットに要約される 残らない (要ブランチ削除 & gc) 軽いまま
Rebase and Merge 全て残る(直線的に再構成) 残る 重くなる
Merge Commit 全て残る(分岐履歴として) 残る 重くなる

結論:戦略がリポジトリの運命を左右する

最初の問い、「Squash Mergeはリポジトリを軽量に保つのか?」への最終的な答えは、**「はい、しかし適切な作法を守った場合に限る」**となる。

そして、その恩恵は単なるログの見栄えの良さにとどまらない。それは、リポジトリの物理的なサイズを健全に保ち、未来のチームメイトが経験するgit cloneという最初の試練を容易にするという、極めて実践的な価値を持つ。ブランチ戦略の選択は、リポジトリの歴史をどう紡ぐかという技術的な判断であると同時に、チーム全体の生産性と開発体験を左右する文化的な決定でもあるのだ。


Footnotes

  1. ブロブ (Blob): Binary Large Objectの略。Gitがファイルの内容そのものを保存するために使用するオブジェクトタイプ。

  2. 到達不能オブジェクト (Unreachable Object): どのブランチやタグなどの参照からもたどることができなくなったオブジェクト。これらはリポジトリ内に存在はするが、通常の操作ではアクセスできない。

  3. ガベージコレクション (Garbage Collection): プログラミング言語におけるメモリ管理と同様の概念で、不要になったオブジェクトを特定し、リポジトリから物理的に削除してディスクスペースを解放し、リポジトリを最適化するプロセス。

@podhmo
Copy link
Author

podhmo commented Jul 19, 2025

gitのgcはマーク・アンド・スイープに似てますか?参照カウンタに似てますか?


素晴らしい質問です。これはGitの内部実装の核心に迫る、非常に的確な問いです。

結論から申し上げると、Gitのガベージコレクションは、圧倒的に「マーク・アンド・スイープ (Mark and Sweep)」に類似しています。 参照カウンタ方式とは根本的に異なります。

Git GCはなぜ「マーク・アンド・スイープ」なのか

Gitのオブジェクトモデルは、コミットが親コミットを指し、ツリーがブロブ(ファイル)や他のツリーを指すという、有向非巡回グラフ(DAG)1になっています。この構造をクリーンアップするのに、マーク・アンド・スイープは非常に合理的なアルゴリズムです。

プロセスは、まさにその名の通り2つのフェーズに分かれます。

1. マーク段階 (Mark Phase)

このフェーズの目的は、「現在も必要とされているオブジェクト」をすべて特定し、印(マーク)をつけることです。

  • ルート (Roots) の特定: まず、探索の起点となる「ルートセット」を特定します。Gitにおけるルートとは、現在アクティブな参照すべてを指します。具体的には、
    • 全てのブランチの先端 (refs/heads/)
    • 全てのタグ (refs/tags/)
    • HEAD (現在チェックアウトしているコミット)
    • インデックス(ステージングエリア)が参照するオブジェクト
    • その他、stashなどの参照
  • グラフの走査: 次に、これらのルートオブジェクトから出発し、グラフを再帰的にたどっていきます。コミットから親コミットへ、コミットからツリーへ、ツリーからブロブやサブツリーへと、参照されているすべてのオブジェクトを訪問します。
  • マーキング: 訪問したすべてのオブジェクトに「到達可能(reachable)」であるという印をつけます。

この段階が終わると、リポジトリ内のすべての「生きている」オブジェクトがマークされた状態になります。

2. スイープ段階 (Sweep Phase)

このフェーズの目的は、「不要なオブジェクト」を処分することです。

  • ゴミの特定: リポジトリ内に存在するすべてのオブジェクトを一つずつチェックします。
  • 処分: もしオブジェクトがマーク段階で印をつけられていなければ、それはどのルートからも到達不可能な「ゴミ」であると判断されます。git gcはこれらの到達不能オブジェクトを物理的に削除します。
    • 実践的には、git gcは多くの場合、到達可能なオブジェクトだけを新しい「パックファイル」2に効率的にまとめ直し、古いファイルや到達不能オブジェクトが含まれる古いパックファイルを削除することで、このスイープ処理を実現します。

なぜ「参照カウンタ」ではないのか

参照カウンタ方式は、各オブジェクトが「自分を指しているポインタの数」を保持し、その数が0になった瞬間にオブジェクトを解放する仕組みです。Gitがこの方式を採用していない理由は明確です。

  1. 即時解放されない挙動: もし参照カウンタ方式なら、ブランチを削除した瞬間(参照が一つ減る)、そのコミットのカウンタが0になれば、連鎖的に親オブジェクトのカウンタも減り、不要なオブジェクトは即座に解放されるはずです。しかし、実際のGitの挙動はそうではありません。ブランチを削除しても、オブジェクトは「到達不能」になるだけで、git gcが実行されるまでリポジトリ内に残り続けます。この「遅延評価的」な削除こそが、マーク・アンド・スイープ方式の最大の特徴です。

  2. 循環参照の問題(理論的な側面): 参照カウンタは、オブジェクト同士が互いを参照しあう「循環参照」を解決できないという古典的な弱点があります。GitのコミットグラフはDAGなので直接的な問題にはなりませんが、より堅牢なグラフ全体の整合性を保つ上では、ルートからの到達可能性で判断するマーク・アンド・スイープの方が優れています。

まとめ:アルゴリズムの比較

特性 マーク・アンド・スイープ (Git GC) 参照カウンタ
実行タイミング 定期的・バッチ処理 (git gc実行時) 参照が変更される都度、即時
循環参照への耐性 高い(ルートからたどれないものは全てゴミ) 低い(循環しているとカウンタが0にならない)
主な動作 グラフ全体を走査し、到達可能なものをマークする 各オブジェクトが参照数を保持・更新する
コスト gc実行時に一時的な負荷がかかる 参照の追加・削除のたびに小さなコストが発生する

このように、Gitの「ブランチを消してもオブジェクトはすぐ消えず、gcでまとめて掃除される」という挙動は、まさにマーク・アンド・スイープ型ガベージコレクションの考え方そのものです。このアルゴリズムの選択は、Gitの堅牢なデータモデルの根幹をなす、非常に合理的な設計と言えるでしょう。


Footnotes

  1. 有向非巡回グラフ (DAG - Directed Acyclic Graph): ノード(点)とエッジ(向きのある線)から構成され、どのノードから出発しても同じノードに戻ってくる経路(サイクル)が存在しないグラフ構造。Gitのコミット履歴は典型的なDAGです。

  2. パックファイル (Packfile): Gitがディスクスペースを節約し、アクセスを高速化するために、複数のオブジェクトを差分圧縮して一つのファイルにまとめたもの。git gcの主要な仕事の一つが、このパックファイルの再構築です。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment