Git高度なテクニックと内部構造

Gitのオブジェクトモデル(内部構造)から、コンフリクトの仕組み、Rebase、Reflogによるデータ救出など、一歩進んだGitの活用法を学びます。

学習をスタートする

ロードマップ

1

Gitの内部オブジェクトモデル

普段私たちが git add や git commit を実行するとき、裏側で何が起きているでしょうか? Gitは単なる「ファイルの差分を記録するツール」ではありません。その正体は、高度に最適化された 「コンテンツアドレス可能なファイルシステム(キーバリューストア)」 です。 第1章では、.git ディレクトリの構造と、Gitデータを構成する「4つの基本オブジェクト」について学びます。 1. コンテンツアドレス可能とは? Gitにファイルや履歴を保存すると、その内容はすべて暗号学的ハッシュ関数(主に SHA-1。最近はSHA-256への移行も進行中)によって 「40文字の16進数ハッシュ値」 に変換されます。 ハッシュ値はデータの内容から一意に決定されるため、Gitはデータを「ファイルパス」ではなく 「ハッシュ値(コンテンツ)」をキーにして管理 します。これが「コンテンツアドレス可能」と呼ばれる理由です。 [!TIP] ハッシュの一意性のメリット ファイルの内容が1文字でも変わればハッシュ値は完全に異なるものになります。逆に、ファイル名が違っても中身が全く同じであれば、Gitの内部では同じハッシュ値(同一のデータ実体)として扱われ、ディスク容量を節約できます。 2. Gitの4つの基本オブジェクト Gitの内部データベース(.git/objects/)に保存されるオブジェクトは、以下の4つのタイプしかありません。 ① Blob (Binary Large Object) ファイルの内容(データ)そのものを保存するオブジェクトです。 * 特徴: ファイル名やディレクトリ構造、パーミッション情報などは一切含みません。 純粋な「ファイルの中身のテキストやバイナリ」のみが圧縮されて保存されます。 ② Tree ファイルシステムにおける「ディレクトリ(フォルダ)」に対応するオブジェクトです。 * 特徴: ディレクトリ配下にあるファイル名、パーミッション、およびそれに対応する Blobのハッシュ値 または 別のTreeのハッシュ値 のリストを保持します。これによって、ファイルシステム全体の階層構造が再現されます。 ③ Commit リポジトリの「ある時点の状態(スナップショット)」を表現するオブジェクトです。 * 特徴: * その時点のプロジェクトのルートディレクトリを表す Treeオブジェクトのハッシュ値 * 直前のコミットを示す 親(Parent)コミットオブジェクトのハッシュ値(最初のコミットには親がありません。マージコミットには複数の親があります) * 作成者(Author)とコミッター(Committer)の名前、メールアドレス、日時 * コミットメッセージ ④ Tag (Annotated Tag) 特定のコミットに固定の目印を付けるためのオブジェクトです。 * 特徴: 指し示す先のコミットハッシュ、タグ名、タグ作成者、作成日時、およびタグメッセージを含みます(※ git tag -a で作成される「注釈付きタグ」のみがオブジェクトとして作成されます。署名のない軽量タグは、単なる参照ファイルです)。 3. .git の中をのぞいてみる Gitリポジトリを初期化すると、プロジェクトルートに .git/ という隠しディレクトリが作られます。 * HEAD: 現在チェックアウトしているブランチ(またはコミット)を指すファイル。通常は ref: refs/heads/main のように記述されています。 * refs/: ブランチ(refs/heads/)やタグ(refs/tags/)の情報を格納するディレクトリ。中身は単純なテキストファイルで、コミットオブジェクトのハッシュ値が1行だけ書かれています。つまり、ブランチとは「特定のコミットハッシュが書かれたテキストファイル」に過ぎず、きわめて軽量 です。 * objects/: 上記で紹介した4つの基本オブジェクトが格納されるデータベース。ハッシュ値の最初の2文字がフォルダ名になり、残りの38文字がファイル名になります。 まとめ * Gitは、データを内容に応じたハッシュ値で管理する コンテンツアドレス可能 なシステムである。 * Gitオブジェクトは、中身を保持する Blob、名前と構成を保持する Tree、履歴を繋ぐ Commit、目印となる Tag の4つだけ。 * ブランチは特定のコミットを指し示す「単なるポインタ(テキストファイル)」であるため、作成や削除が極めて高速に行える。

2

Merge vs Rebase とコンフリクトの仕組み

