Gitのローカルリポジトリを解析して履歴を再現する
目次
はじめに
こんにちは!株式会社ハイヤールーの新谷(@s_shintani)です。この記事はアドベントカレンダー14 日目の記事です。「Kubernetes を用いたオンライン開発環境の基盤設計」では実践形式において候補者が Git を使って開発を行うことのできる環境の構築について触れましたが、本記事では Git のローカルリポジトリを解析することにより候補者のコミットの履歴を再現し、レポート画面で可視化する部分に焦点を当ててお話をさせていただきます。
Git の構造
具体的な実装の詳細に入る前に、Git のローカルリポジトリ内部の構造について整理します。
Git とはソースコードの変更履歴を記録するためのバージョン管理システムであり、全ての変更履歴はローカルリポジトリを初期化した際に生成される.git ディレクトリ内部に存在します。
ファイルの変更履歴を記録するためには、各時点におけるファイルの状態を保持する必要があります。Git のコアの部分はシンプルなキー・バリュー型のデータストアであり、ファイルの状態が変更されると、データストアに blob オブジェクトが格納され、同時にチェックサムのハッシュ値が出力されます。
blob オブジェクトはファイルコンテンツの情報しか持っておらず、blob 単体ではそのコンテンツがどのファイルのコンテンツなのかわかりません。そこでディレクトリ構造となっているソースコードの状態を保持するために存在するのが tree オブジェクトです。tree オブジェクトはその名の通り木構造を有しており、子要素として blob オブジェクトまたは tree オブジェクトを持ちます。
tree オブジェクトがどの時点におけるソースコードであるかを示しているのが commit オブジェクトです。commit オブジェクトは tree オブジェクトへの参照を持っており、tree オブジェクトを再帰的に探索することでそのコミットが作成された時におけるファイルの状態を知ることができます。また commit オブジェクトは親となるコミットへのポインタを有しており、これにより変更の時系列を保持することができます。
以上が Git 内部の基本的な構造です。普段 Git を使用して開発を行う際にはブランチを作成したりタグを生成したりすることがあるかと思いますが、ブランチやタグは単に commit オブジェクトへの参照でしかありません。したがってファイルの変更履歴はすべて blob、tree、commit の 3 つのオブジェクトによって表現されます。
方針
候補者は Git を用いて開発を行い、その変更をリモートリポジトリにプッシュします。私たちは現在ソースリポジトリに GitHub を使用しており、GitHub の API を使って Git のヒストリーを可視化するというのが当初のプランでした。
しかし GitHub の API のレートリミットを超過してしまう可能性や、将来的にソースリポジトリを GCP の Cloud Source Repositories に移行することも視野に入れていたため、GitHub に依存すると移行時に再実装のコストがかかることなどを踏まえ、より低レイヤーな技術である Git だけを使ってヒストリーを復元することに決めました。
レポート画面で可視化したい情報は以下の 4 つです。
- コミットグラフ
- コミットにより変更のあったファイルツリー
- コミットにより生成されたファイルの差分
- 特定のファイルにおける時間軸ごとのファイルコンテンツの変化
これらを可視化する上で必要な情報はすべて.git ディレクトリの中に存在します。.git ディレクトリから必要な情報を抽出して UI で可視化するまでの大まかな流れとしては以下の通りです。
- 候補者のコードをビルドしてイメージを生成する際に、.git ディレクトリを GCS にアップロード
- その後.git ディレクトリを GCS からコピーして中身を解析し、可視化に必要なデータを整形して JSON 形式で GCS にアップロード
- リクエストに応じて必要な整形データを GCS から取得して返却
リクエストのたびに.git ディレクトリを GCS から取得するのは非効率であるため、あらかじめ UI で可視化しやすいように整形した JSON データを GCS に格納しておき、リクエストの際は必要なデータだけを取得して返却している点がポイントです。
実装
リポジトリの初期化
.git ディレクトリを解析する際には、go-gitと呼ばれるライブラリを使用しています。go-git ではコミットやファイルの差分の取得といった様々な API が提供されており、.git ディレクトリのファイルパスを指定することでそれらの API にアクセスすることが可能となります。
.git ディレクトリのパスを指定してリポジトリを返す関数を作成します。テスタビリティを考慮して、go-git の提供するリポジトリの型ではなく、事前に定義したインターフェースを返却します。
type Repository interface {
GetCommit(hash string) (*object.Commit, errors.ServiceError)
GetParents(commit *object.Commit) ([]*object.Commit, errors.ServiceError)
ListCommits(end string) ([]*object.Commit, errors.ServiceError)
GetTree(hash string) (*object.Tree, errors.ServiceError)
GetDiff(current *object.Tree, prev *object.Tree) (object.Changes, errors.ServiceError)
GetFileBody(file *object.File) (string, errors.ServiceError)
}
func (s *service) GetRepository(path string) (Repository, errors.ServiceError) {
repo, err := git.PlainOpen(path)
if err != nil {
return nil, errors.NewFromError(err, errors.Internal, failure.Messagef("failed to open git repository %s", path))
}
return &repository{
client: repo,
path: path,
}, nil
}
コミットグラフ
ブランチのマージがなされた際には一つのコミットが複数の親コミットを持つため、コミットの履歴はグラフ構造となっています。
UI 側ではgitgraph.jsを用いてコミットグラフの可視化を行っていますが、そこでブランチの作成、コミットの作成、ブランチのマージといった各アクションを時系列で指定する必要があるため、各アクションの配列を JSON データとして保存します。
先述の通りブランチはコミットへの参照に過ぎないため、例えばブランチを作成したタイムスタンプといった情報は.git 内部には存在しません。また作成したブランチは HEAD ディレクトリに格納されますが、ブランチが削除される可能性も踏まえ、基本的にはコミットの情報だけを使ってコミットグラフを再現しています。
どのブランチでそのコミットが行われたのかを把握する必要がありますが、コミットの共通祖先となるコミットを取得するためのmerge-baseと呼ばれる API を用いて分岐元のコミットを特定することができます。以下のような関数を用意してコミットツリーを再帰的に探索することで、ブランチとコミットを対応づけることができます。
func traverseCommitTree(root *object.Commit, baseHash string, branch string, res map[string]string) errors.ServiceError {
res[root.Hash.String()] = branch
var parents []*object.Commit
if err := root.Parents().ForEach(func(commit *object.Commit) error {
parents = append(parents, commit)
return nil
}); err != nil {
return errors.NewFromError(err, errors.Internal)
}
// Base case:
// 1. If there's no parents, stop traversing tree
// 2. If it reached to the base hash, we don't want to include any more commit
if len(parents) == 0 || parents[0].Hash.String() == baseHash {
return nil
}
for i, parent := range parents {
if i == 0 {
// The first parent is always on the same branch as the child one, so just continue to traverse
if err := traverseCommitTree(parent, baseHash, branch, res); err != nil {
return err
}
continue
}
// The second or more parent is on a different branch than the child one,
// then find the commit where two branches diverged
commonAncestors, err := parent.MergeBase(parents[0])
if err != nil {
return errors.NewFromError(err, errors.Internal)
}
// Continue to traverse. Commits are on the new branch until we get to the junction found the above
if err := traverseCommitTree(parent, commonAncestors[0].Hash.String(), fmt.Sprintf("unknown-%s", getRandomHash(5)), res); err != nil {
return err
}
}
return nil
}
最終的に GCS に格納される JSON データの構造は以下のようになります。各アクションが時系列順に配列構造をなしています。
[
{
"type": "CREATE_COMMIT",
"hash": "c5cc7e47f2ada749022cf457df84f18fd26a2113",
"message": "initial commit\n",
"branch": "main",
"date": "2022-12-12T23:44:23Z"
},
{
"type": "CREATE_BRANCH",
"name": "development"
},
{
"type": "CREATE_COMMIT",
"hash": "718e22c76d721686b5c4473335bebf306398edb6",
"message": "implemented \n",
"branch": "development",
"date": "2022-12-12T23:47:36Z"
},
{
"type": "MERGE_BRANCH",
"hash": "9574a723826d65f8c16653baf5332540df848295",
"source": "development",
"destination": "main",
"message": "merged development branch\n",
"date": "2022-12-12T23:50:45Z"
},
{
"type": "CREATE_COMMIT",
"hash": "ae34c6b440b5e659aae4862525dfe346b03931a3",
"message": "added new file\n",
"branch": "main",
"date": "2022-12-12T23:51:35Z"
}
]
コミットに紐づく変更ファイル
コミットグラフからコミットを指定すると、そのコミットと紐づく変更がファイルツリーで表示されます。このときその変更が追加(Addition)、変更(Modification)、削除(Deletion)のいずれかがわかるようにファイル名の隣にアイコンを表示します。
上記を実現するためにはコミットごとに変更のあったファイルとその変更の種類を保存する必要があります。
ファイルの差分をあらかじめ Map に保存しておき(後述)、コミットのリストをループすることでコミットとそのコミットに紐づく変更ファイルを対応づけます。
最終的に GCS に格納される JSON データの構造は以下のようになります。コミットが時系列順に配列構造をなしており、それぞれのコミットがどのファイルに変更があったのかという情報を diff の配列として持っています。データサイズを考慮してファイルコンテンツ自体はリストには保存せずに単体で取得する形になっています。
[
{
"hash": "a3516a1d7cdbfac71b40f36c45c7cb1e27ac0bfa",
"parent": "c5cc7e47f2ada749022cf457df84f18fd26a2113",
"diffs": [
{
"operation": "MODIFICATION",
"from_path": "src/App.tsx",
"to_path": "src/App.tsx",
"from_body": "",
"to_body": ""
}
],
"date": "2022-12-12T23:47:12Z"
},
{
"hash": "b27426766675d0ccc5c4ef0e9991ac63ab714009",
"parent": "c5cc7e47f2ada749022cf457df84f18fd26a2113",
"diffs": [
{
"operation": "MODIFICATION",
"from_path": "README.md",
"to_path": "README.md",
"from_body": "",
"to_body": ""
}
],
"date": "2022-12-12T23:47:57Z"
},
{
"hash": "ae34c6b440b5e659aae4862525dfe346b03931a3",
"parent": "9574a723826d65f8c16653baf5332540df848295",
"diffs": [
{
"operation": "ADDITION",
"from_path": "",
"to_path": "new.txt",
"from_body": "",
"to_body": ""
}
],
"date": "2022-12-12T23:51:35Z"
}
]
ファイルの差分
コミットに紐づく変更ファイルを指定すると、そのコミットによって生成されたファイルの差分がポップアップで表示されます。
上記を実現するためにはコミットとファイルパスに対応する差分を保存する必要があります。以下の関数はコミットの配列を引数として受け取り、コミットとファイルパスに対応するファイルの差分を Map に保存して返却します。
func getFileDiffs(commits []*object.Commit, repo git.Repository) (map[string]map[string]fileDiff, errors.ServiceError) {
commitFileDiffMap := map[string]map[string]fileDiff{}
for _, commit := range commits {
parents, serr := repo.GetParents(commit)
if serr != nil {
return nil, serr
}
tree, serr := repo.GetTree(commit.TreeHash.String())
if serr != nil {
return nil, serr
}
var parentTree *object.Tree
if len(parents) > 0 {
parentTree, serr = repo.GetTree(parents[0].TreeHash.String())
if serr != nil {
return nil, serr
}
}
changes, serr := repo.GetDiff(tree, parentTree)
if serr != nil {
return nil, serr
}
for _, change := range changes {
from, to, err := change.Files()
if err != nil {
return nil, errors.NewFromError(err, errors.Internal)
}
// lazy initialization
if commitFileDiffMap[commit.Hash.String()] == nil {
commitFileDiffMap[commit.Hash.String()] = map[string]fileDiff{}
}
if from == nil && to != nil {
// addition
toBody, serr := repo.GetFileBody(to)
if serr != nil {
return nil, serr
}
commitFileDiffMap[commit.Hash.String()][change.To.Name] = fileDiff{
Operation: Addition,
ToPath: change.To.Name,
ToBody: toBody,
}
} else if from != nil && to == nil {
// deletion
fromBody, serr := repo.GetFileBody(from)
if serr != nil {
return nil, serr
}
commitFileDiffMap[commit.Hash.String()][change.From.Name] = fileDiff{
Operation: Deletion,
FromPath: change.From.Name,
FromBody: fromBody,
}
} else if from != nil && to != nil && change.To.Name == change.From.Name {
// modification
fromBody, serr := repo.GetFileBody(from)
if serr != nil {
return nil, serr
}
toBody, serr := repo.GetFileBody(to)
if serr != nil {
return nil, serr
}
commitFileDiffMap[commit.Hash.String()][change.To.Name] = fileDiff{
Operation: Modification,
FromPath: change.To.Name,
ToPath: change.To.Name,
FromBody: fromBody,
ToBody: toBody,
}
} else if from != nil && to != nil && change.To.Name != change.From.Name {
// rename
fromBody, serr := repo.GetFileBody(from)
if serr != nil {
return nil, serr
}
toBody, serr := repo.GetFileBody(to)
if serr != nil {
return nil, serr
}
commitFileDiffMap[commit.Hash.String()][change.To.Name] = fileDiff{
Operation: Rename,
FromPath: change.From.Name,
ToPath: change.To.Name,
FromBody: fromBody,
ToBody: toBody,
}
}
}
}
return commitFileDiffMap, nil
}
最終的に以下のような構造の JSON データが、指定のコミットとファイルパスに対応する GCS のオブジェクトパス配下に保存されます。例えば、コミット 718e22c における src/App.tsx の差分データは、commits/71822c/src/App.tsx 配下に格納されます。ファイルの差分を取得する際にはこのオブジェクトパスを直接指定して GCS から取得します。
{
"operation": "MODIFICATION",
"from_path": "src/App.tsx",
"to_path": "src/App.tsx",
"from_body": "old body",
"to_body": "new body"
}
プレイバック
HireRoo には候補者の問題を解く過程を再現するプレイバック機能が搭載されており、実践形式のプレイバックでは各ファイルごとにコミット単位でファイルの状態がどのように変化したのかをコマ送りで再生することができます。
プレイバックに必要なデータは既に上記で GCS に保存されています。変更ファイルのデータから指定のファイルに関連するコミットを抽出し、差分データからコミットとファイルパスを指定してファイルのスナップショットを取得することで、コミット単位の時間軸におけるファイルの変化を再現することができます。
まとめ
最後までお読みいただきありがとうございます。普段当たり前のように使っている Git ですが、内部の構造について学ぶ機会は意外と少ないのではないでしょうか。Git 自体は非常にシンプルな構造であり、その構造さえ押さえておけばそこから必要な情報を可視化することはそれほど難しくはありません。
今回はレポート画面において変更履歴を可視化するために Git のローカルリポジトリの解析を行いましたが、将来的にはオンライン IDE のファイル同期を担うシステムに Git を統合して、候補者が開発中にオンライン IDE 上で Git の履歴を確認したり、コミットやプッシュを行うことができるといった機能拡張を行う予定です。
次回の記事は@icchy_sanによる「ハイヤールーの決済基盤はこのように動いている」です。そちらも是非ご期待ください。