🔨

GoでGit自作してみた<後編>

2023/06/18に公開

はじめに

Gitの仕組みを理解しようと、Go言語でGitもどきを自作してみました。

その名も 「Goit」 です。
https://github.com/JunNishimura/Goit

前回の記事では、Gitの仕組みについて説明しました。
https://zenn.dev/jundayo/articles/172092175c0426

執筆段階(2023/06)で実装したコマンドには以下のようなものがあります。

  • init - initialize Goit, make .goit directory where you init
  • add - make goit object and register to index
  • commit - make commit object
  • branch - manipulate branches
  • switch - switch branches
  • restore - restore files
  • log - show commit history
  • config - set config. e.x.) name, email
  • cat-file - show goit object data
  • ls-files - show index
  • hash-object - show hash of file
  • rev-parse - show hash of reference such as branch, HEAD
  • update-ref - update reference
  • write-tree - write tree object
  • version - show version of Goit

これら全ての実装内容を書くのは大変なので、Gitを使っていておそらく一番馴染み深い「add」して「commit」するまでの流れに沿って実装部分の説明をしていきたいと思います。

それでは早速いきましょう。

add

addコマンドを実装しているファイルはこちらです。
https://github.com/JunNishimura/Goit/blob/main/cmd/add.go

addコマンドの大まかな処理の流れは以下の通りです。

  1. 引数として指定されたファイルが存在するかを確かめる
  2. Ignore対象でないかを確かめる
  3. 指定されたファイルからBlobオブジェクトを作成する
  4. インデックスを更新
  5. オブジェクトをファイルに書き込む

それでは一つずつ見ていきましょう。

1. 引数として指定されたファイルが存在するかを確かめる

https://github.com/JunNishimura/Goit/blob/8d1f43cb1f2d480aba65d68c86f6a4daf98c0649/cmd/add.go#L75-L86
forループで各引数を見て、その引数として渡されたファイル名が存在するかどうかを確かめています。

ただし、削除したファイルに対してgit addする場合もあり得るので、ファイルが存在しない場合、インデックスにそのファイルが登録されていないかを確かめます。インデックスにも登録されていない場合は、引数が誤りであるためエラーを返します。

2. Ignore対象でないかを確かめる

https://github.com/JunNishimura/Goit/blob/143afe2634aa0fc959d1a84b0587471804f126b9/cmd/add.go#L90-L94
引数で渡されたパスを整形して、client.Ignore.IsIncluded関数に渡しています。
https://github.com/JunNishimura/Goit/blob/143afe2634aa0fc959d1a84b0587471804f126b9/internal/store/ignore.go#L62-L75
ignore対象となるパスを正規表現として格納しておき、引数として渡したパスがその正規表現にマッチした場合はignore対象と判断しています。

3. 指定されたファイルからBlobオブジェクトを作成する

https://github.com/JunNishimura/Goit/blob/19c8f8b507838828fa18b540c8165d8bdd1d986b/cmd/add.go#L19-L28
まずは指定されたファイルの中身を取得します。

そしてobjectパッケージのNewObject関数に、BlobObjectというobject typeと取得したデータをパラメーターとして渡します。

object.NewObject(object.BlobObject, data)

NewObjectの実装は以下の通りです。
https://github.com/JunNishimura/Goit/blob/19c8f8b507838828fa18b540c8165d8bdd1d986b/internal/object/object.go#L24-L46

object構造体はtypehashsizedataをフィールドに持っているので、それぞれのフィールドに値をセットしていくことで、objectを生成します。

4. インデックスを更新

https://github.com/JunNishimura/Goit/blob/19c8f8b507838828fa18b540c8165d8bdd1d986b/cmd/add.go#L43-L49
ここではまずインデックスを更新します。更新の必要ない場合は、後続の処理を行う必要がないので、スキップします。

