詩と創作・思索のひろば

ドキドキギュンギュンダイアリーです!!!

Fork me on GitHub

git fetch の裏側では何が起こっているか

git fetch の裏側でどんな通信が行われてリモートリポジトリの内容が取得できるのか調べたのでまとめる。もともとは git の HTTP や SSH といったプロトコルでどのように実現されているか、というところに興味があった。Git v2.7.1 を基にしている。

事前準備

手を動かしてプロトコルを理解できるよう、gist の小さなリポジトリ を使う。適当なディレクトリ下に bare リポジトリとして clone しておく。

% cd ~/tmp # お好きな場所で
% git clone --bare https://gist.github.com/5cfc0b016df3dc4683ef.git
Cloning into bare repository '5cfc0b016df3dc4683ef.git'...
remote: Counting objects: 9, done.
remote: Compressing objects: 100% (3/3), done.
remote: Total 9 (delta 0), reused 0 (delta 0), pack-reused 0
Unpacking objects: 100% (9/9), done.
Checking connectivity... done.
% ls 5cfc0b016df3dc4683ef.git/
HEAD  config  description  hooks/  info/  objects/  packed-refs  refs/

また、以下で「適当な空のリポジトリ」が必要な場合は以下のようにして作ったものを利用することとする。

% cd ~/tmp
% rm -rf empty-repo
% git init --bare empty-repo.git

pack プロトコル

クライアントが git fetch(とか、それを引き起こす git pull)を実行すると、指定されたリモート(省略した場合は "origin")のサーバと以下の流れに沿ってデータのやりとりを行う。

  1. Reference discovery: リモートリポジトリの ref の一覧をサーバがクライアントに伝える。
  2. Packfile negotiation: 要求する object などの情報をクライアントがサーバに伝える。また、すでにローカルに存在する object をクライアントがサーバに伝え、サーバはそれを元にクライアントの要求に応えるのに必要十分な内容を選択する。
  3. Packfile の送受信: object の列を圧縮した packfile をサーバが送信する。クライアントは受信した packfile をローカルリポジトリに展開する。

これが行われる際、クライアント/サーバそれぞれで git のサブコマンドが実行される。クライアント側では git fetch または git fetch-pack、サーバ側は git upload-pack である。

以下、この流れにそって詳しく見ていく。

pkt-line フォーマット

まずはこのプロトコルで使用されるデータフォーマットについて。送受信されるデータは 4 バイトの 16 進数文字列表現による長さと、それに続くペイロードで一つの塊としてエンコードされる。長さはそれ自身の 4 バイトぶんを含む。このフォーマットを pkt-line と呼ぶ。

具体例をみるのが分かりやすい(Documentation Common to Pack and Http Protocols より):

  pkt-line          actual value
  ---------------------------------
  "0006a\n"         "a\n"
  "0005a"           "a"
  "000bfoobar\n"    "foobar\n"
  "0004"            ""

長さ部分が "0000"(でペイロードが長さ 0)であるものは特に flush-pkt と呼ばれ、要求や応答の終わりを示すのに使われる特殊な pkt-line である。

Reference discovery

サーバは、クライアントにリモートリポジトリの ref(ブランチとタグ)と、それが指す object name(SHA-1)の一覧を伝える。

手元では、git upload-pack --advertise-refs --stateless-rpc <dir> でサーバ側の応答を生成できる。

% git upload-pack --stateless-rpc --advertise-refs 5cfc0b016df3dc4683ef.git/
00d10ab1a827b3193d55b023c1051c6d00bb45057e46 HEAD\0multi_ack thin-pack side-band side-band-64k ofs-delta shallow no-progress include-tag multi_ack_detailed no-done symref=HEAD:refs/heads/master agent=git/2.7.1 # "\0" は NUL
003f0ab1a827b3193d55b023c1051c6d00bb45057e46 refs/heads/master
0000 # 終端の改行なし