チーム開発を進める中で、複数の開発者が異なるブランチで作業し、それらを最終的に統合する必要があります。統合の手法として代表的なのが 「Merge(マージ)」 と 「Rebase(リベース)」 です。 第2章では、これら2つの統合アプローチの内部的な違い、コンフリクト(衝突)が発生するメカニズム、および競合をスムーズに解消するための基礎知識を学びます。 1. Merge と Rebase の本質的な違い マージもリベースも「別々のブランチでの開発成果を統合する」目的は同じですが、履歴の作り方が大きく異なります。 ① Merge (マージ) 2つの異なるブランチの最新コミットを組み合わせ、新しい 「マージコミット(マージノード)」 を作成して結合します。 * メリット: 歴史的事実がそのまま残ります。どのブランチでいつ誰が作業し、どのタイミングで合流したかが完璧に記録されます。 * デメリット: チーム規模が大きくなると、マージコミットが乱立し、コミットグラフが複雑に交差して見づらくなります(スパゲッティ履歴)。 ② Rebase (リベース) ブランチの分岐点(ベース)を、ターゲットとなるブランチの最新コミットへ 「付け替える(Re-base)」 操作です。 * 内部の動き: 分岐点(C2)から先で作られた feature ブランチのコミット(C4, C5)を一時的にパッチとして退避させ、main ブランチの先端(C3)を新しい土台として、退避させたコミットを 1つずつ順番に適用(再作成) します。 * メリット: コミット履歴が完全に一本の直線(一本道)になり、ログの追跡やコードレビューが非常にスムーズになります。 * デメリット: コミットを再作成するため、コミットハッシュ値が変化 します。既に共有リモートリポジトリにプッシュした公開ブランチでリベースを行うと、他の開発者の履歴と整合しなくなるため、「共有ブランチでのリベースは厳禁」 という大原則があります。 2. 3-way merge(3方向マージ)の仕組み Gitがコンフリクトを検出したり、自動でマージを行ったりする際、単に2つのファイルを比べるのではなく、「3-way merge(3方向マージ)」 というアルゴリズムを使用します。 比較するスナップショットは以下の3つです: 1. 共通の祖先 (Merge Base): 2つのブランチが分岐する前の共通の最新コミット。 2. 自分のブランチ (Ours / Mine): マージを実行している現在のブランチの状態。 3. 相手のブランチ (Theirs): マージ対象となるブランチの状態。 自動マージの判断基準 * 祖先から「自分」だけが変更し、「相手」は変更していない場合 $\rightarrow$ 「自分」の変更を採用(自動マージ)。 * 祖先から「相手」だけが変更し、「自分」は変更していない場合 $\rightarrow$ 「相手」の変更を採用(自動マージ)。 * 祖先から「自分」も「相手」も同じ箇所を異なる内容に変更した場合 $\rightarrow$ Gitはどちらを優先すべきか自動判断できず、「コンフリクト(競合)」 となります。 3. コンフリクト(競合)の解読と解決 コンフリクトが発生すると、Gitは該当ファイルの中に以下のような コンフリクトマーカー を挿入します。 * >>>>>> feature/env-config まで: 取り込もうとしているブランチ(マージ元、Theirs)のコードです。 解決のステップ 1. エディタで競合ファイルを特定する: git status を実行して、both modified になっているファイルを確認します。 2. マーカーを基準にどちらのコードを残すか決定する: チームメンバーと相談し、コードの意図を汲み取って正しい状態にエディタで書き換えます。 3. マーカー(>>>>>>)をすべて削除する。 4. 変更をステージする: git add を実行して「解決済み」であることをGitに通知します。 5. マージコミットを作成する: git commit(リベース中の場合は git rebase --continue)を実行します。 4. git cherry-pick(チェリーピック)の活用 ブランチ全体をマージするのではなく、「別のブランチにある特定のコミット(例えばバグ修正コミットなど)だけを、現在のブランチにコピーして適用したい」という場合に役立つのが git cherry-pick です。 * 仕組み: 指定したコミットが持つ「差分(パッチ)」を算出し、現在のブランチの先端に適用して、全く新しいコミットハッシュを持つコミットとして作成します。 まとめ * Merge はブランチの合流地点を示すマージコミットを作る。歴史を残すのに適しているが、履歴が複雑になる。 * Rebase はコミットの土台を付け替え、履歴を直線にする。ただし、共有ブランチに対して適用すると履歴崩壊の原因になる。 * 3-way merge は「共通の祖先」「自分の先端」「相手の先端」の3点を比較して、自動マージや競合の判定を行う。 * コンフリクトが発生した場合は、コンフリクトマーカー を手がかりに手動でコードをマージし、git add で解決を記録する。

