Windows・Mac・Linux で Claude Code を動かしていると、ある日おかしなことに気づきます。
同じ機能を、別々の端末で、別々のやり方で二度作っていた。
スキルが片方にしかない。なぜそう決めたかの経緯が、その端末のセッションにしか残っていない。
共有ドライブに置いても、これは解けません。解けるのは履歴が構造を持つ道具だけで、それが Git です。
ここでは Git をコマンドの使い方ではなく、内部のデータ構造から8層で読みます。
多端末開発で起きることを、具体的に並べます。
Google Workspace の共有ドライブに置けば、少なくとも「片方にしかない」は解けます。 版履歴もあるので、うっかり上書きしても戻せます。 それでも二度作る問題は残ります。理由は3つあります。
版が残ることと、履歴が構造を持つことは別です。 共有ドライブが持っているのは前者で、多端末開発で要るのは後者です。
Git は「どのファイル群が、どういう順序で、なぜ変わったか」をひとまとまりとして持ちます。 そして各端末がその履歴の完全な複製を持ちます。中央サーバが落ちていても、手元に全部あります。
この性質はどれも、これから見る8層の内部構造から出てきます。順に読むと、なぜそう作られたかが繋がります。
下の層の上に上の層が乗っています。この順に読むと、上の層が「そうするしかなかった」ことが分かります。
問① なぜ Git は「バージョン管理システム」ではなく「コンテンツアドレス指定型のファイルシステム」として設計されたのか?
CVS や SVN はファイルごとの変更差分を記録します。バージョン3を得るには、初版に差分1・差分2・差分3を順に適用する必要があります。
Git は違います。コミットのたびにプロジェクト全体の「その時点の状態」をまるごと保存します。
ここが効いてくるのが多端末開発です。「Windows で作業していた時点の状態」を、Mac で丸ごと復元できる。 差分を積み上げ直すのではなく、その状態が最初から独立して存在しているからです。
問② なぜ内容そのものをハッシュ化してIDにする必要があったのか?
これが次の層の核心です。
問③ .git/objects の中身は何で、なぜ4種類しかないのか?
| 種類 | 役割 | ハッシュの対象 |
|---|---|---|
| blob | ファイルの中身そのもの。ファイル名を持たない | 生データ |
| tree | ディレクトリ構造。ファイル名と blob/tree の対応表 | 配下のハッシュ一覧 |
| commit | tree + 親commit + 著者 + メッセージ | tree のハッシュ+メタ情報 |
| tag | 特定の commit への署名付き参照(注釈付きタグ) | commit のハッシュ+メタ情報 |
問④ git add した瞬間、内部では何が起きているか?
ファイルの中身から blob <バイト数>\0<内容> という文字列を組み立て、それを SHA-1 でハッシュ化し、
zlib圧縮して .git/objects/xx/yyyy… に置く——という手順のようです。
ファイル名はハッシュに一切含まれません。だから、同じ内容のファイルがどこにあっても同じ blob を指します。 これが重複排除の仕組みだと理解しています。
下の欄に文字を入れると、この場で SHA-1 を計算します。ヘッダ形式も git と同一なので、
手元で printf '内容' | git hash-object --stdin を叩くと同じ値が出ます。
問⑤ なぜ「内容が変わればIDが変わる」という性質が重要なのか?
ここが Merkle DAG(マークル有向非巡回グラフ) の本質です。 commit のハッシュは親 commit のハッシュを含んで計算されます。 したがって履歴のどこか1バイトでも書き換わると、そこから先の全 commit のハッシュが連鎖的に変わります。
3つの commit が鎖で繋がっています。いちばん古い C1 のメッセージを1文字変えてみてください。 C1 だけでなく、C2・C3 のハッシュも変わります。実際の commit オブジェクト形式で計算しています(著者情報は固定値)。
3台の端末が持つ履歴が同一かどうかは、先頭のハッシュ1つを比べるだけで判定できます。 途中が1バイトでも違えば、先頭のハッシュが違うからです。
「どちらが新しいか分からない」問題が消えるのは、これが理由です。同じか違うかを、内容ではなく構造が保証します。
問⑥ ブランチとは実体として何なのか?
40文字のハッシュ値が書かれた、1行のテキストファイルです。それだけです。
「ブランチを作る」が一瞬で終わる理由がここにあります。木構造をコピーしているのではなく、41バイトのファイルを1つ作っているだけです。 ブランチが100個あっても、増えるのは4キロバイトです。
問⑦ HEAD とは何を指しているのか?
通常 HEAD は ref: refs/heads/main というシンボリック参照——ブランチ名への間接参照です。
commit を直接指している状態が、あの「detached HEAD」です。
そして git commit がやっていることは、HEAD が指すブランチのファイルを、新しい commit のハッシュで書き換える——ただそれだけです。
「Windows での作業は win/ プレフィックスのブランチ」のような運用が現実的なのは、 ブランチの作成コストが実質ゼロだからです。作りすぎて困ることがありません。
並行開発を「同じ場所を同時にいじる」ではなく「別のポインタを進める」に変えられる。ここが設計の効きどころです。
問⑧ 作業ディレクトリからコミットの間に、なぜ「インデックス(ステージングエリア)」が挟まっているのか?
.git/index はバイナリファイルで、「次のコミットに入る tree の予定形」を保持しています。
実質、まだ確定していない tree オブジェクトです。
この中間層があることで、2つのことができます。
git add -p で、1つのファイルの中の一部の変更だけを次のコミットに入れられるAIに書かせていると、1回の作業で複数の関心事が混ざることがよくあります。 インデックスがあるおかげで、混ざった変更を、意味の単位に切り分けてからコミットできます。 後で履歴を読む人(三日後の自分を含む)にとって、これは決定的な差になります。
問⑨ コミットグラフはなぜ木ではなく DAG(有向非巡回グラフ)なのか?
マージコミットは複数の親を持てるからです。各 commit オブジェクトは parent フィールドを1つ以上持ちます。
親が2つ以上あれば、それはマージコミットです(3つ以上も可能で、octopus merge と呼ばれます)。
問⑩ 共通祖先探索(merge-base)はどう実装されているか?
commit は DAG 上のノードなので、2つのブランチの分岐点を探す問題は 最下位共通祖先(LCA: Lowest Common Ancestor)問題そのものです。 Git は各 commit の世代番号やコミット日時をヒューリスティックに使い、全探索を避けて効率的に絞り込みます。
Mac と Windows で別々に進めた作業は、DAG 上では分岐した2本の枝として明示されます。 どこで分かれ、それぞれ何をしたかが構造として残る。
共有ドライブでは、この分岐は「後から保存した方が勝つ」か「競合コピーが増える」という事故としてしか現れません。 Git では正常な状態として現れ、次の層で機械的に統合できます。
問⑪ 毎回まるごと保存しているのに、なぜリポジトリは差分方式並みに小さいのか?
ここが誤解されやすい核心です。二層構造になっています。
git gc が走ると、バラバラのオブジェクト(loose objects)が packfile にまとめられ、
似たオブジェクト同士が差分として格納されます。1文字だけ違う2つのバージョンは、
片方をベースにして「ここが違う」という情報だけを持ちます。
つまりユーザーには毎回全体保存に見えて、実際のディスクでは差分保存されている。 論理モデルの単純さと、物理効率を、両方取っている構造です。
問⑫ pack の中で、どのオブジェクトがベースになるかはどう決まるか?
サイズ・型・作成順の近さをヒューリスティックに使い、似ている可能性が高いもの同士を delta chain に並べます。 厳密な最適解ではなく、実用上十分な近似です。
各端末が全履歴を持つ、と聞くと重そうに感じますが、実際には数年分の履歴でも数十MBに収まることが普通です。 圧縮が効くのは、コードの変更が本質的に局所的だからです。
そして完全な複製を持てるからこそ、ネットワークが切れていても履歴を辿れる/過去に戻れる/ブランチを切れる。 移動中の端末で作業が止まらない理由がここにあります。
問⑬ 3-way merge とは、具体的に何を「3つ」比較しているのか?
この規則を tree と blob の単位で再帰的に適用します。 ディレクトリ単位で見て、ハッシュが同じ枝は丸ごとスキップできる——ここでも「内容がIDになる」設計が効いています。
問⑭ rebase は内部的に何をしているのか。merge との構造的な違いは?
A---B---C (mac) / \ o M \ / D---E---F (win)
o---D---E---F---A'--B'--C' A,B,C の差分を取り出し F の上に順に貼り直した ハッシュは全部別物
rebase の実体はcherry-pick の連続です。対象コミット群の差分を1つずつ取り出し、新しい親の上に順に適用し直す。 結果として全てのハッシュが変わり、DAG 上の分岐点そのものが移動します。
rebase は履歴が直線になって読みやすい反面、「いつ分岐して、いつ合流したか」という情報を捨てます。
三台で並行開発しているとき、その情報こそが後から効きます。「なぜ二重に作ったのか」を追うとき、私が頼りにしている手がかりだからです。 共有済みのブランチを rebase してはいけない、という原則も、他端末が持つハッシュと食い違うからです。
問⑮ push / fetch は、実際に何を送受信しているのか?
まずお互いが持っている ref のハッシュを見せ合います。そのうえで、相手が持っていないオブジェクトだけを packfile にまとめて転送します。
これが smart HTTP / SSH プロトコルの have / want ネゴシエーションです。 全部を送るのではなく、会話で必要最小限を確定させてから送る。
問⑯ なぜ pull は「fetch + merge」という合成コマンドなのか?
fetch はリモート追跡ブランチ(refs/remotes/origin/main)を更新するだけの、完全に安全な操作です。
自分の作業には一切触りません。
一方、それを自分のローカルブランチに統合するかどうかは別の意思決定です。merge するのか、rebase するのか、 そもそも今は統合しないのか。Git はこの2つを意図的に分離しています。
他端末が何をしたかを先に見てから統合を決められます。
git fetch → git log HEAD..origin/main で「向こうで何が起きたか」を読む → それから統合。
この一手間が、二重開発の発見を早めます。
最初に挙げた4つの痛みが、どの層の性質で解けるかを対応させます。
| 多端末開発の痛み | 効く層 | その層の性質 |
|---|---|---|
| スキルが片側にしかない | L0 / L7 | 状態を丸ごと再現でき、不足分だけ転送される |
| 同じものを二度作る | L4 | 分岐が DAG 上で可視化され、事故ではなく構造として扱える |
| 経緯が消える | L1 / L3 | コミット単位に意味を切り分けて、理由をメッセージに残せる |
| どちらが新しいか不明 | L1 | 先頭のハッシュ1つで、履歴全体の同一性が判定できる |
| 移動中に作業が止まる | L5 | 圧縮が効くので、全履歴の完全な複製を各端末が持てる |
Git が多端末開発に効くのは、同期機能があるからではありません。 内容をIDにする設計(L1)の上に、DAG(L4)も分散(L7)も乗っているように見えます。 層を下から読むと、その繋がりが少し追えるようになりました。
この構造は、読むより見たほうが早く腑に落ちました。空のディレクトリで試せます。
# 実験用のリポジトリを作る 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 に分けて書きました。コマンド生成器つきです。
① .claude/skills/ を git 管理下に置く。スキルが片側にしかない問題が、これだけで消えます。
② コミットメッセージに「なぜ」を書く。何をしたかは差分を見れば分かります。 差分から読み取れないのは捨てた案と、その理由だけです。三日後の自分と、別の端末のあなたが読みます。