Advent Calendarネタが全然無いのでgitを作ることにしました。
gitは言わずと知れたバージョン管理ツールです。 多分エンジニアとして生きていく上で一番お世話になっているツールであり、今後もそうだと思います。ですが、gitの使い方は雰囲気分かっていてもgit自体についてあまり知らなかったのでとりあえずgoで作ってみることにしました。
とりあえず空のディレクトリでgit initを実行し、作成されるファイルを見てみます。
$ mkdir git
$ cd git
$ git init
$ tree .git
.git
├── HEAD
├── config
├── description
├── hooks
│ ├── applypatch-msg.sample
│ ├── commit-msg.sample
│ ├── fsmonitor-watchman.sample
│ ├── post-update.sample
│ ├── pre-applypatch.sample
│ ├── pre-commit.sample
│ ├── pre-merge-commit.sample
│ ├── pre-push.sample
│ ├── pre-rebase.sample
│ ├── pre-receive.sample
│ ├── prepare-commit-msg.sample
│ ├── push-to-checkout.sample
│ └── update.sample
├── info
│ └── exclude
├── objects
│ ├── info
│ └── pack
└── refs
├── heads
└── tags
git addは、ファイルをステージングエリアに追加するコマンドです。
ステージングエリアとは、git addしたファイルが保存される場所です。 git addしたファイルは、ステージングエリアに保存され、git commitしたときに、ステージングエリアにあるファイルがコミットされます。 簡単にいうと、ステージングエリアは、次にコミットするファイルを保存しておく場所です。
では、ステージングエリアに保存されるファイルはどこに保存されているのでしょうか。
ここで、git addの挙動を確認するために、git initで作成された.gitディレクトリ自体にもgitを設定しておきます
$ cd .git
$ git init
$ git add .
$ git commit -m "initial git status"
[master (root-commit) e8097c2] initial git status
17 files changed, 806 insertions(+)
create mode 100644 HEAD
...
ファイルを作成し、git add
してみます。
$ touch test.txt
$ git add test.txt
.gitディレクトリのindexファイルとobjectsディレクトリにファイルが作成されていることがわかります。
$ git status -u
On branch master
Untracked files:
(use "git add <file>..." to include in what will be committed)
index
objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391
2つともバイナリファイルとして作成されています。
$ hexdump -C index
00000000 44 49 52 43 00 00 00 02 00 00 00 01 65 6c 7d 01 |DIRC........el}.|
00000010 2f 35 d7 6e 65 6c 7d 01 2f 35 d7 6e 01 00 00 0e |/5.nel}./5.n....|
00000020 06 61 a5 1f 00 00 81 a4 00 00 01 f6 00 00 00 14 |.a..............|
00000030 00 00 00 00 e6 9d e2 9b b2 d1 d6 43 4b 8b 29 ae |...........CK.).|
00000040 77 5a d8 c2 e4 8c 53 91 00 08 74 65 73 74 2e 74 |wZ....S...test.t|
00000050 78 74 00 00 21 ec 61 8d 3b c7 43 20 62 cc ad 7a |xt..!.a.;.C b..z|
00000060 af 77 ba e0 be b5 c4 b0 |.w......|
00000068
$ hexdump -C objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391
00000000 78 01 4b ca c9 4f 52 30 60 00 00 09 b0 01 f0 |x.K..OR0`......|
0000000f
これらのファイルはそれぞれ、
- blobオブジェクトファイル
- indexファイル
というものです。
gitオブジェクトファイルは、gitの中心的な概念で、gitの中で扱う全てのデータは、このオブジェクトファイルとして保存されます。
オブジェクトファイルには、以下のような種類があります。
- blob
- tree
- commit
- tag
ここではステージングエリアに保存されるblobオブジェクトについて紹介します。
blobオブジェクトは、gitで扱うファイルの中身を保存するオブジェクトです。 blobオブジェクトは、ファイルにblobファイルサイズというヘッダ情報を付加し、zlibで圧縮したものです。 実際にblobファイルを作ってみましょう
func saveObjectFile(file *os.File) error {
// ファイルサイズを取得
stat, err := file.Stat()
if err != nil {
return err
}
fileSize := stat.Size()
// ファイルサイズからファイル名を取得
h := getHash(int(fileSize))
blobFileName := hex.EncodeToString(h)
targetPath := ".git/objects/" + blobFileName[:2]
// ディレクトリがなければ作成
// ディレクトリ名はblobファイル名の先頭2文字
if _, err := os.Stat(targetPath); os.IsNotExist(err) {
err := os.Mkdir(targetPath, 0755)
if err != nil {
return err
}
}
// ファイル名からblobオブジェクトファイルを作成
blobFile, err := os.Create(targetPath + "/" + blobFileName[2:])
if err != nil {
return err
}
defer blobFile.Close()
var content bytes.Buffer
// ファイルのヘッダーを書き込み
content.WriteString(fmt.Sprintf("blob %d\x00", fileSize))
// ファイルの中身を書き込み
_, err = io.Copy(&content, file)
// zlibで圧縮して書き込み
zw := zlib.NewWriter(blobFile)
defer zw.Close()
_, err = io.Copy(zw, &content)
if err != nil {
return err
}
fmt.Printf("blob file saved to %s\n", targetPath+"/"+blobFileName[2:])
return nil
}
func getFileName(fileSize int) string {
// ファイルサイズとblobのヘッダーを結合してsha1でハッシュ化
str := fmt.Sprintf("blob %d\x00", fileSize)
sha1 := sha1.New()
io.WriteString(sha1, str)
hash := hex.EncodeToString(sha1.Sum(nil))
return hash
}
次にindexファイルを作成します。
indexファイルは
- 12バイトのヘッダー。
- ソートされたindexエントリ で構成されます。
エントリというのはインデックスにあるファイルの情報のことで、以下のような構造体になっています。 具体的にはstat(2)の結果、ハッシュ、パス名などを格納しています。 ハッシュは前述のBlob ObjectのIDで、パス名はファイル名です。
git-goを参考に以下のようにstructを定義してみます。
type Index struct {
Version uint32
Entries []*Entry
}
type Entry struct {
Hash [20]byte
Name string
CreatedAt time.Time
ModifiedAt time.Time
Dev, Inode uint32
Mode uint32
UID, GID uint32
Size uint32
Stage uint8 // 1: merged Z: ancestor 2: ours 3: theirs
SkipWorktree bool
IntentToAdd bool
}
ここで、Stageというのは、git addしたときに、ステージングエリアに追加されたファイルのことです。 git addしたときに、ステージングエリアに追加されたファイルは、Stageが0になります。 git addした後に、ファイルを変更すると、Stageが2になります。 git addした後に、ファイルを削除すると、Stageが1になります。 git addした後に、ファイルを削除して、その後ファイルを変更すると、Stageが3になります。
indexファイルを作成するには、以下のようなコードを書きます。
var (
indexSignature = [4]byte{'D', 'I', 'R', 'C'}
entryHeaderLength = 62
)
func EncodeIndex(w io.Writer, index Index) error {
// ヘッダーを書き込み
if err := encodeIndexHeader(w, index); err != nil {
return err
}
// エントリーを書き込み
if err := encodeIndexEntries(w, index); err != nil {
return err
}
return nil
}
func encodeIndexHeader(w io.Writer, index Index) error {
bin := []interface{}{
indexSignature,
index.Version,
uint32(len(index.Entries)),
}
for _, v := range bin {
if err := binary.Write(w, binary.BigEndian, v); err != nil {
return err
}
}
return nil
}
func encodeIndexEntries(w io.Writer, index Index) error {
// エントリーを名前順にソート
sort.Slice(index.Entries, func(i, j int) bool {
return index.Entries[i].Name < index.Entries[j].Name
})
for _, entry := range index.Entries {
flags := uint16(entry.Stage&0x3) << 12
const nameMask = 0xFFF
if l := len(entry.Name); l < nameMask {
flags |= uint16(l)
} else {
flags |= nameMask
}
bin := []interface{}{
uint32(entry.CreatedAt.Unix()), uint32(entry.CreatedAt.Nanosecond()),
uint32(entry.ModifiedAt.Unix()), uint32(entry.ModifiedAt.Nanosecond()),
entry.Dev,
entry.Inode,
entry.Mode,
entry.UID,
entry.GID,
entry.Size,
entry.Hash[:],
flags,
}
for _, v := range bin {
if err := binary.Write(w, binary.BigEndian, v); err != nil {
return err
}
}
if _, err := io.WriteString(w, entry.Name); err != nil {
return err
}
padLen := 8 - (entryHeaderLength+len(entry.Name))%8
_, err := w.Write(bytes.Repeat([]byte{'\x00'}, padLen))
if err != nil {
return err
}
}
return nil
}
func NewEntry(f *os.File) (*Entry, error) {
stat, err := f.Stat()
if err != nil {
return nil, err
}
var h [20]byte
copy(h[:], getHash(int(stat.Size())))
return &Entry{
Hash: h,
Name: filepath.Base(f.Name()),
CreatedAt: stat.ModTime(),
ModifiedAt: stat.ModTime(),
Dev: uint32(stat.Sys().(*syscall.Stat_t).Dev),
Inode: uint32(stat.Sys().(*syscall.Stat_t).Ino),
Mode: uint32(stat.Mode()),
UID: uint32(stat.Sys().(*syscall.Stat_t).Uid),
GID: uint32(stat.Sys().(*syscall.Stat_t).Gid),
Size: uint32(stat.Size()),
Stage: 0,
SkipWorktree: false,
IntentToAdd: false,
}, nil
}
func NewIndex() *Index {
return &Index{
Version: 2,
Entries: []*Entry{},
}
}
func (i *Index) AddEntry(entry *Entry) {
i.Entries = append(i.Entries, entry)
}
では、先程作成したblobファイルとともにindexファイルを作成してみましょう。
package main
import (
"bytes"
"compress/zlib"
"crypto/sha1"
"encoding/binary"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"syscall"
"time"
)
func main() {
if err := run(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
func run() error {
path := os.Args[1]
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
if err := saveObjectFile(file); err != nil {
return err
}
if err := saveIndexFile(file); err != nil {
return err
}
return nil
}
上記のコードをgit_add.goという名前で保存します。
$ mkdir gittmp
$ cd gittmp
$ git init
git_add.goを実行します。
go run ../gitgo/git_add.go ../git/test.txt
作成されたファイルをそれぞれ確認してみます。
$ hexdump -C .git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391
00000000 78 9c 4a ca c9 4f 52 30 60 00 04 00 00 ff ff 09 |x.J..OR0`.......|
00000010 b0 01 f0 |...|
00000013
$ hexdump -C .git/index
00000000 44 49 52 43 00 00 00 02 00 00 00 01 65 6c 7d 01 |DIRC........el}.|
00000010 2f 35 d7 6e 65 6c 7d 01 2f 35 d7 6e 01 00 00 0e |/5.nel}./5.n....|
00000020 06 61 a5 1f 00 00 01 a4 00 00 01 f6 00 00 00 14 |.a..............|
00000030 00 00 00 00 e6 9d e2 9b b2 d1 d6 43 4b 8b 29 ae |...........CK.).|
00000040 77 5a d8 c2 e4 8c 53 91 00 08 74 65 73 74 2e 74 |wZ....S...test.t|
00000050 78 74 00 00 |xt..|
00000054
git statusを実行すると、test.txtがステージングエリアに追加されていることがわかります。
git status
On branch master
No commits yet
Changes to be committed:
(use "git rm --cached <file>..." to unstage)
new file: test.txt
Changes not staged for commit:
(use "git add/rm <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
deleted: test.txt
また、作成されたblobファイルが正しく読めるかについても確認してみます。 blobファイルはgit cat-fileで確認できます。
$ echo "test" > test2.txt
$ go run ../gitgo/git_add.go test2.txt
blob file saved to .git/objects/26/aec756de006da7efb3cf1ed7579562a428f91a
$ git cat-file -p 26aec756de006da7efb3cf1ed7579562a428f91a
test
git addの挙動を確認するために、git initで作成される.gitディレクトリにgit addで作成されるindexファイルとblobファイルをgoのコード作成し、そのファイルがgitコマンドで読めることを確認しました。
- https://engineering.mercari.com/blog/entry/2015-09-14-175300/
- https://engineering.mercari.com/blog/entry/2017-04-06-171430/
- https://zenn.dev/kaityo256/articles/objects_of_git
- https://git-scm.com/book/ja/v2/Git%E3%81%AE%E5%86%85%E5%81%B4-Git%E3%82%AA%E3%83%96%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88
- https://mincong.io/2018/04/28/git-index/
- https://pkg.go.dev/gopkg.in/src-d/go-git.v4/plumbing/format/index
- https://git-scm.com/book/en/v2/Git-Tools-Advanced-Merging