Git 最初のコミット e83c516331 を読む
Git は 2005 年 4 月 7 日に初めてコミットされ 2025 年で 20 周年を迎えた。
今では巨大なコードベースになっている(v2.52.0 で約 157 万行)ものの、最初のコミット e83c516331 はたった 1244 行しかない。
ここに Git の本質が詰まっているので、基本的な仕組みを学ぶのにちょうど良いと考えられる。そこで、コード全体を読んで記録を残しておくことにした。
最初のコミットの全体像を掴む
まずはコード全体を見渡し、手元で build して一通り動かした上で詳細な実装を読む。
コミットハッシュを特定する
最初のコミットは最も古いはずなので、以下のコマンドで取り出せる。
git log --reverse --oneline | head -n 1
e83c516331 Initial revision of "git", the information manager from hell
最初は「親がないコミット」でも定まると考えて git log --max-parents=0 したが、なんと 7 つも出てきた。これはよく考えてみれば当然で、例えば cb07fc2a29 のように異なる歴史を統合すると親がないコミットが追加される。親がない最新のコミットは 2009 年の 0ca71b3737。
コード全体を見渡す
ファイルごとの行数を数える。
$ git ls-files -z | xargs -0 wc -l
40 Makefile
168 README
93 cache.h
23 cat-file.c
172 commit-tree.c
51 init-db.c
259 read-cache.c
43 read-tree.c
81 show-diff.c
248 update-cache.c
66 write-tree.c
1244 total
現代の Git と同じく C 言語で書かれている。全体像を掴むために、まずは Makefile と README を読んでみるのが良さそうだ。
Makefile
init-db や update-cache などユーザーが使うコマンドがターゲットになっている。コマンドとファイルはほぼ 1:1 の対応関係にありそうだが、read-cache.o がさまざまなターゲットに登場するのでここに Git object を読む共通処理が書かれているように見える。
make clean で temp_git_file_* が削除されているのは、これが cat-file コマンドによって生成されるファイル名だから。make backup ではルートディレクトリの名前が dir-cache であることを前提に tar している。git ではない理由はおそらく開発者が元々 dir-cache という名前で開発していた名残りで、Git という名前は後から決まったものと考えられる。
README
Git を構成する基本的な概念について詳しく書かれている。
git という名前の由来
- 一般的な UNIX コマンドに存在しない適当な 3 文字の組み合わせ
- ばかげた, 単純な等のスラング
global information trackerの略goddamn idiotic truckload of sh*tの略
git が行う 2 つの抽象化
Git が directory content manager として機能するために、2 つの抽象的な概念を扱う。
object databasecurrent directory cache
The Object Database (SHA1_FILE_DIRECTORY)
環境変数 SHA1_FILE_DIRECTORY のパスに配置される content-addressable な object の集合。
content-addressable とは、object 自身がその content(の SHA-1 ハッシュ)によって名付けられることを指す。全ての object は zlib で圧縮され、圧縮後の SHA-1 ハッシュで識別される。object には以下の 3 種類がある。
blob: バイナリデータの塊で、ファイルの内容を指す objecttree: パーミッション/ファイル・ディレクトリ名/blob または tree ハッシュ のリストで、ディレクトリ構造を指す object。これを再帰的に積み重ねることでリポジトリ全体の構造を示すことができるchangeset: 変更内容、親となる changeset、変更内容に対するコメントを示す object。README では changeset という名前だが実際にはcommitを意味している
全ての object は SHA-1 でハッシュされているので壊れたり改竄されていないことを信頼できる。
ここでいう「信頼」とは内容の完全性だけを示し、本当にその内容が信頼できるかは外部に依存している。つまり、Git のコミットが正しいかはファイルを圧縮して SHA-1 ハッシュし changeset(commit) のハッシュと突合することで検証可能だが、そのコミット自体が信頼できる人物によって作られたものかどうかは保証しない。
Current Directory Cache (“.dircache/index”)
ある時点の仮想ディレクトリ状態を表す単純なバイナリで、コミット前に tree object を生成する際の元データとなる。
名前・日付・パーミッション・内容(Blob)を関連付けた配列を保持し、常に名前順に並び、名前は一意となる。これにより、以下を効率的に行える。
- 再構成: キャッシュ状態(blob や tree)を完全に再生成できる
- 差分検出: 未コミットの tree と実際のディレクトリとの差分を効率的に検出できる
cache は対応する tree の SHA-1 が分かれば復元でき、編集したファイルだけを部分的に更新できる。
build して動かしてみる
簡単なテキストファイルを使って最初の Git によるバージョン管理の流れを確認する。
環境構築
Dockerfile を用意する。
# syntax=docker/dockerfile:1
FROM ubuntu:22.04
ARG GIT_COMMIT=e83c5163316f89bfbde7d9ab23ca2e25604af290
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \
curl \
tar \
bsdextrautils \
libssl-dev \
zlib1g-dev \
diffutils \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /opt/git-e83c516331
RUN curl -L "https://kernel.googlesource.com/pub/scm/git/git/+archive/${GIT_COMMIT}.tar.gz" \
| tar -xz
RUN make \
CFLAGS="-g -std=gnu99 -fcommon" \
LIBS="-lssl -lcrypto -lz"
RUN install -m 0755 \
init-db update-cache write-tree commit-tree read-tree cat-file show-diff \
/usr/local/bin/
WORKDIR /work
CMD ["/bin/bash"]
ビルドしてコンテナに入る。
# Docker image をビルドする
$ docker buildx build --platform linux/amd64 -t git-e83c516331:dev .
# 作業用ディレクトリを作る
$ mkdir -p work
# docker run でコンテナに入る
$ docker run --rm -it --platform linux/amd64 --hostname initial-git -v "$PWD/work":/work git-e83c516331:dev
最初の Git を使ってファイルをバージョン管理する
まずはテスト用にバージョン管理対象となるテキストファイルを作る。
$ echo "Hello,world!" > test.txt
最初は init-db で Git object を格納するデータベースとなるディレクトリを初期化する
$ init-db
defaulting to private storage area
init-db により .dircache/objects/00..ff が作られていることがわかる。
$ ls .dircache/objects/
00 0d 1a 27 34 41 4e 5b 68 75 82 8f 9c a9 b6 c3 d0 dd ea f7
01 0e 1b 28 35 42 4f 5c 69 76 83 90 9d aa b7 c4 d1 de eb f8
02 0f 1c 29 36 43 50 5d 6a 77 84 91 9e ab b8 c5 d2 df ec f9
03 10 1d 2a 37 44 51 5e 6b 78 85 92 9f ac b9 c6 d3 e0 ed fa
04 11 1e 2b 38 45 52 5f 6c 79 86 93 a0 ad ba c7 d4 e1 ee fb
05 12 1f 2c 39 46 53 60 6d 7a 87 94 a1 ae bb c8 d5 e2 ef fc
06 13 20 2d 3a 47 54 61 6e 7b 88 95 a2 af bc c9 d6 e3 f0 fd
07 14 21 2e 3b 48 55 62 6f 7c 89 96 a3 b0 bd ca d7 e4 f1 fe
08 15 22 2f 3c 49 56 63 70 7d 8a 97 a4 b1 be cb d8 e5 f2 ff
09 16 23 30 3d 4a 57 64 71 7e 8b 98 a5 b2 bf cc d9 e6 f3
0a 17 24 31 3e 4b 58 65 72 7f 8c 99 a6 b3 c0 cd da e7 f4
0b 18 25 32 3f 4c 59 66 73 80 8d 9a a7 b4 c1 ce db e8 f5
0c 19 26 33 40 4d 5a 67 74 81 8e 9b a8 b5 c2 cf dc e9 f6
次に、バージョン管理対象のファイルを update-cache で追加する。これは blob object の生成と index 登録を行うので、現代でいう git add に近い。
$ update-cache test.txt
update-cache により Git object の index が作られていることが分かる。個別の Git object は .dircache/objects 以下に追加される。
$ ls -l .dircache/
total 4
-rw------- 1 root root 104 Dec 30 02:25 index
drwx------ 258 root root 8256 Dec 30 02:25 objects
index はバイナリなので hexdump で出力してみる。
$ hexdump -C .dircache/index | head -n 40
00000000 43 52 49 44 01 00 00 00 01 00 00 00 74 ba ba 08 |CRID........t...|
00000010 a7 df 41 b3 a7 16 c1 40 91 97 58 fb cd 57 35 08 |..A....@..X..W5.|
00000020 ed 37 53 69 2c c1 11 09 ed 37 53 69 2c c1 11 09 |.7Si,....7Si,...|
00000030 2b 00 00 00 3f 08 00 00 a4 81 00 00 00 00 00 00 |+...?...........|
00000040 00 00 00 00 0d 00 00 00 87 6b c5 78 8f 4e d7 e4 |.........k.x.N..|
00000050 ac 29 83 3a a8 2a d2 94 6d a7 7c c3 08 00 74 65 |.).:.*..m.|...te|
00000060 73 74 2e 74 78 74 00 00 |st.txt..|
00000068
これを読むには index のデータ構造(後述)を理解する必要があるが 87 6b c5 78 8f 4e ... から始まる 20 bytes が先ほど追加した blob の SHA-1 である。
先頭 1 バイトで fanout することを考えると、実体は .dircache/objects/87/ のディレクトリに入っている。この blob object は zlib で圧縮されているので、そのまま hexdump しても読めない。
$ hexdump -C .dircache/objects/87/6bc5788f4ed7e4ac29833aa82ad2946da77cc3
00000000 78 da 4b ca c9 4f 52 30 34 66 f0 48 cd c9 c9 d7 |x.K..OR04f.H....|
00000010 29 cf 2f ca 49 51 e4 02 00 49 a1 06 97 |)./.IQ...I...|
0000001d
しかし cat-file に Git object の SHA-1 を渡すと、ファイルとして書き出すことができる。
$ cat-file 876bc5788f4ed7e4ac29833aa82ad2946da77cc3
temp_git_file_950z9I: blob
$ cat temp_git_file_950z9I
Hello,world!
その時点の index を元に write-tree で tree object を生成することができる。
$ write-tree
dd6ccb42609c049bc68a40d2a97b31a366831962
生成された tree の SHA-1 を read-tree に渡すと tree を読める。
$ read-tree dd6ccb42609c049bc68a40d2a97b31a366831962
100644 test.txt (876bc5788f4ed7e4ac29833aa82ad2946da77cc3)
commit-tree に tree の SHA-1 を渡すとようやく commit することができる。
$ echo "initial commit\n" | commit-tree dd6ccb42609c049bc68a40d2a97b31a366831962
Committing initial tree dd6ccb42609c049bc68a40d2a97b31a366831962
dda2d9190efec7068da2bd7cd124f8f553f7d482
commit も Git object なので cat-file でファイルに出力できる。
$ cat-file dda2d9190efec7068da2bd7cd124f8f553f7d482
temp_git_file_3n7qJS: commit
$ cat temp_git_file_3n7qJS
tree dd6ccb42609c049bc68a40d2a97b31a366831962
author root <root@initial-git> Tue Dec 30 02:29:00 2025
committer root <root@initial-git> Tue Dec 30 02:29:00 2025
initial commit\n
ここで差分の管理を試すために test.txt を書き換えてみる。
$ echo "hogehoge" > test.txt
show-diff で現在の作業ディレクトリのファイルと .dircache/index との差分を表示することができる。現代の Git でいうと git diff の引数なし挙動に近い。
$ show-diff
test.txt: 876bc5788f4ed7e4ac29833aa82ad2946da77cc3
--- - 2025-12-30 02:38:59.670802971 +0000
+++ test.txt 2025-12-30 02:38:58.392672326 +0000
@@ -1 +1 @@
-Hello,world!
+hogehoge
先ほどと同じように update-cache して write-tree で得た SHA-1 を commit-tree に渡すことでコミットできる。このとき、前のコミットに繋げたいときは -p オプションで親コミットを直接指定しなければならない。指定しない場合、別の root コミットが生成される。
$ write-tree
e0dc3bcc02fd47bd3e3473fd7837c4ac004ed3a0
$ echo "second commit\n" | commit-tree e0dc3bcc02fd47bd3e3473fd7837c4ac004ed3a0 -p dda2d9190efec7068da2bd7cd124f8f553f7d482
33dcc9880a7aa3da98e833a2ca7376472a02b19e
$ cat-file 33dcc9880a7aa3da98e833a2ca7376472a02b19e
temp_git_file_cEwQj4: commit
$ cat temp_git_file_cEwQj4
tree e0dc3bcc02fd47bd3e3473fd7837c4ac004ed3a0
parent dda2d9190efec7068da2bd7cd124f8f553f7d482
author root <root@initial-git> Tue Dec 30 02:44:11 2025
committer root <root@initial-git> Tue Dec 30 02:44:11 2025
second commit\n
これでようやく 2 つの commit をつなげることができた。
この 2 つの commit の差分を見たいときはどうすれば良いだろうか?show-diff コマンドは前述の通り現在の作業ディレクトリと index を比較するコマンドなので commit 間の差分を出すことはできない。
思い出してみると commit は tree への参照、tree は blob への参照を持っていた。つまり、2 つの commit object の tree を read-tree で出して blob の SHA-1 が変わっているものを拾い上げ、cat-file で blob の SHA-1 を指定して通常の diff コマンド等で差分を見ることができる。
まずは read-tree で 2 つの commit が指し示す tree の SHA-1 から blob の SHA-1 を引き出す。
$ read-tree e0dc3bcc02fd47bd3e3473fd7837c4ac004ed3a0
100644 test.txt (7000b81673e954b5c9ec92f5d3ccea84866bf16e)
$ read-tree dd6ccb42609c049bc68a40d2a97b31a366831962
100644 test.txt (876bc5788f4ed7e4ac29833aa82ad2946da77cc3)
blob の SHA-1 で cat-file して diff で比較する。
$ cat-file 7000b81673e954b5c9ec92f5d3ccea84866bf16e
temp_git_file_nqMogt: blob
$ cat-file 876bc5788f4ed7e4ac29833aa82ad2946da77cc3
temp_git_file_QQ6qKF: blob
$ diff temp_git_file_nqMogt temp_git_file_QQ6qKF
1c1
< hogehoge
---
> Hello,world!
これで 2 つの commit 間の差分を出すことができた。現代の Git なら git diff 一発で出せるのに、最初の Git ではこれだけの手間がかかる。
最初の Git を使った開発まとめ
最初のコミット時点の git の基本的なワークフローと用語を以下にまとめる。
基本的なワークフロー
- 作業ディレクトリで
init-dbを実行し object database を初期化する - 作業ディレクトリ配下にファイルを追加したり変更する
update-cacheで作業ディレクトリから blob object と現在のディレクトリキャッシュ.dircache/indexを生成するwrite-treeで.dircache/indexから tree object を生成するcommit-treeで tree object から commit object を生成する
commit を繋げる際は 5. で親 commit object の SHA-1 を -p オプションで指定する。
Git object とは
git が扱うデータは主に blob, tree, commit の 3 種類がある。
blob: git が管理するファイル本体を zlib 圧縮したもの(ファイル名や権限は含まない)tree: ディレクトリ構造やファイル名、モード、blob/tree への参照commit: tree への参照に author, 時刻, メッセージを付加した履歴データ
差分や Git object の内容確認
cat-fileは git object を一時ファイルに出力し、ファイル名と object 種別を標準出力するshow-diffは.dircache/indexと現在の作業ディレクトリを比較し差分を標準出力するread-treeは tree object の内容を標準出力する
ソースコードを読む
詳細な仕様を知りたいので、ファイルごとに実装を読んでいく。
init-db
Git objects を保存するデータベースを初期化する。
source: init-db.c
.dircache/というディレクトリを作る- 今でいう
.git/に該当する - この時点の Git では refs や HEAD, logs などは存在せず index と objects を格納するためだけのディレクトリ
- 既に
.dircache/がある場合は失敗する
- 今でいう
- L13-L26: コメントを読む限り環境変数
SHA1_FILE_DIRECTORYで共有ディレクトリを指定することで git objects を共有できそうに見える- ここでは既にディレクトリが存在することを想定しており新たにディレクトリは作らない
- …という意図がありそうだが、実際にはバグっており環境変数
SHA1_FILE_DIRECTORYを指定していると常に bad directory のメッセージが標準エラー出力され、デフォルト挙動にフォールバックする- なぜなら、
if (!stat(sha1_dir, &st) < 0 &&の箇所で stat が成功するとif (1 < 0 && ...), 失敗するとif (0 < 0 && ...となり、if 文の条件を満たさないから - ここは
if (stat(sha1_dir, &st) == 0 &&が開発者の意図に沿っていそう
- なぜなら、
- L27-L49: デフォルト挙動では
DEFAULT_DB_ENVIRONMENTが示すパス.dircache/objects以下に git objects を格納するためのディレクトリを 256 個00~ff作る- git objects の SHA-1 ハッシュの先頭 2 桁によって格納先が
00~ffに分かれ、一つのディレクトリに集中するのを防ぐ- 例えば
e83c516331...という object は.dircache/objects/e8/ディレクトリに格納される - ファイル名は SHA-1 の残り部分で
3c516331...となる
- 例えば
- git objects の SHA-1 ハッシュの先頭 2 桁によって格納先が
init-db で使われる環境変数は cache.h でマクロとして定義されている。
#define DB_ENVIRONMENT "SHA1_FILE_DIRECTORY"
#define DEFAULT_DB_ENVIRONMENT ".dircache/objects"
mkdir や stat 等で POSIX に依存しているので Windows では動かなさそうだ。しかし、この時点では Linux カーネルの開発者のみが使う想定だったから特に問題はないと思われる。
update-cache
バージョン管理対象となるファイルパスを受け取って blob object の生成と index への登録・更新・削除を行う。
source: update-cache.c
- L222-L227: read_cache で index の内部整合性を検証する
- verify_hdr を使って index の cache_header を検証している
- index は固定長 cache_header と可変長 cache_entry で構成されるが、entry の内容はここでは検証していない
- L228-L232: まず
.dircache/indexが存在していることを確かめ、ロック用の一時ファイル.dircache/index.lockを作成する- 既に存在する場合はエラーになる
- そのため、複数のプロセスから一時ファイルが作られることはない
- L233-L243: コマンドライン引数で与えられたパスを検証する
- パスは複数同時に渡すことができる
- ドット(.)から始まるファイルは全て ignore される
- add_file_to_cache でメモリ上に cache_entry を書き込み index_fd でパスを元にファイルを圧縮して SHA-1 を計算し blob object を生成する
- L244-L255: ファイルをキャッシュに追加する
active_nrはグローバル変数で cache_entry のエントリ数active_cacheもグローバル変数で cache_entry を示すポインタ配列- write_cache で以下の処理を行う
- cache_header と cache_entry から cache_header の sha1 フィールドに書き込むための SHA-1 を計算する
- キャッシュをロック用の一時ファイルに書き込み
.dircache/indexに rename して終了
read_cache は cache header しか検証していない。後述するが git の index は 2 つの構造体 cache_header と cache_entry で構成される。entry は検証しなくていいのだろうか?
実は cache_header の sha1 フィールドには「header の sha1 フィールドの手前まで + 全ての cache_entry」を SHA-1 でハッシュした値が入る。これが一致していることを以て cache_entry が壊れていないと言える。
あくまで index の cache_header がデータ構造に沿っているか、および cache_header と cache_entry 間の整合性を検証するだけで index と作業ディレクトリの整合性をチェックするわけではないが、シンプルかつ強力な仕組みだと思った。
index のデータ構造
.dircache/index のデータ構造は cache.h で定義されており、固定長 cache_header と 可変長 cache_entry の組み合わせで表現されている。
cache_entry にはファイル名が含まれているので 1 件ごとにサイズが異なり、各エントリの境界は 8 bytes 単位で揃う。
+------------------------------+
| struct cache_header | 固定長 32 bytes
+------------------------------+
| struct cache_entry #0 | 可変長
+------------------------------+
| struct cache_entry #1 | 可変長
+------------------------------+
| ... |
+------------------------------+
| struct cache_entry #(n-1) | 可変長
+------------------------------+
cache_header は signature “DIRC”, version, entries でそれぞれ 4 bytes + sha1 の 20 bytes で合計 3 * 4 + 20 = 32 bytes となっている。ヘッダの sha1 は「ヘッダの sha1 フィールド手前 + 全ての cache_entry」を SHA‑1 でハッシュしたもので、整合性チェックに使われる。
struct cache_header {
unsigned int signature;
unsigned int version;
unsigned int entries;
unsigned char sha1[20];
};
cache_entry は stat(2) で取得できるファイル情報や blob の SHA-1 を格納する。
ファイル名 name は namelen で長さを持つ。エントリの並び順はパス名の昇順。
コードコメントにもある通り CPU ネイティブのバイトオーダーで保存されるため、ポータブルではない。
struct cache_time {
unsigned int sec;
unsigned int nsec;
};
struct cache_entry {
struct cache_time ctime;
struct cache_time mtime;
unsigned int st_dev;
unsigned int st_ino;
unsigned int st_mode;
unsigned int st_uid;
unsigned int st_gid;
unsigned int st_size;
unsigned char sha1[20];
unsigned short namelen;
unsigned char name[0];
};
cache_header が 32 bytes で最初の cache_entry の sha1 フィールド直前までが 40 bytes ある。つまり、最初の sha1 は 72 bytes の次から始まるという計算になる。先ほど “Hello,world!” と書かれた test.txt だけを index に書き込んで hexdump したが、これを見返すと最初の blob object の SHA-1 876bc5788f4ed7e4ac29833aa82ad2946da77cc3 が 73 番目の byte から続いていることが見て取れる。
その後の namelen は test.txt で 8 文字になるので 08 00 で name は 74 65 73 74 2e 74 78 74 であることが確認できる。末尾は 8 bytes の境界に丸められるので 00 で埋められている。
$ hexdump -C .dircache/index | head -n 40
00000000 43 52 49 44 01 00 00 00 01 00 00 00 74 ba ba 08 |CRID........t...|
00000010 a7 df 41 b3 a7 16 c1 40 91 97 58 fb cd 57 35 08 |..A....@..X..W5.|
00000020 ed 37 53 69 2c c1 11 09 ed 37 53 69 2c c1 11 09 |.7Si,....7Si,...|
00000030 2b 00 00 00 3f 08 00 00 a4 81 00 00 00 00 00 00 |+...?...........|
00000040 00 00 00 00 0d 00 00 00 87 6b c5 78 8f 4e d7 e4 |.........k.x.N..|
00000050 ac 29 83 3a a8 2a d2 94 6d a7 7c c3 08 00 74 65 |.).:.*..m.|...te|
00000060 73 74 2e 74 78 74 00 00 |st.txt..|
00000068
read-cache.c
read-cache という名前だが .dircache/index を読むだけでなく Git object のデータベース .dircache/objects/ を読み書きするための共通ユーティリティ関数を集めたファイル。
source: read-cache.c
- L24-L36
get_sha1_hex: 40 桁の 16 進文字列を 20 bytes の SHA‑1 バイナリに変換する- 1 byte が 2 文字で表されているので、2 文字ずつポインタを移動しながら 20 回のループを実行する
val = (hexval(hex[0]) << 4) | hexval(hex[1]);で上位 4 bit と下位 4 bit に分けて 1 byte 値(0x00 ~ 0xff)を作る
- L37-L51
sha1_to_hex: 20 bytes の SHA‑1 バイナリを 40 桁の 16 進文字列に変換するget_sha1_hexの逆の処理を行う- 1 byte ずつ
val >> 4で上位 4 bit,val & 0xfで下位 4 bit を取り出す - 文字列を書き込むバッファは 50 文字分確保されているが、実際には 40 文字しか書き込まれない
- L52-L81
sha1_file_name: SHA‑1 から Git object ファイルのパス(xx/xxxx…)を生成する- デフォルトでは
.dircache/objects/以下に00 ~ ffのディレクトリが作られている - SHA-1 の先頭 2 文字と残り 38 文字を分けて、ファイルパスを組み立てる
- 例えば
33dcc9880a7aa3da98e833a2ca7376472a02b19eなら.dircache/objects/33/dcc9880a7aa3da98e833a2ca7376472a02b19e base = malloc(len + 60);で./dircache/objectsの後に 60 bytes のメモリを確保しているが、実際に必要な長さは/<2 bytes>/<38 bytes><NULL>で 43 bytes
- 例えば
- init-db した後に環境変数
DB_ENVIRONMENTを変更しているとディレクトリが存在しないので失敗する
- デフォルトでは
- L82-L132
read_sha1_file: SHA-1 を受け取って buffer に書き込んで返すsha1_file_nameに SHA-1 を渡してファイルパスを取得する- ファイルから zlib 圧縮されたバイト列を読み込んで mmap でメモリに乗せる
- inflate (zlib 展開) して、展開後のデータの先頭にあるヘッダ(type, size)を除いた内容部分をバッファに読み込んで返す
- Git object の先頭には object の種別(blob, tree, commit)と展開後のファイルサイズが書き込まれている
- 先頭部分を除外することでファイルの中身だけをバッファに読み込める
- L133-L167
write_sha1_file: データを圧縮し SHA-1 を計算して object として保存するread_sha1_fileの逆の処理を行う- ファイルを deflate (zlib 圧縮) し、SHA-1 を計算して
write_sha1_bufferでファイルに書き込む
- L168-L180
write_sha1_buffer: 圧縮済みデータを object として保存する- SHA-1 から
sha1_file_nameでファイル名を取得してファイルディスクリプタを開くO_WRONLY(書き込み専用) | O_CREAT(なければ作る) | O_EXCL(すでに同名ファイルが存在したら失敗)- 基本的には SHA-1 が衝突しない限り同じファイルは存在しないはずなので、存在していたら書き込み不要として問題ない
- L175 で return 0 している
- バイト列をファイルに書き込む
- SHA-1 から
- L205-L258
read_cache: index ファイルを読んでグローバル変数に書き込む関数で、.dircache/indexのデータをコマンド内で取り扱いたいときに呼び出す
read-tree
引数として tree object の SHA-1 を受け取り tree にあるファイル名やモードを標準出力する。
source: read-tree.c
- L9-L13: SHA-1 でファイルを読み、object 種別が tree であることを確かめる
- L14-L24: tree object を 1 byte ずつ解析してフォーマットを組み立てて標準出力する
- tree object は
<mode> <path><NUL(\0)><SHA-1>の繰り返しで表現されている- SHA-1 部分は 20 bytes のバイナリ表現
- tree object は
- 引数として与えられた SHA-1 が示す tree object の直下だけを取得し、再帰的な取得は行わない
write-tree
ディレクトリのキャッシュ .dircache/index から tree object を作成する。
source: write-tree.c
- L30, L33-L37:
read_cacheで.dircache/indexを読みこむ - L38-L42: 適当なサイズのバッファを準備する
- tree object は
<mode> <path><NUL(\0)><SHA-1>でパスが可変なので、長いファイル名が多いとバッファが不足する可能性がある- SHA-1 部分は 20 bytes のバイナリ表現
- L47-L49: 不足する場合は realloc で追加確保する
- tree object は
- L44-L46: cache_entry が指す Git object が存在することを確認する
- L51-L55: cache_entry から mode と name と SHA-1 を取り出し buffer に書き込みながら offset を進めていく
- L57-L63: 先頭に
tree <size><NUL(\0)>を書き込み、buffer/offset をヘッダ開始位置に合わせて調整する - L64:
write_sha1_fileでメモリ上のバイト列を zlib 圧縮してファイルに書き込み tree object の SHA-1 を標準出力する
commit-tree
tree object と環境変数やシステムのユーザー情報から commit object を生成し object database に保存する。
source: commit-tree.c
コミットメッセージは以下のように標準入力から受け取る必要がある。親コミットは最大 16 個まで指定できる(L93-L102)。
$ echo "test commit message" | commit-tree <tree SHA-1> -p <parent-commit SHA-1> ...
- L118-L129: コマンドライン引数を解析して tree object と親 commit を取得する
- L130-L148: 環境変数やシステムのユーザー情報から commit に記載するデータを取り出す
- L149-L169: init_buffer で最初のバッファを確保し、add_buffer で commit object を構成するデータを書き込みながらバッファを追加で確保し、finish_buffer で先頭に object 種別(commit)と object のサイズを書き込む。
- L170:
write_sha1_fileでメモリ上のバイト列を zlib 圧縮してファイルに書き込み commit object の SHA-1 を標準出力する
実際の commit object の例
tree dd6ccb42609c049bc68a40d2a97b31a366831962
author root <root@initial-git> Tue Dec 30 02:29:00 2025
committer root <root@initial-git> Tue Dec 30 02:29:00 2025
initial commit\n
show-diff
index に登録されているファイルと現在の作業ディレクトリのファイルとの差分を表示する。
source: show-diff.c
- L47:
read_cacheで.dircache/indexを読み込む - L63-L66: 作業ツリー上の同名ファイルを stat する
- L10-L32:
match_statで index に保存されている cache_entry のメタデータと stat で得られた結果を比較して変化がありそうかを判定- L68-L76: 変化があれば、index が指す blob を
read_sha1_fileで取り出す
- L68-L76: 変化があれば、index が指す blob を
- L33-L44:
diff -uで差分を表示するdiffコマンドがない環境では動かないのと、ファイル名に特殊文字があると失敗しそう
cat-file
Git object の SHA-1 を引数として受け取り、内容を一時ファイルに書き込む。現代の git cat-file はファイルに書き込むのではなく標準出力する。
source: cat-file.c
- git object の SHA-1 を受け取り、その内容を
temp_git_file_XXXXXXという名前のファイルに書き出す- L17: mkstemp 関数にファイル名のテンプレートを渡している
XXXXXX部分はランダムなので、同じ Git object であってもcat-fileする度にファイル名が変わる
- 一時ファイルの生成と書き込みに成功すると、ファイル名と object 種別(blob, tree, commit)を標準出力する
現在の Git と比較する
20 年が経った現代の Git とは大きな差があるが、特に印象的だったのは以下の点だ。
branch が無い
現代の Git における branch は refs/heads/* に置かれる参照(ref)で、通常は commit を指す。基本的にこの参照先が commit 時の親になる。
2005 年の最初のコミットではこのような機構は存在せず、update-cache で index にファイルを追加して write-tree から commit-tree -p <親コミットの SHA-1> する必要がある。
git branch や git switch のような便利コマンドも無いので、どの commit を HEAD として扱うかを自分で管理する必要がある。例えばどこかにメモするか、参照として保存するなどひと手間がかかる。ちなみにこの時点では merge もまだ無かった。
remote が無い
最初の Git には remote がなく、当然 push も pull も無い。
しかし、最初のコミットから 10 日後には公開リポジトリから pull して merge する今では一般的な流れができるようになっていた。メーリングリストのアーカイブを見る限りでは、他の人と作業を共有する場合はそれぞれが git リポジトリを公開しておき、rsync で Git object や HEAD を取得してローカルでマージコミットを作成するといったワークフローをシェルで行っていた。
https://lore.kernel.org/git/Pine.LNX.4.58.0504171455070.7211@ppc970.osdl.org/
log が無い
最初の Git には log がないので parent を辿って過去のコミット一覧を表示するのにひと工夫が必要になる。
その後数週間で rev-list が追加された。rev-list で Git object の SHA-1 配列を作ることができるので、それを比べるようなシェルスクリプトで現代の git log のようなものを作っていたようだ。
Plumbing と Porcelain
Git にはコマンドを分類する Plumbing(配管)と Porcelain(磁器) という概念がある。前者は Git 内部のデータ視点で操作する単機能のコマンドで、後者はそれを組み合わせてユーザー視点で操作する高機能なコマンドを意味している。
最初期のコマンドは全て plumbing なので Git の内部動作を理解していなければ使うことが難しい。一方で、基本的な仕組みさえわかれば入出力をパイプで繋ぎ合わせて自分用のツールを簡単に作ることができる。初期の Git では object の SHA-1 やそこから引き出せるファイルを各自がシェルスクリプトで処理していて、徐々にパフォーマンス向上のために C で書き直したりユーザー向けの porcelain を作るといった流れでコマンドが増えていった。
本質的に Git は「ファイルの内容でアドレスが決まるファイルシステム」で、その上にさまざまなインターフェイスを被せてバージョン管理システムとして使われている。
いつ圧縮するか
初期 Git はこれまで見た通り object を示すバイト列を圧縮してから SHA-1 を計算している一方、現代の Git では type, size, content(非圧縮)で SHA-1 を計算してから content を保存時に圧縮する。
zlib のバージョンや実装の差によって SHA-1 が変わるなど不便な点が多いので、確かに圧縮後に SHA-1 を計算するより非圧縮で SHA-1 を計算してから圧縮した方がいいという背景があったようだ。
読んだ感想
全体で 1244 行しかなく単純な実装なので、それほど時間をかけずに読むことができた。今では当たり前に使われている branch も remote も log も存在しない「原始時代」の Git を体験できて楽しかった。本質的な仕組みは理解できたので、次は自分自身で再実装したい。
Git の作者 Linus Torvalds といえば Linux を作った有名人で、私からすると雲の上の存在だ。一方で、原初の Git はエラー処理が適当だったり、単にバグってたり、エッジケースを無視したり、メモリ確保した後に解放してなかったり、詳細な部分が雑に作られているという印象を受けた。とても失礼な表現かもしれないが、すごく人間味を感じた。
しかし、この Git 最初のコミットはカーネルのメーリングリスト(後述)を見る限り長く見積もっても 30 時間ほどで実装されている。それ以前からバージョン管理システムを使いその課題について考えていたとはいえ、もの凄いスピード感だ。
Git を構成する概念は非常に単純で、圧縮したファイルから計算した SHA-1 をキーとする object database と作業ディレクトリのキャッシュ (index) のみでほぼ全ての実装を説明できる。最初のコミットは 1244 行あるが、object database のディレクトリ構造と index のデータ構造が分かっていればあとは単純なファイルやメモリを読み書きする繰り返しに過ぎない。
これらの核となる概念は現代の Git にも受け継がれており、基礎的な内部構造は大きく変わっていない。長く使われる優れたソフトウェアを作るエンジニアはプログラミングが上手くて速いだけではなく、問題を解決するために核となるアイデアを考えるのが上手いのだと感じた。
小ネタ
Git について調べる中で、昔の話がわかる記事や動画を見つけたのでメモ。
GitLab - 20 年にわたる Git の歴史をたどる
Git の 20 年の歴史をざっくり見る記事。
GitLab - Celebrating Git’s 20th anniversary with creator Linus Torvalds
GitLab - Celebrating Git’s 20th anniversary with creator Linus Torvalds
GitLab 社による作者 Linus Torvalds へのインタビュー記事。
- Linux カーネルを開発する上で、Git を作るまでの数年にわたりバージョン管理システムを使っていた
- 当時使っていた幾つかのバージョン管理システムが使いづらかったり、ライセンス上の問題で揉めていた
- OSS コミュニティから良いものが出てこなかったので自分で作った
- 最初のバージョンは数日でできたが、コードを書くのは簡単だったし実装にかかった時間はさほど重要じゃない。重要なのは、それまでに問題について考えていたから核となる設計ができたということ
- 主な目標は、分散型で高性能であること、そしてどんな破損もキャッチできる絶対的に信頼できるものであること
- バージョン管理システム自体に興味はなく、カーネル開発のために必要だからやっていた
- Git が(その 12 日後にリリースされた)Mercurial より人気が出たのは、カーネルや Ruby on Rails 等が使っていたことによるネットワーク効果によるものと考えている
- (今フルタイムで Git に取り組めるとしたら実装したいことはあるか?という質問に対して)何もない。自分にとって Git の使い方は限定的で、かなり早い段階で自分に必要なものを実現してくれたし、本当に大切にしているのはカーネルだけ
- (Git の設計上の決定で後悔していることは?という質問に対して)細部については別のやり方をしていたかもしれないが、大まかなデザインには満足している。設計の原動力となるコンセプト(Git でいえば object database、Unix でいえば「全てはファイルである」)を導くことが重要。コードの 99% はその概念を現実世界で使えるようにするために構築する醜い細部
- (カーネルのように Rust を Git の一部で使い始めることに意味があるか?という質問に対して)Git のコアとなったアイデアは単純なので一から実装するのが合理的だと思う。そして、Rust の利点が明らかだとは思わない。ネットワーク効果があるから Git を置き換えるには少し優れているのではなく非常に優れていなければならない
Two decades of Git: A conversation with creator Linus Torvalds
GitHub 公式チャンネルが Git 20 周年記念で投稿した動画。Shorts で切り抜きがたくさんアップロードされている。
書き起こしはこちら。
https://github.blog/open-source/git/git-turns-20-a-qa-with-linus-torvalds/
一週間で実装した話
Git は 1 週間で作られたという伝説がある。
作者が「BitKeeper に代わるバージョン管理システムを見つける」という話をしたメールのアーカイブはこちら。
https://lore.kernel.org/all/Pine.LNX.4.58.0504060800280.2215@ppc970.osdl.org/
メールのヘッダーを見ると日付は Wed, 6 Apr 2005 08:42:08 -0700 (PDT) とある。Git 最初のコミットは Thu Apr 7 15:13:13 2005 -0700 に行われたので、およそ 30 時間で作られた計算になる。
作者によると、カーネルに使えるようになるまでには 10 日ほどかかったそうだ。カーネルが完全に Git で管理されるようになったのは Linux v2.6.12-rc3 からだった。
https://lore.kernel.org/all/Pine.LNX.4.58.0504201728110.2344@ppc970.osdl.org/
Git の実装は上記のように短時間で行われていたが、作者曰く、その 4 ヶ月ほど前から前述の BitKeeper 問題があって解決策についてずっと考えていたらしい。
作者がよく使う git コマンド
よく使うコマンドは merge, blame, log, commit, pull の 5 つ。この手の話題で push が入らない人は珍しい。
Tech Talk: Linus Torvalds on git
Tech Talk: Linus Torvalds on git
リリースから 2 年後の 2007 年に作者が Google で Git について喋っている動画(限定公開)。
内容は分散バージョン管理の価値についてで Git の使い方や実装については一切説明していない。本人によると、使い方は Google で検索してマニュアルを読めばいいし実装はシンプルだからとのこと。 Git が一般に普及したきっかけは Ruby on Rails の開発で使われていたことが大きく、2007 年時点では今のように広くは使われていなかったようだ。
当時の Linux カーネルの開発では 22,000 のファイルがある。merge は一日あたり 4 ~ 5 件あって差分のダウンロードを除けば 1 秒以内に完了する。開発者は地理的に分散している。これだけパフォーマンスと信頼を兼ね備えた分散バージョン管理システムがあるのに何故 CVS や SVN を使うんだ?という話をしている。
Git が当たり前になった現在では目新しい内容ではないが、当時の雰囲気を感じることができる貴重な映像といえそう。