開発スキル / Git内部構造 / 2026.08.30

三台で、
同じものを二度作る

Windows・Mac・Linux で Claude Code を動かしていると、ある日おかしなことに気づきます。 同じ機能を、別々の端末で、別々のやり方で二度作っていた。 スキルが片方にしかない。なぜそう決めたかの経緯が、その端末のセッションにしか残っていない。
共有ドライブに置いても、これは解けません。解けるのは履歴が構造を持つ道具だけで、それが Git です。 ここでは Git をコマンドの使い方ではなく、内部のデータ構造から8層で読みます。

対象 多端末でAIコーディングをする人 構成 Layer 0〜7 実験 本物のハッシュを計算します
問題 なぜ同期では足りないのか

共有ドライブに置いても、
二度作る問題は消えない

多端末開発で起きることを、具体的に並べます。

Google Workspace の共有ドライブに置けば、少なくとも「片方にしかない」は解けます。 版履歴もあるので、うっかり上書きしても戻せます。 それでも二度作る問題は残ります。理由は3つあります。

① 履歴が「ファイル単位・時刻順」であること 5つのファイルを1つの意図で直しても、その5つをひとまとまりとして扱えない。 後から「あの変更」を名指しすることも、まとめて戻すこともできない。 ② 「なぜ」を書く欄がないこと 版履歴に残るのは、いつ・誰が・何を保存したかです。 捨てた案も、踏んだ罠も、そう決めた理由も入りません。 ③ 分岐と統合の概念がないこと 二人が同時に触れば、勝つのは後から保存した方か、コピーが増えるかのどちらか。 別々に進めて、あとで機械的に合流させるができません。

版が残ることと、履歴が構造を持つことは別です。 共有ドライブが持っているのは前者で、多端末開発で要るのは後者です。

Git が持つのは、意図の単位の履歴です

Git は「どのファイル群が、どういう順序で、なぜ変わったか」をひとまとまりとして持ちます。 そして各端末がその履歴の完全な複製を持ちます。中央サーバが落ちていても、手元に全部あります。

この性質はどれも、これから見る8層の内部構造から出てきます。順に読むと、なぜそう作られたかが繋がります。

地図 全体構造

8つの層

下の層の上に上の層が乗っています。この順に読むと、上の層が「そうするしかなかった」ことが分かります。

L0 設計哲学

差分ではなく、スナップショット

問① なぜ Git は「バージョン管理システム」ではなく「コンテンツアドレス指定型のファイルシステム」として設計されたのか?

CVS や SVN はファイルごとの変更差分を記録します。バージョン3を得るには、初版に差分1・差分2・差分3を順に適用する必要があります。

Git は違います。コミットのたびにプロジェクト全体の「その時点の状態」をまるごと保存します。

差分方式(CVS / SVN) 初版 ──(差分1)──(差分2)──(差分3)── 現在 任意の版を得るには、そこまでの差分を全部適用する ブランチを跨ぐ比較は、差分の合成になり複雑 スナップショット方式(Git) [状態A] [状態B] [状態C] [状態D] どの版も単独で完成している 2点間の比較も、ブランチ切替も、単純な集合演算

ここが効いてくるのが多端末開発です。「Windows で作業していた時点の状態」を、Mac で丸ごと復元できる。 差分を積み上げ直すのではなく、その状態が最初から独立して存在しているからです。

問② なぜ内容そのものをハッシュ化してIDにする必要があったのか?

これが次の層の核心です。

L1 オブジェクトモデル

保存されているものは、
4種類しかない

問③ .git/objects の中身は何で、なぜ4種類しかないのか?

種類役割ハッシュの対象
blobファイルの中身そのもの。ファイル名を持たない生データ
treeディレクトリ構造。ファイル名と blob/tree の対応表配下のハッシュ一覧
committree + 親commit + 著者 + メッセージtree のハッシュ+メタ情報
tag特定の commit への署名付き参照(注釈付きタグ)commit のハッシュ+メタ情報

問④ git add した瞬間、内部では何が起きているか?

ファイルの中身から blob <バイト数>\0<内容> という文字列を組み立て、それを SHA-1 でハッシュ化し、 zlib圧縮して .git/objects/xx/yyyy… に置く——という手順のようです。

ファイル名はハッシュに一切含まれません。だから、同じ内容のファイルがどこにあっても同じ blob を指します。 これが重複排除の仕組みだと理解しています。

実験 01 / 本物の git ハッシュを計算する

下の欄に文字を入れると、この場で SHA-1 を計算します。ヘッダ形式も git と同一なので、 手元で printf '内容' | git hash-object --stdin を叩くと同じ値が出ます。