3

Reflogによるデータ救出と状態回復

「誤って git reset --hard を実行し、未プッシュのコミットを消してしまった」「作業中のブランチを削除してしまった」 Gitを使っていると、このような絶望的な状況に直面することがあります。しかし、安心してください。Gitは裏側でデータを非常に大切に保持しており、ローカルでのほとんどのミスは復元可能です。 第3章では、Gitのセーフティネットである 「Reflog(参照ログ)」 の仕組みと、失われたデータを救出する手順、および「Detached HEAD」の正体について学びます。 1. Gitはデータを簡単には捨てない Gitでコミットを作成すると、第1章で学んだ通り、.git/objects/ にオブジェクトファイルとして書き込まれます。 たとえブランチを削除したり、git reset で履歴を上書きしたりしても、コミットオブジェクトそのものはディスク上から即座に消えるわけではありません。 どのブランチやタグからも参照されなくなったコミットは 「孤立した(Dangling)コミット」 と呼ばれます。これらは、数週間〜数ヶ月後にGitの自動ガベージコレクション(git gc)が実行されるまで、リポジトリ内に残り続けます。つまり、ハッシュ値さえ分かればいつでも元に戻すことができます。 2. Reflog(参照ログ)とは? 孤立したコミットのハッシュ値を見つけるための最も強力なツールが git reflog です。 Reflog(Reference Log)は、ローカルリポジトリにおいて 「HEAD(およびブランチのポインタ)が過去にどのコミットを指していたか」の移動履歴 をすべて記録しているログです。 * コミットの作成 (commit) * ブランチの切り替え (checkout / switch) * 履歴の巻き戻し (reset) * マージやリベース (merge / rebase) これらすべての操作によってHEADが移動するたびに、Reflogにその履歴が追加されます。このログは完全にローカル限定であり、リモートリポジトリにプッシュされることはありません。 git reflog の出力例 * HEAD@{0}: 現在のHEADの位置(最新)。 * HEAD@{1}: 1つ前のHEADの位置。 * 左端の 8a9b2c3 などの文字列が、その時点のコミットハッシュです。 3. 失われたコミットの救出手順 シナリオ:git reset --hard で消したコミットを戻したい 誤って git reset --hard HEAD~1 を実行し、直前の重要なコミット c7d8e9f が消えてしまったとします。 ステップ 1: Reflogを確認する ターミナルで git reflog を実行し、消してしまったコミットのハッシュ値を探します。 上記の出力例であれば、c7d8e9f HEAD@{1}: commit: Add database scaling chapter が該当します。 ステップ 2: 状態を復旧する 以下のいずれかの方法で復旧します。 方法 A: そのコミットの位置まで強制的に戻す(現在のブランチを移動する) これで、ブランチの先端が元の正しい位置に戻り、ワークツリー(ファイル)も復元されます。 方法 B: 救出用の新しいブランチを作成してチェックアウトする 安全を期して、現在の状態を残したまま別ブランチとして復元したい場合に適しています。 4. Detached HEAD(離れ小島)状態の理解 git checkout を実行したときに表示される 「You are in 'detached HEAD' state.」 という警告メッセージの正体を説明します。 通常、HEAD はブランチ(例: main)を指し、ブランチが特定のコミットを指しています(間接参照)。 しかし、ブランチ名ではなくコミットハッシュを直接チェックアウトすると、HEAD はコミットオブジェクトを直接指すようになります。この状態を「Detached HEAD(分離されたHEAD)」と呼びます。 なぜ注意が必要なのか? Detached HEAD状態のままコードを変更してコミットを作ると、それらのコミットはどのブランチにも属さない状態になります。そのまま別のブランチ(git checkout mainなど)に切り替えると、新しく作ったコミットを指し示すポインタがなくなるため、履歴から見失ってしまいます。 もし見失ってしまったら? 慌てずに git reflog を実行すれば、Detached HEAD状態で作ったコミットのハッシュ値が記録されています。そこからブランチ(git checkout -b new-feature-branch )を作成すれば、無事に復元できます。 [!NOTE] Git 2.23以降の新コマンド 近年のGitでは、役割が肥大化していた git checkout の代わりに、ブランチ切り替え用の git switch と、ファイルの復元用の git restore コマンドが推奨されています。 まとめ * git reset --hard などで履歴から見えなくなったコミットも、.git/objects にオブジェクトとして実体が残っている。 * git reflog はローカルでのHEADポインタの全移動履歴を記録しているため、失われたコミットハッシュを突き止めることができる。 * Detached HEAD 状態は、HEADがブランチを経由せずコミットを直接指している状態。この状態でのコミット切り替え時も見失いやすいが、Reflogから復元可能である。

