概要
CodeCraftersというサービスの中の課題で、Gitの一部をgolangで実装しました。
その際に、commitなどのGitオブジェクトの保存を実装する必要があったため、理解のために調査したことをまとめました。
Gitオブジェクトとは
Git管理されているプロジェクトの/projectroot/.git/objectsディレクトリ配下で管理されている、コンテンツを指します。
Gitはキー・バリュー方式のデータストアとして構築されています。
つまり、Gitは/projectroot/.git/objectsディレクトリ配下に保存されたあらゆるコンテンツを一意に特定するキーを返し、私たちはそのキーを用いていつでもコンテンツを取り出すことができます。
Gitオブジェクトの種類
では、具体的にGitオブジェクトにはどのようなコンテンツがあるのかを説明します。
以下の4つがGitオブジェクトです。
- blobオブジェクト: ファイルを圧縮したもの。例えば、main.goやuser_usecase.tsなど、私たちが実際にプログラムを記述するファイルを圧縮したものを指します。
- treeオブジェクト: blobオブジェクトや別のtreeオブジェクトを管理する。役割としてはディレクトリと同じようなもので、
git commit
されたファイルやディレクトリの階層構造を保持するためのものです。 - commitオブジェクト: treeオブジェクトを包んだもの。コミットのスナップショットに対応するtreeオブジェクトに、親コミット、コミットメッセージなどを付加します。
- tagオブジェクト: 他のGitオブジェクトを包んだもの。基本的にcommitオブジェクトを包み、TagのメッセージやTagをつけた人の情報などを付加します。例えば、
git tag <tagname>
でバージョン管理をすることができます。
この記事では、blobオブジェクト、treeオブジェクト、commitオブジェクトについて説明します。

SHA-1(シャーワン)ハッシュ
.git/objectsディレクトリの下は、以下のように「2文字の英数字」のディレクトリ、「38文字の英数字」のファイルとなっています。
コンテンツ1つごとに1ファイルが生成されます。

これらはヘッダーと元々のコンテンツとを結合して、その新しいコンテンツから計算されたSHA-1ハッシュを用いています。
ヘッダーとはtree 190\u0000
のように、オブジェクトの種類とサイズを識別するためのものです。
Gitがヘッダを構築する際には、まず初めにオブジェクトのタイプを表す文字列が来ます。次に、スペース、コンテンツのサイズ、最後にヌルバイトが追加されます。
生成されたhashの最初の2文字が、objectsディレクトリのサブディレクトリ名、残りの38文字がファイル名となります。
golangでは以下のような実装になります。
package myhash
import (
"crypto/sha1"
"fmt"
)
func GetHashByBlob(blob []byte) string {
sha := sha1.Sum(blob)
sha1Hex := fmt.Sprintf("%x", sha)
return sha1Hex
}
zlib圧縮
Gitは、ヘッダーと元々のコンテンツとを結合して得た新たなコンテンツを、zlibを用いて圧縮して取得したbyte配列を、生成された38文字の名前のファイルに書き込みます。
zlib圧縮は、golangでは以下のような実装となります。
package myzlib
import (
"bytes"
"compress/zlib"
)
func CompressData(data []byte) ([]byte, error) {
var compressedData bytes.Buffer
zlibWriter := zlib.NewWriter(&compressedData)
// データをzlibWriterに書き込み
_, err := zlibWriter.Write(data)
if err != nil {
return nil, err
}
// zlibWriterを閉じて、バッファに圧縮データを書き込む
zlibWriter.Close()
// 圧縮されたデータを取得
compressedBytes := compressedData.Bytes()
return compressedBytes, nil
}
blobオブジェクト
ファイルを保存するために用いるGitオブジェクトです。
フォーマットは、元々のファイルコンテンツにblob ファイルサイズ\u0000
というヘッダを付与したものです。
これを元に計算したSHA-1ハッシュの後ろ38文字が名前となっているファイルに、zlib圧縮したbyte配列を保存します。
treeオブジェクト
上で挙げたblobオブジェクトだけではファイル内容は分かりますが、ファイル名(main.goやuser_usecase.tsなど)が保存されていません。
また、コミット内容にディレクトリがあるときの階層構造の保存が解決しません。
これらの問題を解決するのがtreeオブジェクトです。
以下に具体例とそれを表す図を示します。なお、SHA-1ハッシュの対応が分かりやすいよう、簡略化しています。(実際は40文字です)
例1
$ git cat-file -p master^{tree}
100644 blob hashA README // プロジェクトルートにあるREADMEファイル
100644 blob hashB Rakefile // プロジェクトルートにあるRakefileファイル
040000 tree hashC lib // プロジェクトルートにあるlibディレクトリ
例2
$ git cat-file -p hashC
100644 blob hashD simplegit.rb // libディレクトリの中にあるsimplegit.rbファイル
treeオブジェクトに含まれるコンテンツの要素としては4つあります。
左から
- モード・・・100644は通常ファイル、040000はtreeオブジェクトを示します。
- コンテンツの種類
- SHA-1ハッシュ
- ファイル・ディレクトリ名
ここで、 例2のコンテンツにtree <サイズ>
を付与したコンテンツを元に計算したSHA-1ハッシュがhashC
となります。
hashCを元にsimplegit.rbファイルのtreeオブジェクトを参照できることから、例1に含まれるlibディレクトリのSHA-1ハッシュは例2のtreeオブジェクトへのポインタであるということがわかります。