計算中…
計算中…

問⑤ なぜ「内容が変わればIDが変わる」という性質が重要なのか?

ここが Merkle DAG(マークル有向非巡回グラフ) の本質です。 commit のハッシュは親 commit のハッシュを含んで計算されます。 したがって履歴のどこか1バイトでも書き換わると、そこから先の全 commit のハッシュが連鎖的に変わります。

実験 02 / 1文字変えると、どこまで壊れるか

3つの commit が鎖で繋がっています。いちばん古い C1 のメッセージを1文字変えてみてください。 C1 だけでなく、C2・C3 のハッシュも変わります。実際の commit オブジェクト形式で計算しています(著者情報は固定値)。

まだ変更されていません

多端末開発にとって、この性質が意味すること

3台の端末が持つ履歴が同一かどうかは、先頭のハッシュ1つを比べるだけで判定できます。 途中が1バイトでも違えば、先頭のハッシュが違うからです。

「どちらが新しいか分からない」問題が消えるのは、これが理由です。同じか違うかを、内容ではなく構造が保証します。

L2 参照系

ブランチは、1行のテキストファイル

問⑥ ブランチとは実体として何なのか?

40文字のハッシュ値が書かれた、1行のテキストファイルです。それだけです。

.git/refs/heads/main → a3f1c92b8e... (41バイト。改行込み) .git/refs/heads/feature → 7d2e4b01af... .git/HEAD → ref: refs/heads/main (間接参照)

「ブランチを作る」が一瞬で終わる理由がここにあります。木構造をコピーしているのではなく、41バイトのファイルを1つ作っているだけです。 ブランチが100個あっても、増えるのは4キロバイトです。

問⑦ HEAD とは何を指しているのか?

通常 HEAD は ref: refs/heads/main というシンボリック参照——ブランチ名への間接参照です。 commit を直接指している状態が、あの「detached HEAD」です。

そして git commit がやっていることは、HEAD が指すブランチのファイルを、新しい commit のハッシュで書き換える——ただそれだけです。

端末ごとにブランチを分けるのが軽い理由

「Windows での作業は win/ プレフィックスのブランチ」のような運用が現実的なのは、 ブランチの作成コストが実質ゼロだからです。作りすぎて困ることがありません。

並行開発を「同じ場所を同時にいじる」ではなく「別のポインタを進める」に変えられる。ここが設計の効きどころです。

L3 インデックス

なぜ、もう一段あるのか

問⑧ 作業ディレクトリからコミットの間に、なぜ「インデックス(ステージングエリア)」が挟まっているのか?

.git/index はバイナリファイルで、「次のコミットに入る tree の予定形」を保持しています。 実質、まだ確定していない tree オブジェクトです。

作業ディレクトリ ──git add──▶ インデックス ──git commit──▶ commitオブジェクト (自由に散らかる) (次に確定させる分だけ) (確定・不変)

この中間層があることで、2つのことができます。

AIに書かせていると、1回の作業で複数の関心事が混ざることがよくあります。 インデックスがあるおかげで、混ざった変更を、意味の単位に切り分けてからコミットできます。 後で履歴を読む人(三日後の自分を含む)にとって、これは決定的な差になります。

L4 履歴のDAG

並行開発が、構造になる

問⑨ コミットグラフはなぜ木ではなく DAG(有向非巡回グラフ)なのか?

マージコミットは複数の親を持てるからです。各 commit オブジェクトは parent フィールドを1つ以上持ちます。 親が2つ以上あれば、それはマージコミットです(3つ以上も可能で、octopus merge と呼ばれます)。

O 共通祖先 merge-base ABC Mac で進めた3コミット DEF Windows で進めた3コミット M 親が2つ M の parent は C と F の 2つ。木ではなく DAG になる理由

問⑩ 共通祖先探索(merge-base)はどう実装されているか?

commit は DAG 上のノードなので、2つのブランチの分岐点を探す問題は 最下位共通祖先(LCA: Lowest Common Ancestor)問題そのものです。 Git は各 commit の世代番号やコミット日時をヒューリスティックに使い、全探索を避けて効率的に絞り込みます。

「二度作った」が、ここで初めて見える

Mac と Windows で別々に進めた作業は、DAG 上では分岐した2本の枝として明示されます。 どこで分かれ、それぞれ何をしたかが構造として残る。

共有ドライブでは、この分岐は「後から保存した方が勝つ」か「競合コピーが増える」という事故としてしか現れません。 Git では正常な状態として現れ、次の層で機械的に統合できます。