4

Gitフックと開発プロセスの自動化

プロジェクトが拡大し、チームメンバーが増えるにつれて、「コードスタイルの乱れ」「コンパイルエラーのあるコードのコミット」「不適切なコミットメッセージ」などが問題になりやすくなります。 これらを人間がコードレビューで手動で指摘するのは非効率的であり、レビューのコストも増加します。そこで役立つのが、特定のGit操作をトリガーにしてスクリプトを自動実行する 「Gitフック(Git Hooks)」 です。 第4章では、Gitフックの内部的な仕組みから、プロジェクトでのチーム共有方法、そして自動テストと連携したデバッグ手法である git bisect の自動化について詳しく学びます。 1. Gitフックの仕組みと基本 Gitフックは、git commit や git push などの特定のライフサイクルイベントの前後で、独自のスクリプトを自動的に実行させる仕組みです。 特別なツールのインストールは不要で、Gitリポジトリを初期化した時点で標準機能として組み込まれています。 フックの格納場所 Gitフックの実体は、リポジトリの隠しディレクトリである .git/hooks/ の中に格納されている 実行可能ファイル(スクリプト) です。 .git/hooks/ の中身をのぞくと、いくつかのサンプルスクリプトが用意されています。 * pre-commit.sample * commit-msg.sample * pre-push.sample これらは .sample という拡張子が付いているため無効化されています。フックを有効化するには、拡張子を取り除き(例: pre-commit)、実行権限(chmod +x)を付与 するだけです。 フックの言語 フック用のスクリプトは、シェルスクリプト(Bashなど)で書かれることが多いですが、インタプリタが指定されていれば、Node.js (JavaScript/TypeScript)、Python、Ruby など、どのような言語で記述しても動作します。 2. 主要なクライアントサイドフック 開発者のローカル環境で実行される「クライアントサイドフック」のうち、特に実務でよく使われる3つのフックを紹介します。 ① pre-commit git commit を実行した直後、コミットメッセージを入力するエディタが起動する前に実行されます。 * 用途: コードの静的解析(Linter/Formatter)の実行、単体テストの実行、コミットファイル内に不要なデバッグコード(console.log やパスワードなどの機密情報)が含まれていないかの検証。 * 挙動: このスクリプトが 「0以外のステータス(エラー)」 で終了すると、コミット処理全体が即座に中断(Abort)されます。 ② commit-msg コミットメッセージが決定された後、コミットが確定する直前に実行されます。 * 用途: コミットメッセージが特定の規約(例: feat: ..., fix: ... のような Conventional Commits)に準拠しているかのチェック。 * 挙動: メッセージが書かれた一時ファイルへのパスがスクリプトの第1引数として渡されます。チェックに失敗し、0以外のステータスを返すとコミットが中止されます。 ③ pre-push git push を実行した際、リモートへのデータ転送が始まる前に実行されます。 * 用途: プッシュ前のビルド確認、結合テストの実行。 * 挙動: ここでテストが失敗すると、プッシュが中断されるため、壊れたビルドが共有ブランチへプッシュされるのをローカルで未然に防ぐことができます。 3. Gitフックのチーム共有と自動化 Gitフックの大きな課題は、.git ディレクトリ配下(.git/hooks/)はGitの追跡対象外 である点です。そのため、自分が作成したフック用スクリプトをそのまま git push して他のメンバーと共有することができません。 この課題を解決するために、モダンな開発では以下のツールを用いてフック管理を自動化します。 simple-git-hooks / husky の仕組み これらのパッケージマネージャー対応ツールは、以下のような方法で追跡対象外の課題をクリアします。 1. Gitの設定 core.hooksPath を変更し、フックの読み込み先を .git/hooks からリポジトリ内の通常の追跡対象ディレクトリ(例: .husky/ や .share-hooks/)に変更する。 2. npm install や bun install 時に、インストール後のフック設定を自動で走らせる。 設定例:simple-git-hooks (本プロジェクトでも採用) package.json に実行したいコマンドを設定します。 コミット時には、ステージングされたファイルに対してのみ Linter を走らせる lint-staged などと組み合わせて実行時間を短縮し、効率的にチェックを行います。 [!WARNING] フックのバイパス(強制コミット) 緊急の修正などでフックの実行を一時的にスキップしたい場合は、git commit --no-verify (または -n オプション) を使用してフックを回避できます。ただし、これを乱用するとフックを導入した意味が薄れるため、使用は必要最低限にとどめましょう。 4. git bisect と自動テストを組み合わせたバグ特定 開発が自動化されたら、バグ発生時の特定プロセスも自動化できます。 git bisect(二分探索) を自動テストスクリプトと連携させることで、過去数千件のコミットの中から「バグが混入した最初のコミット」を数秒で自動特定できます。 自動特定の手順 バグを検知できるテストコード(例: 実行するとバグがあるときはエラーになり、ないときは成功するテスト)がある場合、git bisect run コマンドで全自動探索を実行できます。 1. 探索の開始を宣言します。 2. 現在(バグが出ている最新コミット)を「悪い (bad)」と宣言します。 3. 確実にバグがなかった過去のコミットハッシュを指定し、「良い (good)」と宣言します。 4. 【自動化の肝】 テストを実行するコマンド(バグありなら終了ステータス非ゼロ、バグなしならゼロ)を指定して実行します。 Gitは自動的にコミット履歴を二分探索し、指定されたテストコマンドを各コミットで実行していきます。探索が完了すると、コンソールにバグを混入させたコミットのハッシュと作成者情報がピンポイントで出力されます。 まとめ * Gitフック は、Git操作のライフサイクルに割り込んで自動処理を実行する仕組み。 * 実体は .git/hooks/ 内のスクリプトであり、拡張子を除去して実行権限を与えることで有効化される。 * pre-commit, commit-msg, pre-push などを使い分けることで、バグや規約違反のコードがリポジトリに入るのを防ぐ。 * チーム共有には simple-git-hooks や husky を利用し、core.hooksPath を書き換えて管理する。 * git bisect run を用いることで、バグが混入したコミットの特定を全自動で行える。

