どうもこんにちは.RCC副執行委員長の3^5です.

11月の終わりごろに執筆しているのですが無事25日連続投稿が成功して延長線としてこの記事が公開されることを願っております.締め切りを遵守した1~2回生は数名と聞いているのでとても心配ですが…w

(12/22追記: どうやら数人の失踪によりこの記事は24日目になるみたいですね.今年は1回生のアドカレ担当者が足りない分の記事を錬成しなかったらしい)

以下,今回の目次です.

目次

  • Rustで作るGit入門
    • .gitの中身を見る
      • HEADについて
      • refsについて
      • indexについて
      • objectsについて
    • Gitオブジェクトとその種類
      • blobオブジェクト
      • treeオブジェクト
      • commitオブジェクト
    • 配管コマンド
      • cat-file
      • hash-object
    • 磁器コマンドを配管コマンドで再現
      • add
      • commit
  • 終わりに

Rustで作るGit入門

さて本題ですが,今回の話は先日TwitterでRustのGit自作本を見かけたので読んでみたという話です.

実装内容とか詳細をこの記事に書いちゃうと元記事(有料500円)のネタバレになっちゃうのでここではGitのディレクトリ構造とかGitオブジェクトの話とかを中心にしていきます.(実装に関しても元記事の6章くらいまでしかやってないけど)

なお,実装内容はGitHubで公開しています.

.gitの中身を見る

.gitディレクトリの中をじっくり見たことはありますか?実はこんな感じになっています.(見づらい画像でごめんなさい)

この中で以降出てくるものは

  • HEAD
  • index
  • objects
  • refs

の4つになります.

それぞれ詳しくみていきましょう.

HEADについて

HEADはgitにおいては基本的には現在いるブランチを指すエイリアスになっています.

例えば,今developブランチにいるならばgit push origin developと書くところを代わりにgit push origin HEADと書けるわけです.間違いが減ってありがたいですね.

このHEAD.git内に直接ファイルが存在していて,現在いるブランチへの参照となっています.実はブランチは特定(最新)のコミットを指す概念であって,それぞれに分かれた枝においてのコミットの列を指しているわけではないことに注意が必要です.この記事とかが多分参考になります.

ご自身の環境にある適当なGit管理されているプロジェクトの.git/HEADを見てみてください.そこにはおそらくref: refs/heads/<branch名>と書かれていることでしょう.ブランチは.git/refs内に情報が格納されているのです.

refsについて

refsの中を覗いてみると大体の雰囲気はつかめると思います.なんとなくheadsの中にローカルのブランチがあって,remotesにリモートリポジトリのブランチがあるんだな,みたいなことがわかります.

実際にはこのファイルになっている部分(上画像で言えばheads/masterなど)が,そのブランチの最新のコミット(前項で説明したようにブランチは特定のコミットを指すのでそのブランチの最新のコミットという表現は適切ではありませんが)の参照になっています.

indexについて

.git/indexは現在追跡中のファイルが記されています.

indexはバイナリファイルですが,git ls-files --stageコマンドで中身を表示することもできます.

100644 95ba71b8e7c2d70965d9a5086a9fabd58e87d259 0 .gitignore
100644 960a0c393e5e493e911ac24ac42d6eb125409263 0 Makefile
100644 9d7f6b8213c452a8e698ccb5557cdf5d36ccf25c 0 README.md

一番左の100644というのはこのファイルが通常ファイルであることを表しています.これはtreeオブジェクトの実装の際にももう一度出てくるのですが,対象が実行可能ファイルだったりtreeオブジェクトだったりするとこの値が変わってきます.

その次のよく分からない値はハッシュ値です..git/objectsの中にそのファイル名のオブジェクトが存在します.ただし最初の2桁がディレクトリになっていて残りの桁が名前になったファイルがその中に配置されているという形で保管されています.ここら辺は次の項でも出てきます.

次の数値はステージと呼ばれる値で0~3の値をとり,マージ時に使われます.Gitのマージコンフリクトの解消は一方のブランチと他方のブランチ,そして二つの親ブランチの3バージョンを比較して行うために0の他に3種類のフラグを要するわけです.

objectsについて

.git/objectsにはGitオブジェクトが配置されています.それぞれのオブジェクトは前述したようにそのハッシュ値の上二桁を除いたものがファイル名になっており,上二桁の名前のディレクトリ内に配置されています.

Gitオブジェクトはすべてこの.git/objectsに配置されますが,その中でも役割に応じて3つの種類に分かれています.

Gitオブジェクトとその種類

Gitオブジェクトにはblob, tree, commitの3種類が存在します.

前項で見たようにGitオブジェクトはすべて.git/objectsに格納されています.格納場所や方法にタイプによる違いはなく,blobだろうとそうでなかろうと前述の方法で保存されています.

各オブジェクトはどれもheaderとbodyから構成されており,headerのフォーマットは"<object-type> <byte-size>"で統一されています.bodyのフォーマットは各オブジェクトのタイプで異なります.

それぞれの役割とRustでの簡単な実装例(ほぼ定義のみ)をみていきましょう.

blobオブジェクト

一つのファイルを表すGitオブジェクトです.

