4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

GoでGitを自作してみた<後編>

Posted at

はじめに

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コマンドを実装しているファイルはこちらです。

add.go
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コマンドの大まかな処理の流れは以下の通りです。

  1. 引数として指定されたファイルが存在するかを確かめる
  2. Ignore対象でないかを確かめる
  3. 指定されたファイルからBlobオブジェクトを作成する
  4. インデックスを更新
  5. オブジェクトをファイルに書き込む

それでは一つずつ見ていきましょう。

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構造体はtypehashsizedataをフィールドに持っているので、それぞれのフィールドに値をセットしていくことで、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. インデックスには登録されているけど、中身が変わった(ハッシュが変わった)ことによりインデックスを更新する必要のあるファイル

とでは処理が少し異なります。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コマンドを実装しているファイルはこちらです。

commit.go
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コマンドの大まかな流れは以下の通りです。

  1. configの設定がされているか確認
  2. コミットの必要があるか(インデックスが更新されているか)の確認
  3. ツリーオブジェクトの作成
  4. コミットオブジェクトの作成
  5. ブランチの更新
  6. log書き出し
  7. 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の実装について>
4
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?