L5 差分・圧縮

論理はスナップショット、
物理は差分

問⑪ 毎回まるごと保存しているのに、なぜリポジトリは差分方式並みに小さいのか?

ここが誤解されやすい核心です。二層構造になっています。

ユーザーから見える層 コミットごとに全体のスナップショット ↕ ディスク上の層 packfile の中で delta 圧縮(バイナリ差分)

git gc が走ると、バラバラのオブジェクト(loose objects)が packfile にまとめられ、 似たオブジェクト同士が差分として格納されます。1文字だけ違う2つのバージョンは、 片方をベースにして「ここが違う」という情報だけを持ちます。

つまりユーザーには毎回全体保存に見えて、実際のディスクでは差分保存されている。 論理モデルの単純さと、物理効率を、両方取っている構造です。

問⑫ pack の中で、どのオブジェクトがベースになるかはどう決まるか?

サイズ・型・作成順の近さをヒューリスティックに使い、似ている可能性が高いもの同士を delta chain に並べます。 厳密な最適解ではなく、実用上十分な近似です。

これが「全端末が完全な複製を持てる」根拠です

各端末が全履歴を持つ、と聞くと重そうに感じますが、実際には数年分の履歴でも数十MBに収まることが普通です。 圧縮が効くのは、コードの変更が本質的に局所的だからです。

そして完全な複製を持てるからこそ、ネットワークが切れていても履歴を辿れる/過去に戻れる/ブランチを切れる。 移動中の端末で作業が止まらない理由がここにあります。

L6 マージ・リベース

3つを見比べて、統合する

問⑬ 3-way merge とは、具体的に何を「3つ」比較しているのか?

共通祖先(merge-base)の tree ← 分かれる前の姿 ② 自分のブランチの tree ③ 相手のブランチの tree 祖先から見て… 片方だけが変更した → 自動でその変更を採用 両方が同じ箇所を変更 → conflict(人間に判断を渡す) 両方が同じ内容に変更 → 問題なし(結果が同じ)

この規則を tree と blob の単位で再帰的に適用します。 ディレクトリ単位で見て、ハッシュが同じ枝は丸ごとスキップできる——ここでも「内容がIDになる」設計が効いています。

問⑭ rebase は内部的に何をしているのか。merge との構造的な違いは?

MERGE — 合流点を作る

   A---B---C  (mac)
  /         \
 o           M
  \         /
   D---E---F  (win)
元のコミットは全て残る。M という親2つのコミットが増える。 分岐した事実が履歴に刻まれる。ハッシュは変わらない。

REBASE — 履歴を書き換える

   o---D---E---F---A'--B'--C'

 A,B,C の差分を取り出し
 F の上に順に貼り直した
 ハッシュは全部別物
直線になる代わりに、元のコミットは消える。 A' は A と内容は同じでも別のコミット。だからpush に --force が要る。

rebase の実体はcherry-pick の連続です。対象コミット群の差分を1つずつ取り出し、新しい親の上に順に適用し直す。 結果として全てのハッシュが変わり、DAG 上の分岐点そのものが移動します。

私は多端末では merge を選んでいます

rebase は履歴が直線になって読みやすい反面、「いつ分岐して、いつ合流したか」という情報を捨てます。

三台で並行開発しているとき、その情報こそが後から効きます。「なぜ二重に作ったのか」を追うとき、私が頼りにしている手がかりだからです。 共有済みのブランチを rebase してはいけない、という原則も、他端末が持つハッシュと食い違うからです。

L7 分散プロトコル

持っていないものだけを送る

問⑮ push / fetch は、実際に何を送受信しているのか?

まずお互いが持っている ref のハッシュを見せ合います。そのうえで、相手が持っていないオブジェクトだけを packfile にまとめて転送します。

ローカル リモート │ ── want a3f1c9… ────────▶ │ これが欲しい │ ◀──── have 7d2e4b… ─────── │ こっちはこれを持っている │ ── have 91bc0f… ────────▶ │ │ │ → 差分が確定 │ ◀═══ packfile(不足分のみ)═ │

これが smart HTTP / SSH プロトコルの have / want ネゴシエーションです。 全部を送るのではなく、会話で必要最小限を確定させてから送る。

問⑯ なぜ pull は「fetch + merge」という合成コマンドなのか?

fetch はリモート追跡ブランチ(refs/remotes/origin/main)を更新するだけの、完全に安全な操作です。 自分の作業には一切触りません。

一方、それを自分のローカルブランチに統合するかどうかは別の意思決定です。merge するのか、rebase するのか、 そもそも今は統合しないのか。Git はこの2つを意図的に分離しています。