commitオブジェクト
コミット(スナップショット)を保存するためのものです。
追跡したいプロジェクトに対し、それぞれ異なる内容のスナップショットを示すツリー3つができました。ですが、各スナップショットを呼び戻すには3つのSHA-1の値すべてを覚えておかなければならない、という以前からの問題は残ったままです。 さらに、そのスナップショットを誰が、いつ、どのような理由で保存したのかについての情報が一切ありません。 これはコミットオブジェクトに保存される基本的な情報です。
10.2 Gitの内側 - Gitオブジェクト
具体的な内容は以下の通りです。commit <サイズ>
ヘッダーを付与したものから計算したSHA-1ハッシュが、git log
コマンド等で確認できるハッシュです。
$ git cat-file -p <commitのハッシュ>
tree 86bc8eec65ef3a3151ba3d7a2414d43ec07b6027
parent 390215c8d97d7612eb12e404f068a05fe9af7f59
author author-name <mail address> 1693773811 +0900
committer committor-name <mail address> 1693773811 +0900
comit-message

まず1行目は、このコミットが指し示すツリーオブジェクトのSHA-1ハッシュです。このハッシュは、例えば先ほど示した例1の内容に、tree <サイズ>
を付与したコンテンツから計算されたものです。
- 例1
tree <サイズ>
100644 blob hashA README // プロジェクトルートにあるREADMEファイル
100644 blob hashB Rakefile // プロジェクトルートにあるRakefileファイル
040000 tree hashC lib // プロジェクトルートにあるlibディレクトリ
2行目は、親コミットのSHA-1ハッシュで、親コミットがある場合のみ追加されます。
3行目、4行目は著者とコミッター情報です。名前、メールアドレス、コミット日時が含まれます。
最後に、改行の後にコミットメッセージが記述されます。
この日時はUNIX時間と呼びます。
golangでこれを計算するには、以下のような実装となります。
package mydate
import (
"fmt"
"time"
)
func GetNowUnixTimestamp() string {
// 現在の日付と時刻を取得
currentTime := time.Now()
// Unixエポックからの経過秒数
epochSeconds := currentTime.Unix()
// タイムゾーンオフセット(秒数)
offsetSeconds := currentTime.UTC().Sub(currentTime).Seconds()
sign := "+"
if offsetSeconds < 0 {
sign = "-"
offsetSeconds = -offsetSeconds
}
hours := int(offsetSeconds / 3600)
minutes := int((offsetSeconds - float64(hours)*3600) / 60)
formattedTimezoneOffset := fmt.Sprintf("%s%02d%02d", sign, hours, minutes)
// タイムゾーンオフセットを文字列に変換
unixTimestamp := fmt.Sprintf("%d %s", int(epochSeconds), formattedTimezoneOffset)
return unixTimestamp
}
まとめ
上記のようにGitはblob、tree、commitの各オブジェクトを個別のGitオブジェクトとして保存しています。その保存形式は、SHA-1を用いて、それが指し示すGitオブジェクトを一意に特定するキーバリュー形式です。