インデックスの更新部分は次の通りです。
https://github.com/JunNishimura/Goit/blob/19c8f8b507838828fa18b540c8165d8bdd1d986b/internal/store/index.go#L87-L108
インデックスには、各エントリ毎のファイルパスとハッシュ値が格納されています。新規追加ファイルと同じパス、ハッシュ値を持つエントリが既にインデックスに登録されている場合、インデックスを登録する必要がないため、returnします。

インデックスを更新する場合でも

  1. 新規追加ファイル
  2. インデックスには登録されているけど、中身が変わった(ハッシュが変わった)ことによりインデックスを更新する必要のあるファイル

とでは処理が少し異なります。1の場合は単純にエントリを格納しているスライスに追加するだけで良いですが、2の場合は既存のエントリを一度消してから、新しいエントリを追加しています。

そして、新規エントリ追加後はインデックスをファイルパスでソートし、更新したインデックスをファイルに書き込みます。

5. オブジェクトをファイルに書き込む

https://github.com/JunNishimura/Goit/blob/ecd5fce9ccd1de80c7fffe7896bffe3762d89b85/cmd/add.go#L51-L54
object.Writeの実装部分は次のようになっています。

https://github.com/JunNishimura/Goit/blob/19c8f8b507838828fa18b540c8165d8bdd1d986b/internal/object/object.go#L135-L157
まず、オブジェクトのデータをzlibで圧縮しています。その後適当なパスに対してファイルを作成し、圧縮したデータを書き込みます。

ここまでの流れでgit addの実装は終了です。それでは次にgit commitについて見ていきましょう。

commit

commitコマンドを実装しているファイルはこちらです。
https://github.com/JunNishimura/Goit/blob/main/cmd/commit.go

commitコマンドの大まかな流れは以下の通りです。

  1. configの設定がされているか確認
  2. コミットの必要があるか(インデックスが更新されているか)の確認
  3. ツリーオブジェクトの作成
  4. コミットオブジェクトの作成
  5. ブランチの更新
  6. log書き出し
  7. HEADの更新

1. configの設定がされているか確認

https://github.com/JunNishimura/Goit/blob/59917c1df9f43714c01c067f23b047959169854f/cmd/commit.go#L175-L177
IsUserSetの実装部分は次の通りです。
https://github.com/JunNishimura/Goit/blob/59917c1df9f43714c01c067f23b047959169854f/internal/store/config.go#L95-L112
ここでは、local(.goit/.goitconfig)あるいはglobal(~/.goitconfig)のconfigファイルにuser(name & email)が設定されているかを確認しています。

ユーザー情報(name & email)はコミットに含まれるCommitterとAuthorで参照するので設定されている必要があるため、ここで確認しています。

2. コミットの必要があるか(インデックスが更新されているか)の確認

https://github.com/JunNishimura/Goit/blob/59917c1df9f43714c01c067f23b047959169854f/cmd/commit.go#L196-L203
isCommitNecessary関数の実装は次のとおりです。
https://github.com/JunNishimura/Goit/blob/59917c1df9f43714c01c067f23b047959169854f/cmd/commit.go#L141-L161
コミットの必要がある、コミットに意味がある場合というのはインデックスが更新されている場合です。インデックスの更新有無検知はひとつ前のコミット(のツリーオブジェクト)と比較することで実現します。ということで、ここではHEADが指しているコミットオブジェクトが内包しているツリーオブジェクトとインデックスを比較しています。

3. ツリーオブジェクトの作成

https://github.com/JunNishimura/Goit/blob/59917c1df9f43714c01c067f23b047959169854f/cmd/commit.go#L35-L39
ツリーオブジェクトはインデックスをもとに作成されるので、引数でインデックスを渡しています。writeTreeObjectの実装は次のとおりです。
https://github.com/JunNishimura/Goit/blob/59917c1df9f43714c01c067f23b047959169854f/cmd/writeTree.go#L15-L93
少し複雑な実装になっていますが、内容自体はそれほど複雑ではありません。