実装としてはデータとその長さをメンバに持つ構造体を定義して,メソッドとしてバイト列から構造体を作ったり逆にバイト列として出力したりする関数やオブジェクトのファイル名となるハッシュ値を計算するメソッドを作っておけばよいでしょう.

treeオブジェクト

treeはディレクトリ構造を保存するオブジェクトです.

名前の通り木構造になっており,blobオブジェクトやtreeオブジェクトを保有します.この木構造がそのままコミット時のディレクトリ構造を示しています.

Gitデータモデルの簡略版
画像はGit Bookより引用

実装については構造体のメンバとしてFileを表す構造体の配列を持つようにします.File構造体はファイル名・ハッシュ値・ファイルの種類(通常ファイルやtreeオブジェクトを表すフラグ)を持つことでtreeが木構造を表現しています.

commitオブジェクト

commitオブジェクトは名の通りコミットを表現するオブジェクトです.

あるcommitオブジェクトは下図のようにコミット時点でのtreeオブジェクトを指します.

Gitリポジトリ内のすべてのオブジェクト
画像はGit Bookより引用

メンバとしては5つの情報を持っており,指しているtreeオブジェクトのハッシュ値,一個前のコミットオブジェクトのハッシュ値,author/commiter情報,そしてコミットメッセージです.

配管コマンド

Gitには普段よく使うgit addgit commitcheckout, branch, remoteのようなユーザフレンドリーなコマンドがある一方で,git ls-filesのような低レベルの処理を行うためのコマンドもたくさんあります.これら後者のコマンドは配管コマンドと呼ばれていて,これをUNIX流につなぎ合わせることで前者磁器コマンドが実装できます.

cat-file

git cat-file -p <見たいblobオブジェクトのハッシュ値>を実行することでそのファイルをcatすることができます.これも配管コマンドの一つです.

ls-filesで出てきたハッシュ値を与えて実行するとそのファイルの中身が標準出力に出力されるでしょう.当然ですが与えたハッシュ値のオブジェクトがblobオブジェクトである必要があります.

このcat-fileが行う作業は以下のようになります.

  • ハッシュ値からblobオブジェクトのパスを生成
  • blobオブジェクトのデータを読み取る
  • データを解凍する
  • 出力

実装はかなり簡単です..git/objectsの中のハッシュ値上二桁のディレクトリ内で残りのハッシュ値のファイルをオープンし,内容を読み取ってzlibで解凍するだけです.

hash-object

git hash-object <ファイル名>とすることでそのファイルをblobオブジェクトにすることができます.基本的には標準出力にハッシュ値を表示しますが-wオプションでそれを直接.git/objectsに保存することもできます.cat-fileの逆みたいな感じですね.これも実装は簡単です.

  • ファイルを開いて中身を読み取る
  • 読み取ったバイト列を使ってblobオブジェクトを生成して返却する

磁器コマンドを配管コマンドで再現

こっからは実装できてないので先ほど出てきたcat-filehash-objectに加え公式のGitに含まれている配管コマンドも使って話を進めていきます.

配管コマンドだけで磁器コマンドであるaddcommitを実行してみましょう.

git add

git addがやっていることは大きく分けて2つです.

  • 引数のファイルのGitオブジェクトを生成
  • indexファイルを更新

前者はhash-objectを使うことで.git/objectsに新しいblobオブジェクトを生成できますね?

git hash-object -w <file-name>

後者はupdate-indexという配管コマンドを使います.名前の通りindexを更新できますがいくつか引数とオプションを与える必要があります.具体的には以下の通りです.

git update-index --add --cacheinfo <mode> <hash> <file-name>

--addオプションはindexに追加されていないファイルをaddする際に利用します.

--cacheinfoindexに記録される値を記述します.引数としてtreeオブジェクトの際に実装したFile構造体の持っている情報と同じものを渡しています.modeのところにファイルの種類が入ります.残りの二つはそのままです.

git commit

git commitがやっていることは大きく分けて3つです.

  • treeオブジェクトの生成
  • commitオブジェクトの生成
  • refに書かれているcommitへの参照を更新

まずgit write-treeindexの情報をもとにtreeオブジェクトを生成します.

次に,作成したtreeオブジェクトのハッシュ値を使ってcommitオブジェクトを生成します.これはcommit-treeコマンドを使います.いつも通り-mでコミットメッセージがつけられます.

最後にrefに記述されているcommitへの参照を更新してやります.これはupdate-refコマンドを用います.引数としてref内のブランチのパスとcommitオブジェクトのハッシュ値を渡します.

git write-tree
git commit-tree <hash> -m "commit message"
git update-ref refs/heads/<branch> <hash>

終わりに

やった内容はこんな感じです.最初に載せた記事の方はまだまだ続きがあるので皆さん買ってやりましょう.

感想ですが,Gitの内部構造をなんとなく理解できたおかげでとログの見方が分かったり誤った処理をやったときの対処などが想像できるようになってとてもよかったです.今回は参考記事をほとんどそのまま写経していきましたがもう実際の動きは分かっているのでもうちょっとこだわった実装で書き直してみても面白いかもですね.とくにtreeオブジェクトを実際に木構造で書いてみたさがあります.

それでは長文になりましたがこれで失礼します.皆さんよいお年をお迎えください~~~!

Twitterでフォローしよう

おすすめの記事