5

リポジトリの最適化と大規模開発

プロジェクトの規模が大きくなり、何年にもわたってコミットが積み重なると、Gitリポジトリの容量は数十GBにまで肥大化することがあります。 第5章では、肥大化したGitリポジトリを整理する仕組みと、モノレポなどの大規模開発でGitを快適に使うための最適化技術について学びます。 1. Gitのガベージコレクション (GC) Gitはファイルを削除したり、コミットを上書き(commit --amend や rebase)したりしても、古いデータオブジェクトをすぐにはディスクから削除しません。これらは一時的に 「ルーズオブジェクト(参照されない浮いたオブジェクト)」 として残ります。 これらのゴミデータを掃除し、リポジトリを最適化するのが git gc コマンドです。 git gc が行うこと * パックファイル (Packfile) への圧縮: 数千、数万個に分かれたルーズオブジェクトを、1つのバイナリファイル(.pack)にまとめます。この際、ファイル間の類似性をもとに「差分(デルタ)圧縮」を行うため、サイズが劇的に縮小します。 * 到達不能オブジェクトの削除: reflog の期限(デフォルトで30日前後)も切れ、どのブランチやタグからも完全に辿れなくなったオブジェクトをシステムから消去します。 2. 大規模開発向けのクローン最適化 巨大なリポジトリで git clone を行うと、何時間も待たされることがあります。この問題を回避するために、必要なデータだけをダウンロードする手法があります。 ① シャロークローン (Shallow Clone) 履歴のすべてではなく、最新の指定した数コミット分だけを取得します。 * 用途: CI/CD環境など、ビルドするだけで過去の全履歴が不要な場合に効果的です。 ② パーシャルクローン (Partial Clone) コミット履歴はすべて取得しますが、ファイルの実体(Blobオブジェクト)は必要になった時点で動的にダウンロードします。 * 用途: コミット数やブランチ数は多いが、個々のファイルサイズが大きいリポジトリで、開発者の手元ですばやく clone するために使用されます。 3. スパースチェックアウト (Sparse Checkout) 大規模なリポジトリ(特に複数のサブシステムが1つにまとまった「モノレポ」)において、「特定のフォルダだけを作業ディレクトリに展開したい」 という場合に使う機能です。 設定方法 これによって、リポジトリの全データを裏で保持しつつも、自分のエディタ上には frontend/ ディレクトリだけが見える状態になり、ビルドや検索のパフォーマンスが大幅に向上します。 まとめ * git gc は、不要な古いデータを削除し、オブジェクトをデルタ圧縮してリポジトリのパフォーマンスを維持する。 * 巨大リポジトリ対策として、履歴を制限する シャロークローン や、Blobを制限する パーシャルクローン が有効。 * 展開するディレクトリを限定する スパースチェックアウト を使うことで、モノレポなどの作業環境を軽量化できる。