"SHA1 ref" という内容で、ref とそれが現在指す object の pkt-line が並ぶ。最初の行の、NUL を挟んだ残りの部分に含まれているのは capabilities と呼ばれ、サーバおよびクライアントがサポートしている機能の一覧や、このセッションに関するメタ情報を含んでいる。例えば

  • 次節の packfile negotiation の拡張プロトコルに対応しているかどうか
  • packfile (後述)の拡張フォーマットに対応しているかどうか

など。いろいろあるんだけどひとつひとつ取りあげることはしない。詳しくは Git Protocol Capabilities に。

Packfile negotiation

サーバから ref の一覧を受け取って、クライアントがどの object を要求するかを決める。必要な ref の指している先がすでにローカルに存在していることがわかった場合や、git ls-remote の中ではこれ以降のステップは実行されない。

手元では、git upload-pack --stateless-rpc <dir> でサーバ側の応答を生成できる。クライアントからの要求は標準入力に渡す。

以下は非常に単純な例。うまくいくと標準出力にバイナリデータが出力さる(ターミナルが壊れることもある)。

{ echo '003ewant 0ab1a827b3193d55b023c1051c6d00bb45057e46 no-progress'; \
  echo -n  '0000'; \
  echo '0032have 136802d3c5782043066e192863c45c421b88f0a8'; \
  echo '0009done'; \
} | git upload-pack --stateless-rpc 5cfc0b016df3dc4683ef.git/

この例では、

  • want f04e005b13815c4455474c5550f069f6776eff5e では特定の object をリモートから受け取りたいこと、
  • no-progress capability でリモートの進捗("Counting objects: 458, done." みたいなの)を受け取らないこと、
  • 0000 で受け取りたい object の列挙の終わり、
  • have 054bc353ea512a5b846545981306ca1153c39b5f ではこの object がローカルリポジトリに存在していること、
  • done でクライアント要求の終わり

を伝えている。want も have もサーバ側はこれを受け取って、packfile(詳細は次の項)を生成しクライアントに送信する。

0031ACK 136802d3c5782043066e192863c45c421b88f0a8
PACK[バイナリデータ…]

サーバの応答の ACK は、クライアントが have で伝えてきた object がリモートリポジトリにも存在しますよ、という意味。

Packfile negotiation とあるように、ここは本当はインタラクティブに行われる。このフェーズについて詳しくみると:

  • クライアントは欲しい object のリストを want 行で送信する。
  • クライアントはローカルリポジトリの object を最大 256 個まで have 行で送信する(git の実装では新しい順)。
  • サーバは have 行を受け取った際、その object がリモートリポジトリにも存在していたら ACK object-id を送信する。
  • クライアントはサーバの ACK を受け取って、have で送信する object を調整する。十分な情報が集まったら done を送信する。
    • 例えばあるコミットについて ACK を受け取った場合、その親をたどる必要はなくなる。

このフェーズになると git-upload-pack のための入力を手で送信するのは大変なので、クライアント側も git fetch-pack を使ってみる。GIT_TRACE_PACKET 環境変数でこのプロトコルに関するデバッグ出力を有効にできる。

適当な空のリポジトリを作って以下を実行する。

