はじめに
Gitの仕組みを理解しようと、Go言語でGitもどきを自作してみました。
その名も 「Goit」 です。
前回の記事では、Gitの仕組みについて説明しました。
執筆段階(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コマンドを実装しているファイルはこちらです。
package cmd
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/JunNishimura/Goit/internal/file"
"github.com/JunNishimura/Goit/internal/object"
"github.com/spf13/cobra"
)
func add(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("%w: %s", ErrIOHandling, path)
}
// make blob object
object, err := object.NewObject(object.BlobObject, data)
if err != nil {
return fmt.Errorf("fail to get new object: %w", err)
}
// get relative path
curPath, err := os.Getwd()
if err != nil {
return err
}
relPath, err := filepath.Rel(curPath, path)
if err != nil {
return err
}
cleanedRelPath := strings.ReplaceAll(relPath, `\`, "/") // replace backslash with slash
byteRelPath := []byte(cleanedRelPath)
// update index
isUpdated, err := client.Idx.Update(client.RootGoitPath, object.Hash, byteRelPath)
if err != nil {
return fmt.Errorf("fail to update index: %w", err)
}
if !isUpdated {
return nil
}
// write object to file
if err := object.Write(client.RootGoitPath); err != nil {
return fmt.Errorf("fail to write object: %w", err)
}
return nil
}
// addCmd represents the add command
var addCmd = &cobra.Command{
Use: "add",
Short: "register changes to index",
Long: "This is a command to register changes to index.",
PreRunE: func(cmd *cobra.Command, args []string) error {
if client.RootGoitPath == "" {
return ErrGoitNotInitialized
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
// args validation check
if len(args) == 0 {
return errors.New("nothing specified, nothing added")
}
for _, arg := range args {
if _, err := os.Stat(arg); os.IsNotExist(err) {
// If the file does not exist but is registered in the index, delete it from the index
// but not delete here, just check it
cleanedArg := filepath.Clean(arg)
cleanedArg = strings.ReplaceAll(cleanedArg, `\`, "/")
_, _, isEntryFound := client.Idx.GetEntry([]byte(cleanedArg))
if !isEntryFound {
return fmt.Errorf(`path "%s" did not match any files`, arg)
}
}
}
for _, arg := range args {
// check if the arg is the target of excluding path
cleanedArg := filepath.Clean(arg)
cleanedArg = strings.ReplaceAll(cleanedArg, `\`, "/")
if client.Ignore.IsIncluded(cleanedArg) {
continue
}
// If the file does not exist but is registered in the index, delete it from the index
if _, err := os.Stat(arg); os.IsNotExist(err) {
_, entry, isEntryFound := client.Idx.GetEntry([]byte(cleanedArg))
if !isEntryFound {
return fmt.Errorf(`path "%s" did not match any files`, arg)
}
if err := client.Idx.DeleteEntry(client.RootGoitPath, entry); err != nil {
return fmt.Errorf("fail to delete untracked file %s: %w", cleanedArg, err)
}
continue
}
path, err := filepath.Abs(arg)
if err != nil {
return fmt.Errorf("fail to convert abs path: %s", arg)
}
// directory
if f, err := os.Stat(arg); !os.IsNotExist(err) && f.IsDir() {
filePaths, err := file.GetFilePathsUnderDirectory(path)
if err != nil {
return fmt.Errorf("fail to get file path under directory: %w", err)
}
for _, filePath := range filePaths {
if err := add(filePath); err != nil {
return err
}
}
} else {
if err := add(path); err != nil {
return err
}
}
}
return nil
},
}
func init() {
rootCmd.AddCommand(addCmd)
}
add
コマンドの大まかな処理の流れは以下の通りです。
- 引数として指定されたファイルが存在するかを確かめる
- Ignore対象でないかを確かめる
- 指定されたファイルからBlobオブジェクトを作成する
- インデックスを更新
- オブジェクトをファイルに書き込む
それでは一つずつ見ていきましょう。
1. 引数として指定されたファイルが存在するかを確かめる
for _, arg := range args {
if _, err := os.Stat(arg); os.IsNotExist(err) {
// If the file does not exist but is registered in the index, delete it from the index
// but not delete here, just check it
cleanedArg := filepath.Clean(arg)
cleanedArg = strings.ReplaceAll(cleanedArg, `\`, "/")
_, _, isEntryFound := client.Idx.GetEntry([]byte(cleanedArg))
if !isEntryFound {
return fmt.Errorf(`path "%s" did not match any files`, arg)
}
}
}
forループで各引数を見て、その引数として渡されたファイル名が存在するかどうかを確かめています。
ただし、削除したファイルに対してgit add
する場合もあり得るので、ファイルが存在しない場合、インデックスにそのファイルが登録されていないかを確かめます。インデックスにも登録されていない場合は、引数が誤りであるためエラーを返します。
2. Ignore対象でないかを確かめる
cleanedArg := filepath.Clean(arg)
cleanedArg = strings.ReplaceAll(cleanedArg, `\`, "/")
if client.Ignore.IsIncluded(cleanedArg) {
continue
}
引数で渡されたパスを整形して、client.Ignore.IsIncluded
関数に渡しています。
func (i *Ignore) IsIncluded(path string) bool {
target := path
info, _ := os.Stat(path)
if info.IsDir() && !directoryRegexp.MatchString(path) {
target = fmt.Sprintf("%s/", path)
}
for _, exFile := range i.paths {
exRegexp := regexp.MustCompile(exFile)
if exRegexp.MatchString(target) {
return true
}
}
return false
}
ignore対象となるパスを正規表現として格納しておき、引数として渡したパスがその正規表現にマッチした場合はignore対象と判断しています。
3. 指定されたファイルからBlobオブジェクトを作成する
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("%w: %s", ErrIOHandling, path)
}
// make blob object
object, err := object.NewObject(object.BlobObject, data)
if err != nil {
return fmt.Errorf("fail to get new object: %w", err)
}
まずは指定されたファイルの中身を取得します。
そしてobjectパッケージのNewObject関数に、BlobObjectというobject typeと取得したデータをパラメーターとして渡します。
object.NewObject(object.BlobObject, data)
NewObjectの実装は以下の通りです。
func NewObject(objType Type, data []byte) (*Object, error) {
// get size of data
size := len(data)
// get hash of object
checkSum := sha1.New()
content := fmt.Sprintf("%s %d\x00%s", objType, size, data)
_, err := io.WriteString(checkSum, content)
if err != nil {
return nil, err
}
hash := checkSum.Sum(nil)
// make object
object := &Object{
Type: objType,
Hash: hash,
Size: size,
Data: data,
}
return object, nil
}
object構造体はtype
、hash
、size
、data
をフィールドに持っているので、それぞれのフィールドに値をセットしていくことで、objectを生成します。
4. インデックスを更新
isUpdated, err := client.Idx.Update(client.RootGoitPath, object.Hash, byteRelPath)
if err != nil {
return fmt.Errorf("fail to update index: %w", err)
}
if !isUpdated {
return nil
}
ここではまずインデックスを更新します。更新の必要ない場合は、後続の処理を行う必要がないので、スキップします。
インデックスの更新部分は次の通りです。
func (idx *Index) Update(rootGoitPath string, hash sha.SHA1, path []byte) (bool, error) {
pos, gotEntry, isFound := idx.GetEntry(path)
if isFound && string(gotEntry.Hash) == string(hash) && string(gotEntry.Path) == string(path) {
return false, nil
}
// add new entry and update index entries
entry := NewEntry(hash, path)
if pos != newEntryFlag {
// remove existing entry
idx.Entries = append(idx.Entries[:pos], idx.Entries[pos+1:]...)
}
idx.Entries = append(idx.Entries, entry)
idx.EntryNum = uint32(len(idx.Entries))
sort.Slice(idx.Entries, func(i, j int) bool { return string(idx.Entries[i].Path) < string(idx.Entries[j].Path) })
if err := idx.write(rootGoitPath); err != nil {
return false, err
}
return true, nil
}
インデックスには、各エントリ毎のファイルパスとハッシュ値が格納されています。新規追加ファイルと同じパス、ハッシュ値を持つエントリが既にインデックスに登録されている場合、インデックスを登録する必要がないため、returnします。
インデックスを更新する場合でも
- 新規追加ファイル
- インデックスには登録されているけど、中身が変わった(ハッシュが変わった)ことによりインデックスを更新する必要のあるファイル
とでは処理が少し異なります。1の場合は単純にエントリを格納しているスライスに追加するだけで良いですが、2の場合は既存のエントリを一度消してから、新しいエントリを追加しています。
そして、新規エントリ追加後はインデックスをファイルパスでソートし、更新したインデックスをファイルに書き込みます。
5. オブジェクトをファイルに書き込む
// write object to file
if err := object.Write(client.RootGoitPath); err != nil {
return fmt.Errorf("fail to write object: %w", err)
}
object.Write
の実装部分は次のようになっています。
func (o *Object) Write(rootGoitPath string) error {
buf, err := o.compress()
if err != nil {
return err
}
dirPath := filepath.Join(rootGoitPath, "objects", o.Hash.String()[:2])
filePath := filepath.Join(dirPath, o.Hash.String()[2:])
if f, err := os.Stat(dirPath); os.IsNotExist(err) || !f.IsDir() {
if err := os.Mkdir(dirPath, os.ModePerm); err != nil {
return fmt.Errorf("%w: %s", ErrIOHandling, dirPath)
}
}
f, err := os.Create(filePath)
if err != nil {
return fmt.Errorf("%w: %s", ErrIOHandling, filePath)
}
defer f.Close()
if _, err := f.Write(buf.Bytes()); err != nil {
return fmt.Errorf("%w: %s", ErrIOHandling, filePath)
}
return nil
}
まず、オブジェクトのデータをzlibで圧縮しています。その後適当なパスに対してファイルを作成し、圧縮したデータを書き込みます。
ここまでの流れでgit add
の実装は終了です。それでは次にgit commit
について見ていきましょう。
commit
commit
コマンドを実装しているファイルはこちらです。
package cmd
import (
"errors"
"fmt"
"os"
"path/filepath"
"time"
"github.com/JunNishimura/Goit/internal/log"
"github.com/JunNishimura/Goit/internal/object"
"github.com/JunNishimura/Goit/internal/sha"
"github.com/JunNishimura/Goit/internal/store"
"github.com/spf13/cobra"
)
var (
message string
ErrUserNotSetOnConfig = errors.New(`
*** Please tell me who you are.
Run
goit config user.email "you@example.com"
goit config user.name "Your name"
to set your account's default identity.
`)
ErrNothingToCommit = errors.New("nothing to commit, working tree clean")
)
func commit() error {
// make and write tree object
treeObject, err := writeTreeObject(client.RootGoitPath, client.Idx.Entries)
if err != nil {
return err
}
// make and write commit object
var data []byte
branchPath := filepath.Join(client.RootGoitPath, "refs", "heads", client.Head.Reference)
branchBytes, err := os.ReadFile(branchPath)
author := object.NewSign(client.Conf.GetUserName(), client.Conf.GetEmail())
committer := author
if err != nil {
// no branch means that this is the initial commit
data = []byte(fmt.Sprintf("tree %s\nauthor %s\ncommitter %s\n\n%s\n", treeObject.Hash, author, committer, message))
} else {
parentHash := string(branchBytes)
data = []byte(fmt.Sprintf("tree %s\nparent %s\nauthor %s\ncommitter %s\n\n%s\n", treeObject.Hash, parentHash, author, committer, message))
}
commitObject, err := object.NewObject(object.CommitObject, data)
if err != nil {
return fmt.Errorf("fail to get new object: %w", err)
}
commit, err := object.NewCommit(commitObject)
if err != nil {
return fmt.Errorf("fail to make commit object: %w", err)
}
if err := commit.Write(client.RootGoitPath); err != nil {
return fmt.Errorf("fail to write commit object: %w", err)
}
// create/update branch
var from sha.SHA1
if client.Refs.IsBranchExist(client.Head.Reference) {
// update
if err := client.Refs.UpdateBranchHash(client.RootGoitPath, client.Head.Reference, commit.Hash); err != nil {
return fmt.Errorf("fail to update branch %s: %w", client.Head.Reference, err)
}
from = client.Head.Commit.Hash
} else {
// create
if err := client.Refs.AddBranch(client.RootGoitPath, client.Head.Reference, commit.Hash); err != nil {
return fmt.Errorf("fail to create branch %s: %w", client.Head.Reference, err)
}
from = nil
}
// log
record := log.NewRecord(log.CommitRecord, from, commit.Hash, client.Conf.GetUserName(), client.Conf.GetEmail(), time.Now(), message)
if err := gLogger.WriteHEAD(record); err != nil {
return fmt.Errorf("log error: %w", err)
}
if err := gLogger.WriteBranch(record, client.Head.Reference); err != nil {
return fmt.Errorf("log error: %w", err)
}
// update HEAD
if err := client.Head.Update(client.Refs, client.RootGoitPath, client.Head.Reference); err != nil {
return fmt.Errorf("fail to update HEAD: %w", err)
}
return nil
}
func getEntriesFromTree(rootName string, nodes []*object.Node) ([]*store.Entry, error) {
var entries []*store.Entry
for _, node := range nodes {
if len(node.Children) == 0 {
var entryName string
if rootName == "" {
entryName = node.Name
} else {
entryName = fmt.Sprintf("%s/%s", rootName, node.Name)
}
newEntry := &store.Entry{
Hash: node.Hash,
NameLength: uint16(len(entryName)),
Path: []byte(entryName),
}
entries = append(entries, newEntry)
} else {
var newRootName string
if rootName == "" {
newRootName = node.Name
} else {
newRootName = fmt.Sprintf("%s/%s", rootName, node.Name)
}
childEntries, err := getEntriesFromTree(newRootName, node.Children)
if err != nil {
return nil, err
}
entries = append(entries, childEntries...)
}
}
return entries, nil
}
func isIndexDifferentFromTree(index *store.Index, tree *object.Tree) (bool, error) {
rootName := ""
gotEntries, err := getEntriesFromTree(rootName, tree.Children)
if err != nil {
return false, err
}
if len(gotEntries) != int(index.EntryNum) {
return true, nil
}
for i := 0; i < len(gotEntries); i++ {
if string(gotEntries[i].Path) != string(index.Entries[i].Path) {
return true, nil
}
if !gotEntries[i].Hash.Compare(index.Entries[i].Hash) {
return true, nil
}
}
return false, nil
}
func isCommitNecessary(commitObj *object.Commit) (bool, error) {
// get tree object
treeObject, err := object.GetObject(client.RootGoitPath, commitObj.Tree)
if err != nil {
return false, fmt.Errorf("fail to get tree object: %w", err)
}
// get tree
tree, err := object.NewTree(client.RootGoitPath, treeObject)
if err != nil {
return false, fmt.Errorf("fail to get tree: %w", err)
}
// compare index with tree
isDiff, err := isIndexDifferentFromTree(client.Idx, tree)
if err != nil {
return false, fmt.Errorf("fail to compare index with tree: %w", err)
}
return isDiff, nil
}
// commitCmd represents the commit command
var commitCmd = &cobra.Command{
Use: "commit",
Short: "commit",
Long: "this is a command to commit",
PreRunE: func(cmd *cobra.Command, args []string) error {
if client.RootGoitPath == "" {
return ErrGoitNotInitialized
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
if !client.Conf.IsUserSet() {
return ErrUserNotSetOnConfig
}
// see if committed before
dirName := filepath.Join(client.RootGoitPath, "refs", "heads")
files, err := os.ReadDir(dirName)
if err != nil {
return fmt.Errorf("%w: %s", ErrIOHandling, dirName)
}
if len(files) == 0 { // no commit before
if client.Idx.EntryNum == 0 {
return ErrNothingToCommit
}
// commit
if err := commit(); err != nil {
return err
}
} else {
// compare last commit with index
isDiff, err := isCommitNecessary(client.Head.Commit)
if err != nil {
return fmt.Errorf("fail to compare last commit with index: %w", err)
}
if !isDiff {
return ErrNothingToCommit
}
// commit
if err := commit(); err != nil {
return fmt.Errorf("fail to commit: %w", err)
}
}
return nil
},
}
func init() {
rootCmd.AddCommand(commitCmd)
commitCmd.Flags().StringVarP(&message, "message", "m", "", "commit message")
}
commit
コマンドの大まかな流れは以下の通りです。
- configの設定がされているか確認
- コミットの必要があるか(インデックスが更新されているか)の確認
- ツリーオブジェクトの作成
- コミットオブジェクトの作成
- ブランチの更新
- log書き出し
- HEADの更新
1. configの設定がされているか確認
if !client.Conf.IsUserSet() {
return ErrUserNotSetOnConfig
}
IsUserSetの実装部分は次の通りです。
func (c *Config) IsUserSet() bool {
localKV, localOK := c.local["user"]
globalKV, globalOK := c.global["user"]
if !localOK && !globalOK {
return false
}
if _, ok := localKV["name"]; !ok {
if _, ok := globalKV["name"]; !ok {
return false
}
}
if _, ok := localKV["email"]; !ok {
if _, ok := globalKV["email"]; !ok {
return false
}
}
return true
}
ここでは、local(.goit/.goitconfig)あるいはglobal(~/.goitconfig)のconfigファイルにuser(name & email)が設定されているかを確認しています。
ユーザー情報(name & email)はコミットに含まれるCommitterとAuthorで参照するので設定されている必要があるため、ここで確認しています。
2. コミットの必要があるか(インデックスが更新されているか)の確認
// compare last commit with index
isCommitNecessary, err := isCommitNecessary(client.Head.Commit)
if err != nil {
return fmt.Errorf("fail to compare last commit with index: %w", err)
}
if !isCommitNecessary {
return ErrNothingToCommit
}
isCommitNecessary
関数の実装は次のとおりです。
func isCommitNecessary(commitObj *object.Commit) (bool, error) {
// get tree object
treeObject, err := object.GetObject(client.RootGoitPath, commitObj.Tree)
if err != nil {
return false, fmt.Errorf("fail to get tree object: %w", err)
}
// get tree
tree, err := object.NewTree(client.RootGoitPath, treeObject)
if err != nil {
return false, fmt.Errorf("fail to get tree: %w", err)
}
// compare index with tree
isDiff, err := isIndexDifferentFromTree(client.Idx, tree)
if err != nil {
return false, fmt.Errorf("fail to compare index with tree: %w", err)
}
return isDiff, nil
}
コミットの必要がある、コミットに意味がある場合というのはインデックスが更新されている場合です。インデックスの更新有無検知はひとつ前のコミット(のツリーオブジェクト)と比較することで実現します。ということで、ここではHEADが指しているコミットオブジェクトが内包しているツリーオブジェクトとインデックスを比較しています。
3. ツリーオブジェクトの作成
// make and write tree object
treeObject, err := writeTreeObject(client.RootGoitPath, client.Idx.Entries)
if err != nil {
return err
}
ツリーオブジェクトはインデックスをもとに作成されるので、引数でインデックスを渡しています。writeTreeObject
の実装は次のとおりです。
func writeTreeObject(rootGoitPath string, entries []*index.Entry) (*object.Object, error) {
var dirName string
var data []byte
var entryBuf []*index.Entry
i := 0
for {
if i >= len(entries) {
// if the last entry is in the directory
if dirName != "" {
treeObject, err := writeTreeObject(rootGoitPath, entryBuf)
if err != nil {
return nil, err
}
data = append(data, []byte(fmt.Sprintf("040000 %s", dirName))...)
data = append(data, 0x00)
data = append(data, treeObject.Hash...)
}
break
}
entry := entries[i]
slashSplit := strings.SplitN(string(entry.Path), "/", 2)
if len(slashSplit) == 1 { // if entry is not in sub-directory
if dirName != "" { // if previous entry is in sub-directory
// make tree object from entryBuf
treeObject, err := writeTreeObject(rootGoitPath, entryBuf)
if err != nil {
return nil, err
}
data = append(data, []byte(fmt.Sprintf("040000 %s", dirName))...)
data = append(data, 0x00)
data = append(data, treeObject.Hash...)
// clear dirName and entryBuf
dirName = ""
entryBuf = make([]*index.Entry, 0)
}
data = append(data, []byte(fmt.Sprintf("100644 %s", string(entry.Path)))...)
data = append(data, 0x00)
data = append(data, entry.Hash...)
} else { // if entry is in sub-directory
if dirName == "" { // previous entry is not in sub-directory
dirName = slashSplit[0] // root sub-directory name e.x) cmd/pkg/main.go -> cmd
newEntry := index.NewEntry(entry.Hash, []byte(slashSplit[1]))
entryBuf = append(entryBuf, newEntry)
} else if dirName != "" && dirName == slashSplit[0] { // previous entry is in sub-directory, and current entry is in the same sub-directory
newEntry := index.NewEntry(entry.Hash, []byte(slashSplit[1]))
entryBuf = append(entryBuf, newEntry)
} else if dirName != "" && dirName != slashSplit[0] { // previous entry is in sub-directory, and current entry is in the different sub-directory
// make tree object
treeObject, err := writeTreeObject(rootGoitPath, entryBuf)
if err != nil {
return nil, err
}
data = append(data, []byte(fmt.Sprintf("040000 %s", dirName))...)
data = append(data, 0x00)
data = append(data, treeObject.Hash...)
// start making tree object for different sub-directory
dirName = slashSplit[0]
newEntry := index.NewEntry(entry.Hash, []byte(slashSplit[1]))
entryBuf = []*index.Entry{newEntry}
}
}
i++
}
// make tree object
treeObject, err := object.NewObject(object.TreeObject, data)
if err != nil {
return nil, err
}
// write tree object
if err := treeObject.Write(rootGoitPath); err != nil {
return nil, err
}
return treeObject, nil
}
少し複雑な実装になっていますが、内容自体はそれほど複雑ではありません。
インデックスに登録されているエントリを一つずつ走査し、エントリがBlobオブジェクトであれば、そのまま追加し、エントリがTreeオブジェクトであれば、writeTreeObjectを再帰的に呼び出してTreeオブジェクトを作成します。
ただ、インデックスのエントリは全てBlobオブジェクトとして登録されているので、エントリのパスからそのエントリがサブディレクトリに含まれているかを確認します。例えばエントリのパスがtmp/test.txt
であれば、test.txtはtmpディレクトリ内にあるとして、「test.txt」Blobオブジェクトを持つ「tmp」Treeオブジェクトを作ります。
4. コミットオブジェクトの作成
commitObject, err := object.NewObject(object.CommitObject, data)
if err != nil {
return fmt.Errorf("fail to get new object: %w", err)
}
commit, err := object.NewCommit(commitObject)
if err != nil {
return fmt.Errorf("fail to make commit object: %w", err)
}
if err := commit.Write(client.RootGoitPath); err != nil {
return fmt.Errorf("fail to write commit object: %w", err)
}
まずはcommitObjectを作成して、それをもとにcommitを作成します。NewObject
メソッドは上で説明したので省略します。
NewCommit
メソッドは次のとおりです。
func NewCommit(o *Object) (*Commit, error) {
if o.Type != CommitObject {
return nil, ErrNotCommitObject
}
commit := &Commit{
Object: o,
}
buf := bytes.NewReader(o.Data)
scanner := bufio.NewScanner(buf)
for scanner.Scan() {
text := scanner.Text()
splitText := strings.SplitN(text, " ", 2)
if len(splitText) != 2 {
break
}
lineType := splitText[0]
body := splitText[1]
switch lineType {
case "tree":
hash, err := sha.ReadHash(body)
if err != nil {
return nil, err
}
commit.Tree = hash
case "parent":
hash, err := sha.ReadHash(body)
if err != nil {
return nil, err
}
commit.Parents = append(commit.Parents, hash)
case "author":
sign, err := readSign(body)
if err != nil {
return nil, err
}
commit.Author = sign
case "committer":
sign, err := readSign(body)
if err != nil {
return nil, err
}
commit.Committer = sign
}
}
message := make([]string, 0)
for scanner.Scan() {
message = append(message, scanner.Text())
}
commit.Message = strings.Join(message, "\n")
return commit, nil
}
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. ブランチの更新
if client.Refs.IsBranchExist(client.Head.Reference) {
// update
if err := client.Refs.UpdateBranchHash(client.RootGoitPath, client.Head.Reference, commit.Hash); err != nil {
return fmt.Errorf("fail to update branch %s: %w", client.Head.Reference, err)
}
from = client.Head.Commit.Hash
} else {
// create
if err := client.Refs.AddBranch(client.RootGoitPath, client.Head.Reference, commit.Hash); err != nil {
return fmt.Errorf("fail to create branch %s: %w", client.Head.Reference, err)
}
from = nil
}
ブランチがすでに存在する場合とそうでない場合で場合分けをしています。
既に存在する場合は、ブランチのハッシュ値のみ更新します。
func (r *Refs) UpdateBranchHash(rootGoitPath, branchName string, newHash sha.SHA1) error {
n := r.getBranchPos(branchName)
if n == NewBranchFlag {
return fmt.Errorf("branch '%s' does not exist", branchName)
}
branch := r.Heads[n]
branch.hash = newHash
// write file
if err := branch.write(rootGoitPath); err != nil {
return fmt.Errorf("fail to write branch: %w", err)
}
return nil
}
branchのハッシュを書き換えて、branchファイルを上書きします。
存在しない場合は、新しくブランチを追加します。
func (r *Refs) AddBranch(rootGoitPath, newBranchName string, newBranchHash sha.SHA1) error {
// check if branch already exists
n := r.getBranchPos(newBranchName)
if n != NewBranchFlag {
return fmt.Errorf("a branch named '%s' already exists", newBranchName)
}
b := newBranch(newBranchName, newBranchHash)
r.Heads = append(r.Heads, b)
// write file
if err := b.write(rootGoitPath); err != nil {
return fmt.Errorf("fail to write branch: %w", err)
}
// sort heads
sort.Slice(r.Heads, func(i, j int) bool { return r.Heads[i].Name < r.Heads[j].Name })
return nil
}
RefsのHeadsに新規ブランチを追加して、ソートします。
6. log書き出し
// log
record := log.NewRecord(log.CommitRecord, from, commit.Hash, client.Conf.GetUserName(), client.Conf.GetEmail(), time.Now(), message)
if err := gLogger.WriteHEAD(record); err != nil {
return fmt.Errorf("log error: %w", err)
}
if err := gLogger.WriteBranch(record, client.Head.Reference); err != nil {
return fmt.Errorf("log error: %w", err)
}
Gitでは.git/logs配下でログファイルを管理しています。commitでは.git/logs/HEADのHEADファイルと.git/logs/refs/heads/のブランチファイルにログを残します。
7. HEADの更新
// update HEAD
if err := client.Head.Update(client.Refs, client.RootGoitPath, client.Head.Reference); err != nil {
return fmt.Errorf("fail to update HEAD: %w", err)
}
Head.Update
は以下のとおりに実装しています。
func (h *Head) Update(refs *Refs, rootGoitPath, newRef string) error {
// check if branch exists
n := refs.getBranchPos(newRef)
if n == NewBranchFlag {
return fmt.Errorf("branch %s does not exist", newRef)
}
headPath := filepath.Join(rootGoitPath, "HEAD")
if _, err := os.Stat(headPath); os.IsNotExist(err) {
return errors.New("fail to find HEAD, cannot update")
}
f, err := os.Create(headPath)
if err != nil {
return fmt.Errorf("fail to create HEAD: %w", err)
}
defer f.Close()
if _, err := f.WriteString(fmt.Sprintf("ref: refs/heads/%s", newRef)); err != nil {
return fmt.Errorf("fail to write HEAD: %w", err)
}
h.Reference = newRef
// get commit from branch
branchPath := filepath.Join(rootGoitPath, "refs", "heads", newRef)
if _, err := os.Stat(branchPath); os.IsNotExist(err) {
return fmt.Errorf("fail to find branch %s: %w", newRef, err)
}
commit, err := getHeadCommit(newRef, rootGoitPath)
if err != nil {
return ErrInvalidHead
}
h.Commit = commit
return nil
}
HEADファイルを書き換えて、HEADのコミットをHeadに登録しています。
まとめ
前編と後編に分けて、GitをGoで自作した話について書きました。
この記事がGitを理解する、Gitを自作する上で何かしらの役に立つのであれば幸いです。
また、今後も「Goit」の開発を進めるていくつもりなので、レポジトリにスターを付けてくださると励みになります。
参考資料
<Gitについて>
<Gitの仕組みについて>
- Git の仕組み (1)
- Git の仕組み (2) - コミット・ブランチ・タグ
- Gitのインデックスの中身
- Gitのブランチの実装
- Gitのオブジェクトの中身
- GitのHEADとは何者なのか
- たぶんもう怖くないGit ~Git内部の仕組み~
- コミットはスナップショットであり差分ではない
- コマンドを使わずに理解するGit
- Gitを作ってみる(理解編)
- Gitが連想配列記憶装置であることを低レイヤーな操作を通して体感しよう!
- Git が内部でデータを取り扱う方法
- Gitの内側
- Git Internals part 1: The git object model
- What does the git index contain EXACTLY?
- What is the internal format of a Git tree object?