多端末なら、fetch と merge を分ける癖をつけると安全です

他端末が何をしたかを先に見てから統合を決められます。

git fetchgit log HEAD..origin/main で「向こうで何が起きたか」を読む → それから統合。 この一手間が、二重開発の発見を早めます。

接続 8層が何を解決するか

冒頭の痛みへ、戻る

最初に挙げた4つの痛みが、どの層の性質で解けるかを対応させます。

多端末開発の痛み効く層その層の性質
スキルが片側にしかないL0 / L7状態を丸ごと再現でき、不足分だけ転送される
同じものを二度作るL4分岐が DAG 上で可視化され、事故ではなく構造として扱える
経緯が消えるL1 / L3コミット単位に意味を切り分けて、理由をメッセージに残せる
どちらが新しいか不明L1先頭のハッシュ1つで、履歴全体の同一性が判定できる
移動中に作業が止まるL5圧縮が効くので、全履歴の完全な複製を各端末が持てる

Git が多端末開発に効くのは、同期機能があるからではありません。 内容をIDにする設計(L1)の上に、DAG(L4)も分散(L7)も乗っているように見えます。 層を下から読むと、その繋がりが少し追えるようになりました。

実践 手を動かす

.git を、直接覗く

この構造は、読むより見たほうが早く腑に落ちました。空のディレクトリで試せます。

# 実験用のリポジトリを作る
mkdir /tmp/gitlab && cd /tmp/gitlab && git init

# L1: ファイルを1つ足して、オブジェクトが増えるのを見る
find .git/objects -type f          # → まだ空
echo hello > a.txt && git add a.txt
find .git/objects -type f          # → 1つ増えた(blob)

# L1: 中身と型を見る
git cat-file -p b6fc4c6            # → hello
git cat-file -t b6fc4c6            # → blob

# L1: ファイル名はハッシュに含まれない、を確かめる
cp a.txt b.txt && git add b.txt
find .git/objects -type f          # → 増えない(同じblobを共有)

# L2: ブランチの実体を見る
git commit -m first
cat .git/refs/heads/main           # → 40文字のハッシュ1行
cat .git/HEAD                      # → ref: refs/heads/main

# L1: commit が tree と親を持つことを見る
git cat-file -p HEAD               # → tree / author / message
git ls-tree HEAD                   # → ファイル名とblobの対応

# L4: 分岐して合流させ、親が2つになるのを見る
git switch -c side && echo x > c.txt && git add . && git commit -m side
git switch main && echo y > d.txt && git add . && git commit -m main2
git merge side --no-edit
git cat-file -p HEAD | head -3     # → parent が2行ある
git log --graph --oneline          # → DAGが図として見える

git add の前後で find .git/objects -type f を比べる——これがいちばん効く実験だと思います。 コマンドの裏で、ファイルが1つ増えているだけだと分かります。

そもそも、なぜ詰まるのか

Gitが効く手前の話——なぜ全体像が見えなくなり、指示が出せなくなるのか分かっていないことが、分からない — 詰まった開発を動かす三つの外部化 に書きました。git はそのうちの1つ(経緯の外部化)にあたります。

私がまだ追えていないところ

ここまで書いておいてなんですが、理解できていない層がいくつも残っています。 せっかくなので、どこで止まっているかを書いておきます。

L5 の delta chain の組み方。「サイズ・型・作成順の近さをヒューリスティックに使う」と書きましたが、 実際にどの順で走査してどう打ち切るのかは、まだ読めていません。
L4 の merge-base の探索。世代番号を使う、という名前を知っているだけで、 どういう場合に速くなるのかを説明できません。
SHA-1 から SHA-256 への移行。仕様があるのは知っていますが、 既存リポジトリが実務でどう移るのかは分かっていません。

それでも、ここまで分かった範囲で多端末の運用は回りはじめました。 全部を理解してから使い始める必要はなかった、というのが正直な実感です。

実践編があります

「では実際にどう繋ぐのか」——GitHub に上げずに、SSH と tailnet だけで2台以上を繋ぐ手順は remote は、GitHubのことではない — 手元とtailnetだけで回すGit に分けて書きました。コマンド生成器つきです。

多端末で始めるなら、まずこの2つ

.claude/skills/ を git 管理下に置く。スキルが片側にしかない問題が、これだけで消えます。

② コミットメッセージに「なぜ」を書く。何をしたかは差分を見れば分かります。 差分から読み取れないのは捨てた案と、その理由だけです。三日後の自分と、別の端末のあなたが読みます。