% echo refs/heads/master | GIT_TRACE_PACKET=1 git --git-dir=empty-repo.git/ fetch-pack --stdin --no-progress 5cfc0b016df3dc4683ef.git/
13:42:52.135504 pkt-line.c:80           packet:  upload-pack> 0ab1a827b3193d55b023c1051c6d00bb45057e46 HEAD\0multi_ack thin-pack side-band side-band-64k ofs-delta shallow no-progress include-tag multi_ack_detailed symref=HEAD:refs/heads/master agent=git/2.7.1
13:42:52.136424 pkt-line.c:80           packet:  upload-pack> 0ab1a827b3193d55b023c1051c6d00bb45057e46 refs/heads/master
13:42:52.136447 pkt-line.c:80           packet:  upload-pack> 0000
13:42:52.136233 pkt-line.c:80           packet:   fetch-pack< 0ab1a827b3193d55b023c1051c6d00bb45057e46 HEAD\0multi_ack thin-pack side-band side-band-64k ofs-delta shallow no-progress include-tag multi_ack_detailed symref=HEAD:refs/heads/master agent=git/2.7.1
13:42:52.136805 pkt-line.c:80           packet:   fetch-pack< 0ab1a827b3193d55b023c1051c6d00bb45057e46 refs/heads/master
13:42:52.136812 pkt-line.c:80           packet:   fetch-pack< 0000
13:42:52.137172 pkt-line.c:80           packet:   fetch-pack> want 0ab1a827b3193d55b023c1051c6d00bb45057e46 multi_ack_detailed side-band-64k no-progress ofs-delta agent=git/2.7.1
13:42:52.137181 pkt-line.c:80           packet:   fetch-pack> 0000
13:42:52.137192 pkt-line.c:80           packet:   fetch-pack> done
13:42:52.137212 pkt-line.c:80           packet:  upload-pack< want 0ab1a827b3193d55b023c1051c6d00bb45057e46 multi_ack_detailed side-band-64k no-progress ofs-delta agent=git/2.7.1
13:42:52.137342 pkt-line.c:80           packet:  upload-pack< 0000
13:42:52.137352 pkt-line.c:80           packet:  upload-pack< done
13:42:52.137357 pkt-line.c:80           packet:  upload-pack> NAK
13:42:52.137451 pkt-line.c:80           packet:   fetch-pack< NAK
13:42:52.142308 pkt-line.c:80           packet:     sideband< PACK ...
13:42:52.142630 pkt-line.c:80           packet:  upload-pack> 0000
13:42:52.143079 pkt-line.c:80           packet:     sideband< 0000
0ab1a827b3193d55b023c1051c6d00bb45057e46 refs/heads/master

refs/heads/master なる ref に対応する object を取得するよう、git fetch-pack に標準に入力から伝えている。--no-progress はパケットのやりとりを簡単にするためのオプション。デバッグ出力のうち、upload-pack> で始まる行がサーバ側の応答であり、fetch-pack> で始まる行がクライアント側の要求である。

これを実行すると git fetch-pack により packfile が展開され、ローカルリポジトリに object が追加される。git --git-dir=empty-repo.git/ show 0ab1a827b3193d55b023c1051c6d00bb45057e46 などとして、当該コミットがリポジトリに存在することが確認できる。

サーバとクライアントのネゴシエーションにはいくつかバージョンがあって、複数の object を ACK できる multi_ack やそれを拡張した multi_ack_detailed capability が存在する。最新の git であれば multi_ack_detailed が有効なはず。ここを詳しくは見ないので Packfile transfer protocols を参照。

Packfile の送受信

サーバは PACK のあとに packfile と呼ばれるバイナリデータを送信する。これは複数の object をひとつのファイルにまとめたもので、git gc の際に生成されることもある。ここではこの詳細なフォーマットについては立ち入らない。詳しく知りたい場合は Pro Git: Gitの内側 - Packfile を参照。

それではこの項で何を語るのかという話になるのだけれど、ここで興味深いのが side-band および side-band-64k という capability。これがクライアントによって指定された場合、サーバは packfile の送信を多重化する。つまり packfile 自体のデータに他の情報も織り込んで送信する。

このモードが有効な場合、packfile の送受信は pkt-line フォーマット上で行われる。その際、ペイロード部の最初の 1 バイトの値によってデータのチャンネルが決まる:

  • \1: ペイロードは packfile のデータそのもの。
  • \2: ペイロードは進行状況。"Compressing objects: 100% (3/3)" みたいなやつ
  • \3: ペイロードはエラー情報。

先の例では git fetch-pack にあえて --no-progress をつけていたけど、これを外せば多重化された内容が確認できる。適当な空のリポジトリに対して:

% echo refs/heads/master | GIT_TRACE_PACKET=1 git --git-dir=empty-repo.git/ fetch-pack --stdin 5cfc0b016df3dc4683ef.git/
[中略]
13:50:05.646040 pkt-line.c:80           packet:   fetch-pack> want 136802d3c5782043066e192863c45c421b88f0a8 multi_ack_detailed side-band-64k ofs-delta agent=git/2.7.1 # no-progress なし
[中略]
13:50:05.650561 pkt-line.c:80           packet:     sideband< \2Counting objects: 3, done.
remote: Counting objects: 3, done.
13:50:05.651377 pkt-line.c:80           packet:     sideband< \2Total 3 (delta 0), reused 0 (delta 0)
remote: Total 3 (delta 0), reused 0 (delta 0)
13:50:05.651396 pkt-line.c:80           packet:     sideband< PACK ...
13:50:05.651612 pkt-line.c:80           packet:  upload-pack> 0000
13:50:05.651784 pkt-line.c:80           packet:     sideband< 0000
Unpacking objects: 100% (3/3), done.
136802d3c5782043066e192863c45c421b88f0a8 refs/heads/master