インデックスに登録されているエントリを一つずつ走査し、エントリがBlobオブジェクトであれば、そのまま追加し、エントリがTreeオブジェクトであれば、writeTreeObjectを再帰的に呼び出してTreeオブジェクトを作成します。

ただ、インデックスのエントリは全てBlobオブジェクトとして登録されているので、エントリのパスからそのエントリがサブディレクトリに含まれているかを確認します。例えばエントリのパスがtmp/test.txtであれば、test.txtはtmpディレクトリ内にあるとして、「test.txt」Blobオブジェクトを持つ「tmp」Treeオブジェクトを作ります。

4. コミットオブジェクトの作成

https://github.com/JunNishimura/Goit/blob/2255ed8a87d176cbc6c390be9ac88dd0e2c0f47c/cmd/commit.go#L57-L67
まずはcommitObjectを作成して、それをもとにcommitを作成します。NewObjectメソッドは上で説明したので省略します。

NewCommitメソッドは次のとおりです。
https://github.com/JunNishimura/Goit/blob/2255ed8a87d176cbc6c390be9ac88dd0e2c0f47c/internal/object/commit.go#L59-L115
Commitデータを1行ずつ読み込んで、適当なフィールドに値をセットしていきます。

Commitデータは

$ git cat-file -p 8ae3a823e4fc675afb9772408129d483687d6e0c
tree c23b8eb57ad94e6eb0acefee6abbb7ad37adac10
parent fb1155f1745d4848600d47315bd0b3663a7d9a50
author test taro <test@example.com> 1686202540 +0900
committer test taro <test@example.com> 1686202540 +0900

delete main

といったようにtree parent author committer messageで構成されています。

commit.Writeに関してはcommit内部でobjectのWriteメソッドを呼び出しています。Writeメソッドに関しても上で説明したので省略します。

5. ブランチの更新

https://github.com/JunNishimura/Goit/blob/2255ed8a87d176cbc6c390be9ac88dd0e2c0f47c/cmd/commit.go#L71-L83
ブランチがすでに存在する場合とそうでない場合で場合分けをしています。

既に存在する場合は、ブランチのハッシュ値のみ更新します。
https://github.com/JunNishimura/Goit/blob/2255ed8a87d176cbc6c390be9ac88dd0e2c0f47c/internal/store/refs.go#L211-L226
branchのハッシュを書き換えて、branchファイルを上書きします。

存在しない場合は、新しくブランチを追加します。
https://github.com/JunNishimura/Goit/blob/2255ed8a87d176cbc6c390be9ac88dd0e2c0f47c/internal/store/refs.go#L106-L125
RefsのHeadsに新規ブランチを追加して、ソートします。

6. log書き出し

https://github.com/JunNishimura/Goit/blob/2255ed8a87d176cbc6c390be9ac88dd0e2c0f47c/cmd/commit.go#L84-L91
Gitでは.git/logs配下でログファイルを管理しています。commitでは.git/logs/HEADのHEADファイルと.git/logs/refs/heads/<branch name>のブランチファイルにログを残します。

7. HEADの更新

https://github.com/JunNishimura/Goit/blob/2255ed8a87d176cbc6c390be9ac88dd0e2c0f47c/cmd/commit.go#L93-L96
Head.Updateは以下のとおりに実装しています。
https://github.com/JunNishimura/Goit/blob/2255ed8a87d176cbc6c390be9ac88dd0e2c0f47c/internal/store/head.go#L88-L123
HEADファイルを書き換えて、HEADのコミットをHeadに登録しています。

まとめ

前編と後編に分けて、GitをGoで自作した話について書きました。

この記事がGitを理解する、Gitを自作する上で何かしらの役に立つのであれば幸いです。

また、今後も「Goit」の開発を進めるていくつもりなので、レポジトリにスターを付けてくださると励みになります。

https://github.com/JunNishimura/Goit

参考資料

<Gitについて>

<Gitの仕組みについて>
<Gitの実装について>

Discussion