この記事は新歓ブログリレー2023の50日目の記事です。
こんにちは、SysAd班21Bのmehm8128です。
今回はタイトルにもあるように、Gitを自作してみようとした話をしようと思います。
Gitとは?
新入生の皆さんはGitが何なのかを知らない人が多いと思うので、軽く説明します。
Gitは分散型バージョン管理システムで、プログラムのソースコードを管理・共有するために使います。
例えば新しい機能を追加したときに、別の場所でバグが発生してしまったとします。そのとき、バージョン管理をしていなければ機能追加前のコードに戻せなくなってしまいますが、Gitを使えば機能を追加する前の状態で保存してあれば、元に戻ってどこからバグが発生してしまったのかを調査することができます(GitHubというサービスを利用すれば見やすく確認することができます)。
今回はそのバージョン管理システムであるGitの内部の仕組みに軽く触れてみて、一部の機能を自作してみたり自作しようとしてみたりしたので、紹介しようと思います。ただし、実装の仕方の紹介はほとんどせずに僕が理解している範囲で内部の仕組みを説明する方に重点を置こうと思います。
Gitの仕組み
Gitで現在のソースコードの状態を保存するには主に2つのステップが必要になります。
git add
というコマンドを使って保存するファイルを選択するgit commit
というコマンドを使って選択したファイルたちの保存を確定させる
この2つの話をメインにしていきます。前者は8割くらい実装できて、実際にVSCodeのGUIが認識してくれるくらいまで動くようにはなっています(ただし、不備があって上手く動かないケースがあります)。後者は実装力が足りなくて諦めました(そもそも実装方針に問題があるかも?)。
注意:ここより先は実際に勉強して実装してから2ヶ月くらい経ってから書いているので間違いがあるかもしれないことにご注意ください。
git add
ではgit add
で行っていることを説明します。git add
では主に以下の手順で処理が進みます。
- stageされているファイルの中身の先頭に
blob <ファイルサイズ><NULL文字>
をつけたものを用意 - 1.で作ったものをzlib圧縮する
- 1.で作ったものをハッシュ化した文字列の先頭2文字をディレクトリ名、残りをファイル名にして2.で作ったものを
objects
ディレクトリに保存 .git/index
をフォーマットに従って更新
.git/index
の中身のフォーマットは大体ここらに書いてある通りになっています。
https://qiita.com/noshishi/items/60a6fe7c63097950911b#インデックスを解体してみる
indexファイルを更新する際に、1.から3.で作ったblob
オブジェクトが必要になります。
gitにはblob
オブジェクト、tree
オブジェクト、commit
オブジェクトというものがあります(tag
オブジェクトもあるらしいですが今回は関係ないので飛ばします)。blob
オブジェクトは1. ~3.で作ったような形式で、git管理されているファイルを示すものです。tree
オブジェクトはgit commit
のときに出てくるのですが、ファイルではなくてディレクトリを示すもので、そのディレクトリに入っているオブジェクトの情報が入っています。commit
オブジェクトもgit commit
のときに出てくるのですが、コミットの情報が入っています。
なんたらオブジェクトとかindexファイルとか言われても分からないと思うので、ハンズオン形式で軽く実際に見てみましょう(ファイル名やファイルに書き込む内容は同じものにしてください。でないとハッシュ値がずれてしまってこの通りになりません)。
$ mkdir git-handson
$ cd git-handson
$ touch aaa.txt
$ echo oisu- > aaa.txt
$ git init
$ git add .
$ git ls-files --stage
ここまでやって
100644 72943a16fb2c8f38f9dde202b7a70ccc19c52f34 0 aaa.txt
のような表示になりましたでしょうか。
左から順にファイルの権限、ハッシュ値、なんだっけこれ、ファイル名、です。
git ls-files --stage
することで、indexファイルの中身を解読してstageされているファイルの一覧を見ることができます。
さらに続いて
$ cd .git/objects/72
$ ls
すると
943a16fb2c8f38f9dde202b7a70ccc19c52f34
と出てきたと思います。
これはディレクトリ名と合わせると、ls-files
したときに出てきたハッシュ値と一致しますね。上で説明した通り、ファイルのハッシュ値の先頭2文字がディレクトリ名、残りがファイル名となってgit add
したファイルの中身が保存されています。
最初のコマンドの72
はどこから出てきたんだと言われたら.git/objects
ディレクトリのファイル一覧を見てずるしてるのですが、許してください(もしくは、状態によってはgit commit
のときに紹介する方法で辿ることはできます)。
では本当にこのファイルにaaa.txt
の中身が保存されているのでしょうか?確認してみましょう。
git cat-file -p 72943a16fb2c8f38f9dde202b7a70ccc19c52f34
無事ファイルの中身である、
oisu-
が表示されましたでしょうか。cat-file
コマンドを使うことでblob
オブジェクトの中身を確認することができます。ちなみに「おいすー」はtraP部員がよく使う挨拶です。
ちなみにls-files
やcat-file
は配管コマンドと呼ばれ、これも自分で実装してみました。git add
の実装よりはかなり手軽に実装できるので、やってみてください。最後に参考にした記事をまとめて載せるので、そこを参考にするといいと思います。
さて、ここまで分かったらあとは実装するだけです。上で挙げた手順に従って処理をするように実装をしてみましょう。最初に上手く動かないケースがあると言ったのは、git add
されたときにindex
ファイルをstagingエリアにあるファイルだけで完全に上書きしてしまっていたのですが、実は今までgit add
されたものは残しながら更新しないといけなかったらしいみたいな話があります。2ヶ月前のことなのであんまり記憶がないのですが、git commit
のときに上手くいかないとか何とかが理由だったような気がします。
一応VSCodeで動いているところを動画撮ったのでご覧ください。
ではここまででgit add
が完成したということにして、次にgit commit
について見ていきましょう。
git commit
ではgit commit
で行っていることを説明します。git commit
では主に以下の手順で処理が進みます。
- 現在のディレクトリ構造と
index
ファイルからtree
オブジェクトを生成し、commit
オブジェクトを生成 - 1.で作った
commit
オブジェクトをrefs/heads
以下が指すようにする
git add
のときよりざっくりした手順になってしまいましたが、1つずつ説明します。
tree
オブジェクトというのは上でも書いた通りディレクトリを示すもので、ディレクトリに入っているオブジェクトの一覧が入っています。commit
オブジェクトはそのコミット時のルートのtree
オブジェクトのハッシュ値やコミットしたユーザーの情報、タイムスタンプなどが含まれています。
では先ほどの続きから実際に手を動かして確認していきましょう。
$ git commit -m 'first commit'
$ git cat-file -t 3d0dff9c5d6f9b1fd844ddc7cc74cfcada495a32
$ git cat-file -p 3d0dff9c5d6f9b1fd844ddc7cc74cfcada495a32
2つ目のコマンドで
tree
3つ目のコマンドで
100644 blob 6299ee48c1967e011cb8cec0cabef203df5e96b0 aaa.txt
という表示になりましたでしょうか。これでコミットしたことによってtree
オブジェクトが生成され、その中にはaaa.txt
を示すblob
オブジェクトが入っていることが確認できます。
左から順に権限、オブジェクトの種類、ハッシュ値、ファイル名(ディレクトリ名)です。
このtree
オブジェクトは現在コマンドを実行しているディレクトリのtree
オブジェクトです。なので、aaa.txt
をさらに深い階層のディレクトリに入れたらその分何回もtree
オブジェクトを辿らないといけなくなります(ディレクトリの中にディレクトリが入っている場合、blob
と書いてあるところがtree
になることもあります)。
続けてcommit
ファイルを確認します。
$ git cat-file -t 1147583dcfd01266a21c23428eb4f8ff3eab0973
$ git cat-file -p 1147583dcfd01266a21c23428eb4f8ff3eab0973
1つ目のコマンドで
commit
2つ目のコマンドで
tree 3d0dff9c5d6f9b1fd844ddc7cc74cfcada495a32
author {ユーザー名} <{メールアドレス}> {タイムスタンプ} +0900
committer {ユーザー名} <{メールアドレス}> {タイムスタンプ} +0900
gpgsig -----BEGIN PGP SIGNATURE-----
{PGP SIGNATURE}
-----END PGP SIGNATURE-----
first commit
という表示になりましたでしょうか。{}
で囲ってある部分は人によって違うと思います。また、僕はGPG署名の設定をしてあるのでgpgsig
とかいうのがありますが、設定していない人はcommitter
のすぐ下にfirst commit
というコミットメッセージが表示されると思います(2ヶ月前に勉強してたときはなかったのにその間にGPG署名の設定したので表示されるようになっていて驚きました)。
commit
オブジェクトはtree
オブジェクトのハッシュ値を1つだけ保存しています。このtree
オブジェクトはさっき見た、今いるディレクトリのハッシュ値になります。このハッシュ値だけ分かっていれば、このtree
オブジェクトの中身をみてその中にあるtree
オブジェクトをさらに辿って...としてディレクトリ全体を再現することができるからです。
まだ続きます。
$ echo oisi- > aaa.txt
$ git add .
$ git commit -m 'second commit'
$ git cat-file -p 60553e5e46bfa5ccebc635cb8df4a805ba31d4de
2つ目のコミットを打って、そのcommit
オブジェクトを見ています(一番下に表示されるコミットメッセージがsecond commit
となっているはずです)。ちなみに、このgit add
では最初のgit add
でblob
オブジェクトにしたファイルも更新された状態で再びblob
ファイルとして保存されます。これが「gitは差分ではなくスナップショットで保存される」みたいな話ですね(古いものはpackというディレクトリに圧縮されて差分等で保存されるらしいですが)。
cat-file
の結果を上から2行分だけ取り出して見ていきましょう。
tree 3901d5eb8422b3cdede6e5bd733c09564163f701
parent 1147583dcfd01266a21c23428eb4f8ff3eab0973
さっきの1つ目のcommit
オブジェクトにはなかった、parent
というものがありますね。そしてこのハッシュ値は1つ目のコミットのcommit
オブジェクトを示しています。gitではこのようにして、新しいcommit
オブジェクトに前のcommit
オブジェクトのハッシュ値をつけておくことによって、現在のcommit
オブジェクトのハッシュ値だけ分かれば過去の状態を遡ることができるようになっています。これが高パフォーマンスでバージョン管理ができるようになっている要因の1つなのですね。
もう少しで終わります。
$ cat .git/HEAD
$ cat .git/refs/heads/master
1つ目のコマンドで
ref: refs/heads/master
2つ目のコマンドで
60553e5e46bfa5ccebc635cb8df4a805ba31d4de
が表示されましたでしょうか。
既に普段からgitを使っている人はgit reset --hard HEAD^
みたいにしてHEAD
というものを使ったことがある人がいるかもしれませんが、これは単にrefs/heads/master
にあるハッシュ値を指しているのですね(master
ブランチにいる場合)。そしてそこにあるハッシュ値60553e5e46bfa5ccebc635cb8df4a805ba31d4de
は、さっき見た2つ目のcommit
オブジェクトのハッシュ値です。gitのブランチは最新のオブジェクトを指しているだけ、という話を聞いたことがあるかもしれませんが、それはこのことです。master
ブランチはハッシュ値60553e5e46bfa5ccebc635cb8df4a805ba31d4de
のcommit
オブジェクトを指しているだけで、それより前のcommit
オブジェクトはこのcommit
オブジェクトのparent
のハッシュ値を辿っていけば全て見れます。すごいですよね。興味のある人は別のブランチを作ってrefs/heads/<ブランチ名>
とかを確認してみるといいと思います。
これでgit commit
についての説明は一通り終えたつもりです。実装はできていないので動画などはありません。
git log
おまけです。
実は過去にこんな勉強会があったらしいです。
https://www.youtube.com/watch?v=-4rcs6SgT0o&ab_channel=CAMPHOR-
ここでgit log
を実装していたので、git add
等の実装の参考にしようとしてgitの仕組みを勉強した後に最初にこれを追って実装していました。実装の雰囲気を掴んでみたい人はやってみるといいと思います。ただし、僕は前半しか見ていません。後半は先ほど少しだけ触れたpack
ディレクトリの話になって難しそうだったのでやめました。
最後に
僕は最初はgitがかなり苦手だったのですが、SysAd班で色々使っているうちに内部の仕組みに興味が持てるくらいまで苦手を克服できました。
興味を持ってくれた人がいれば是非さらに勉強して、余力があれば実装までやってみてほしいです。
また、traPでは毎年春にgit講習会というものを開催していて、git初心者がgitのいくつかの基本的なコマンドを使い、Pull Requestを出せるところまでサポートしています。gitを実際に使えるようになりたい人がいれば是非受講してみてください!
ちなみに、一応GitHubでリポジトリは公開しているのですが完成度がかなり微妙なので敢えてリンクは置きません。どうしても気になる人は探し出してみてください。
参考記事
gitの仕組みについてしっかり説明している記事は多くなかったのですが、参考にさせていただいた記事の中でも特によかったものを紹介させていただきます。僕のこの記事もgitの仕組みについて興味を持った人々の役に立つことができればと思います。
既に紹介したやつ
https://www.youtube.com/watch?v=-4rcs6SgT0o&ab_channel=CAMPHOR-
https://qiita.com/noshishi/items/60a6fe7c63097950911b
メルカリエンジニアブログ
https://engineering.mercari.com/blog/entry/2015-09-14-175300/
https://engineering.mercari.com/blog/entry/2016-02-08-173000/
https://engineering.mercari.com/blog/entry/2017-04-06-171430/
UUUMエンジニアブログ
https://system.blog.uuum.jp/entry/2020/09/17/110000
https://system.blog.uuum.jp/entry/2020/09/24/110000
リプセンスエンジニアブログ
https://made.livesense.co.jp/entry/2017/08/22/080000
git公式ドキュメント
https://git-scm.com/book/ja/v2
明日の担当は鵜崎くんとH1ronoくんです、お楽しみに!