git clonegit pull 時にいつも見るメッセージはここでやりとりされていたわけだ。

packfile への圧縮・packfile からの展開

複数の object をひとつのデータに効率よくまとめたものが packfile。これは git pack-objectsgit unpack-objects を使って変換できる。前者は現在のリポジトリの複数の object を packfile フォーマットにまとめ、後者はその逆の操作を行う。

以下はあるリポジトリに含まれている2個の object を git pack-objectsgit unpack-objects で別の適当な空のリポジトリに送る例。

% git --git-dir=5cfc0b016df3dc4683ef.git/ rev-parse master~1:README.txt master:README.txt | git --git-dir=5cfc0b016df3dc4683ef.git/ pack-objects --stdout | git --git-dir=empty-repo.git/ unpack-objects
Counting objects: 2, done.
Total 2 (delta 0), reused 0 (delta 0)
Unpacking objects: 100% (2/2), done.
% git --git-dir=empty-repo.git/ cat-file -p 18b287e01c9f4b756d96e9a62491aa9868fb679bAn example simple repository.

リモートからの転送の際、packfile にまとめられて送られてくる object の数が多い時は git unpack-objects の代わりに git index-pack が使用される。これは object を単純にリポジトリに展開するのではなく、packfile 内の object の索引(.idx)を作成するコマンド。以下、git unpack-objects と書いているところは git index-pack にもなりうる。

各種トランスポートの実装

ここまで pack protocol を見たところで、これを転送するトランスポートの仕組みを見てみる。

file トランスポート

ローカルマシンの中で完結する file トランスポートは、実はこれまで見てきた標準入出力による pack protocol とほぼ同じ。

file プロトコルのリモートリポジトリに対して git fetch を呼び出すと、リモートリポジトリ(= ローカルのディレクトリ)で git upload-pack をサブプロセスとして起動し、パイプを使って通信する。ここでは git-fetch-pack ではなく git-fetch 自体が negotiation を担当する。

このとき起動されるプロセスは以下のような親子関係になっている(はず)。親子プロセスはたいていの場合パイプで通信しあっている。

LOCAL                                  REMOTE

git fetch
 |------------------------------------ git upload-pack
 `- git unpack-objects                  `- git pack-objects

ssh トランスポート

ssh トランスポートも file トランスポートのわりと素朴な拡張で、ローカルのディレクトリで git upload-pack を実行する代わりに ssh した先で git-upload-pack <dir> を実行する(git upload-pack ではない)。パイプでやりとりするのは同じ。

ssh://git@example.com/path/repo.git という URL であれば ssh git@exmaple.com 'git-upload-pack /path/repo.git' が実行される。

ちなみに似たようなものとして SCP-like syntax と呼ばれる git@example.com:path/repo.git という記法があるが、この形の場合起動されるコマンドは ssh git@example.com 'git-upload-pack path/repo.git' と、パスの先頭に / がないものとなり、似ているようで異なるので注意が必要である。~ も特別扱いされる。詳しくは Packfile transfer protocols を参照。

LOCAL                                  REMOTE

git fetch
 |- ssh  <-----------[SSH]----------->  git-upload-pack
 `- git unpack-objects                    `- git pack-objects

git トランスポート

git トランスポートは TCP 9418 ポートに pkt-line でリクエストを送ることで開始される。

git://gist.github.com/5cfc0b016df3dc4683ef.git であれば:

% echo -e -n '0043git-upload-pack /5cfc0b016df3dc4683ef.git\0host=gist.github.com\0' \
  | nc gist.github.com 9418
00eb0ab1a827b3193d55b023c1051c6d00bb45057e46 HEADmulti_ack thin-pack side-band side-band-64k ofs-delta shallow no-progress include-tag multi_ack_detailed symref=HEAD:refs/heads/master agent=git/2:2.6.5~peff-relaxed-fsck-1348-ga22efe0
003f0ab1a827b3193d55b023c1051c6d00bb45057e46 refs/heads/master

リモートは git-daemon の実装であれば git upload-pack を呼び出してレスポンスを生成する。

LOCAL                                REMOTE

git fetch  <---------[TCP]-------->  git-daemon
 `- git unpack-objects                `- git upload-pack
                                          `- git pack-objects

http(s) トランスポート

http(s) での通信は、古くは dumb protocol と呼ばれる .git 以下のファイルを一々転送するようなプロトコルが用いられていたが、今後これが話題になることはないと思うのでこでは触れない。

このトランスポートでは 2 回の HTTP リクエストがおこなわれる。

  1. Reference discovery. GET /path/repo.git/info/refs?service=git-upload-pack
  2. Packfile negotiation + Packfile. POST /path/repo.git/git-upload-pack

送受信される内容はわずかに特別なヘッダが付与されることを除けばこれまで見てきたものと同じである。Reference discovery であれば手で実行できるので見てみよう。

% curl -i 'https://gist.github.com/5cfc0b016df3dc4683ef.git/info/refs?service=git-upload-pack' -H 'User-Agent: git/2.7.1'
HTTP/1.1 200 OK
Server: GitHub Babel 2.0
Content-Type: application/x-git-upload-pack-advertisement
Transfer-Encoding: chunked
Expires: Fri, 01 Jan 1980 00:00:00 GMT
Pragma: no-cache
Cache-Control: no-cache, max-age=0, must-revalidate
Vary: Accept-Encoding
X-GitHub-Request-Id: 7047A333:5489:AB8B02B:56DD99A0
X-Frame-Options: DENY

001e# service=git-upload-pack
000000f30ab1a827b3193d55b023c1051c6d00bb45057e46 HEAD\0multi_ack thin-pack side-band side-band-64k ofs-delta shallow no-progress include-tag multi_ack_detailed no-done symref=HEAD:refs/heads/master agent=git/2:2.6.5~peff-relaxed-fsck-1348-ga22efe0 # "\0" は NUL
003f0ab1a827b3193d55b023c1051c6d00bb45057e46 refs/heads/master
0000

実装は git-fetch にビルトインではなく、git-remote-http(s) というヘルパーコマンドを通じて行われる。

このヘルパーコマンドも標準入力から命令を受け取ることになっていて、詳細は [githelp:remote-helpers] に記載されている。以下のコマンドを空のリポジトリで実行すれば雰囲気をつかめるだろう。

% { echo list; \
  echo fetch 0ab1a827b3193d55b023c1051c6d00bb45057e46 refs/heads/master; \
  echo \
} | GIT_CURL_VERBOSE=1 git --git-dir=empty-repo.git/ remote-https https://gist.github.com/5cfc0b016df3dc4683ef.git
[省略]
LOCAL                                 REMOTE

git fetch
 `- git remote-https  <---[HTTP]--->  (httpd)
     `- git fetch-pack                 `- git upload-pack
         `- git unpack-objects             `- git pack-objects

まとめ

git の object の転送プロトコルについて簡単に見ていった。調べはじめた時には HTTP 通信専用のヘルパがあるとは知らず手間取ったけれど、豊富なサブコマンドをパイプでつないで転送を実現していることがわかった。予想に反して結構複雑な作りになっていた……。

何か間違ってるところがあったら教えてください。

file トランスポートで見たように転送のコア部分は git のサブコマンドで実現されているので、git のリモートサーバは意外と簡単に実装することができる: github.com/motemen/mir はそういうひとつ(だがシンプルではない)

ここで説明しきれていない話:

  • Packfile のフォーマット
  • Packfile negotiation の詳細な挙動
  • multi_ack の挙動
  • カスタムトランスポート

参考資料

はてなで一緒に働きませんか?