complete the storage/read process + tests (!)

Michael MurΓ© created

Change summary

bug/bug.go                       | 105 +++++++++++++++++++++++++++++-
bug/operation.go                 |   4 
bug/operation_pack.go            |  30 ++++++--
bug/operations/create.go         |   6 
bug/operations/operation_test.go |  57 ----------------
bug/operations/operations.go     |  10 ++
commands/new.go                  |   5 
notes                            |   3 
repository/git.go                |  69 ++++++++++++++++++-
repository/mock_repo.go          | 118 +++++++++++++++++++++++++++++++--
repository/repo.go               |  51 ++++++++++++++
tests/bug_test.go                |  29 ++++++++
tests/operation_pack_test.go     |  18 +---
13 files changed, 400 insertions(+), 105 deletions(-)

Detailed changes

bug/bug.go πŸ”—

@@ -1,6 +1,7 @@
 package bug
 
 import (
+	"errors"
 	"fmt"
 	"github.com/MichaelMure/git-bug/repository"
 	"github.com/MichaelMure/git-bug/util"
@@ -9,6 +10,8 @@ import (
 
 const BugsRefPattern = "refs/bugs/"
 const BugsRemoteRefPattern = "refs/remote/%s/bugs/"
+const OpsEntryName = "ops"
+const RootEntryName = "root"
 
 // Bug hold the data of a bug thread, organized in a way close to
 // how it will be persisted inside Git. This is the datastructure
@@ -42,6 +45,80 @@ func NewBug() (*Bug, error) {
 	}, nil
 }
 
+// Read and parse a Bug from git
+func ReadBug(repo repository.Repo, id string) (*Bug, error) {
+	hashes, err := repo.ListCommits(BugsRefPattern + id)
+
+	if err != nil {
+		return nil, err
+	}
+
+	parsedId, err := uuid.FromString(id)
+
+	if err != nil {
+		return nil, err
+	}
+
+	bug := Bug{
+		id: parsedId,
+	}
+
+	for _, hash := range hashes {
+		entries, err := repo.ListEntries(hash)
+
+		bug.lastCommit = hash
+
+		if err != nil {
+			return nil, err
+		}
+
+		var opsEntry repository.TreeEntry
+		opsFound := false
+		var rootEntry repository.TreeEntry
+		rootFound := false
+
+		for _, entry := range entries {
+			if entry.Name == OpsEntryName {
+				opsEntry = entry
+				opsFound = true
+				continue
+			}
+			if entry.Name == RootEntryName {
+				rootEntry = entry
+				rootFound = true
+			}
+		}
+
+		if !opsFound {
+			return nil, errors.New("Invalid tree, missing the ops entry")
+		}
+
+		if !rootFound {
+			return nil, errors.New("Invalid tree, missing the root entry")
+		}
+
+		if bug.root == "" {
+			bug.root = rootEntry.Hash
+		}
+
+		data, err := repo.ReadData(opsEntry.Hash)
+
+		if err != nil {
+			return nil, err
+		}
+
+		op, err := ParseOperationPack(data)
+
+		if err != nil {
+			return nil, err
+		}
+
+		bug.packs = append(bug.packs, *op)
+	}
+
+	return &bug, nil
+}
+
 // IsValid check if the Bug data is valid
 func (bug *Bug) IsValid() bool {
 	// non-empty
@@ -104,12 +181,13 @@ func (bug *Bug) Commit(repo repository.Repo) error {
 	root := bug.root
 	if root == "" {
 		root = hash
+		bug.root = hash
 	}
 
 	// Write a Git tree referencing this blob
-	hash, err = repo.StoreTree(map[string]util.Hash{
-		"ops":  hash, // the last pack of ops
-		"root": root, // always the first pack of ops (might be the same)
+	hash, err = repo.StoreTree([]repository.TreeEntry{
+		{repository.Blob, hash, OpsEntryName},  // the last pack of ops
+		{repository.Blob, root, RootEntryName}, // always the first pack of ops (might be the same)
 	})
 	if err != nil {
 		return err
@@ -126,6 +204,8 @@ func (bug *Bug) Commit(repo repository.Repo) error {
 		return err
 	}
 
+	bug.lastCommit = hash
+
 	// Create or update the Git reference for this bug
 	ref := fmt.Sprintf("%s%s", BugsRefPattern, bug.id.String())
 	err = repo.UpdateRef(ref, hash)
@@ -140,8 +220,12 @@ func (bug *Bug) Commit(repo repository.Repo) error {
 	return nil
 }
 
+func (bug *Bug) Id() string {
+	return fmt.Sprintf("%x", bug.id)
+}
+
 func (bug *Bug) HumanId() string {
-	return bug.id.String()
+	return fmt.Sprintf("%.8s", bug.Id())
 }
 
 func (bug *Bug) firstOp() Operation {
@@ -157,3 +241,16 @@ func (bug *Bug) firstOp() Operation {
 
 	return nil
 }
+
+// Compile a bug in a easily usable snapshot
+func (bug *Bug) Compile() Snapshot {
+	snap := Snapshot{}
+
+	it := NewOperationIterator(bug)
+
+	for it.Next() {
+		snap = it.Value().Apply(snap)
+	}
+
+	return snap
+}

bug/operation.go πŸ”—

@@ -3,7 +3,7 @@ package bug
 type OperationType int
 
 const (
-	UNKNOW OperationType = iota
+	UNKNOWN OperationType = iota
 	CREATE
 	SET_TITLE
 	ADD_COMMENT
@@ -15,7 +15,7 @@ type Operation interface {
 }
 
 type OpBase struct {
-	OperationType OperationType `json:"op"`
+	OperationType OperationType
 }
 
 func (op OpBase) OpType() OperationType {

bug/operation_pack.go πŸ”—

@@ -1,7 +1,8 @@
 package bug
 
 import (
-	"encoding/json"
+	"bytes"
+	"encoding/gob"
 	"github.com/MichaelMure/git-bug/repository"
 	"github.com/MichaelMure/git-bug/util"
 )
@@ -13,22 +14,35 @@ import (
 // inside Git to form the complete ordered chain of operation to
 // apply to get the final state of the Bug
 type OperationPack struct {
-	Operations []Operation `json:"ops"`
-	hash       util.Hash
+	Operations []Operation
 }
 
-func Parse() (OperationPack, error) {
-	// TODO
-	return OperationPack{}, nil
+func ParseOperationPack(data []byte) (*OperationPack, error) {
+	reader := bytes.NewReader(data)
+	decoder := gob.NewDecoder(reader)
+
+	var opp OperationPack
+
+	err := decoder.Decode(&opp)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &opp, nil
 }
 
 func (opp *OperationPack) Serialize() ([]byte, error) {
-	jsonBytes, err := json.Marshal(*opp)
+	var data bytes.Buffer
+
+	encoder := gob.NewEncoder(&data)
+	err := encoder.Encode(*opp)
+
 	if err != nil {
 		return nil, err
 	}
 
-	return jsonBytes, nil
+	return data.Bytes(), nil
 }
 
 // Append a new operation to the pack

bug/operations/create.go πŸ”—

@@ -11,9 +11,9 @@ var _ bug.Operation = CreateOperation{}
 
 type CreateOperation struct {
 	bug.OpBase
-	Title   string     `json:"t"`
-	Message string     `json:"m"`
-	Author  bug.Person `json:"a"`
+	Title   string
+	Message string
+	Author  bug.Person
 }
 
 func NewCreateOp(author bug.Person, title, message string) CreateOperation {

bug/operations/operation_test.go πŸ”—

@@ -1,57 +0,0 @@
-package operations
-
-import (
-	"github.com/MichaelMure/git-bug/bug"
-	"testing"
-)
-
-// Different type with the same fields
-type CreateOperation2 struct {
-	Title   string
-	Message string
-}
-
-func (op CreateOperation2) OpType() bug.OperationType {
-	return bug.UNKNOW
-}
-
-func (op CreateOperation2) Apply(snapshot bug.Snapshot) bug.Snapshot {
-	// no-op
-	return snapshot
-}
-
-func TestOperationsEquality(t *testing.T) {
-	var rene = bug.Person{
-		Name:  "RenΓ© Descartes",
-		Email: "rene@descartes.fr",
-	}
-
-	var A bug.Operation = NewCreateOp(rene, "title", "message")
-	var B bug.Operation = NewCreateOp(rene, "title", "message")
-	var C bug.Operation = NewCreateOp(rene, "title", "different message")
-
-	if A != B {
-		t.Fatal("Equal value ops should be tested equals")
-	}
-
-	if A == C {
-		t.Fatal("Different value ops should be tested different")
-	}
-
-	D := CreateOperation2{Title: "title", Message: "message"}
-
-	if A == D {
-		t.Fatal("Operations equality should handle the type")
-	}
-
-	var isaac = bug.Person{
-		Name:  "Isaac Newton",
-		Email: "isaac@newton.uk",
-	}
-
-	var E bug.Operation = NewCreateOp(isaac, "title", "message")
-
-	if A == E {
-		t.Fatal("Operation equality should handle the author")
-	}
-}

bug/operations/operations.go πŸ”—

@@ -0,0 +1,10 @@
+package operations
+
+import "encoding/gob"
+
+// Package initialisation used to register operation's type for (de)serialization
+func init() {
+	gob.Register(AddCommentOperation{})
+	gob.Register(CreateOperation{})
+	gob.Register(SetTitleOperation{})
+}

commands/new.go πŸ”—

@@ -59,9 +59,10 @@ func RunNewBug(repo repository.Repo, args []string) error {
 	createOp := operations.NewCreateOp(author, title, *newMessage)
 
 	newbug.Append(createOp)
-	newbug.Commit(repo)
 
-	return nil
+	err = newbug.Commit(repo)
+
+	return err
 
 }
 

notes πŸ”—

@@ -19,6 +19,9 @@ git show-ref refs/bug
 
 git update-index --add --cacheinfo 100644 83baae61804e65cc73a7201a7252750c76066a30 test.txt
 
+git show-ref --hash refs/bugs/4ef19f8a-2e6a-45f7-910e-52e3c639cd86
+
+git for-each-ref --format="%(refname)" "refs/bugs/*"
 
 
 Bug operations:

repository/git.go πŸ”—

@@ -134,15 +134,26 @@ func (repo *GitRepo) StoreData(data []byte) (util.Hash, error) {
 	return util.Hash(stdout), err
 }
 
-// StoreTree will store a mapping key-->Hash as a Git tree
-func (repo *GitRepo) StoreTree(mapping map[string]util.Hash) (util.Hash, error) {
-	var buffer bytes.Buffer
+// ReadData will attempt to read arbitrary data from the given hash
+func (repo *GitRepo) ReadData(hash util.Hash) ([]byte, error) {
+	var stdout bytes.Buffer
+	var stderr bytes.Buffer
 
-	for key, hash := range mapping {
-		buffer.WriteString(fmt.Sprintf("100644 blob %s\t%s\n", hash, key))
+	err := repo.runGitCommandWithIO(nil, &stdout, &stderr, "cat-file", "-p", string(hash))
+
+	if err != nil {
+		return []byte{}, err
 	}
 
+	return stdout.Bytes(), nil
+}
+
+// StoreTree will store a mapping key-->Hash as a Git tree
+func (repo *GitRepo) StoreTree(entries []TreeEntry) (util.Hash, error) {
+	buffer := prepareTreeEntries(entries)
+
 	stdout, err := repo.runGitCommandWithStdin(&buffer, "mktree")
+
 	if err != nil {
 		return "", err
 	}
@@ -179,3 +190,51 @@ func (repo *GitRepo) UpdateRef(ref string, hash util.Hash) error {
 
 	return err
 }
+
+// ListRefs will return a list of Git ref matching the given refspec
+func (repo *GitRepo) ListRefs(refspec string) ([]string, error) {
+	// the format option will strip the ref name to keep only the last part (ie, the bug id)
+	stdout, err := repo.runGitCommand("for-each-ref", "--format=%(refname:lstrip=-1)", refspec)
+
+	if err != nil {
+		return nil, err
+	}
+
+	splitted := strings.Split(stdout, "\n")
+
+	if len(splitted) == 1 && splitted[0] == "" {
+		return []string{}, nil
+	}
+
+	return splitted, nil
+}
+
+// ListCommits will return the list of commit hashes of a ref, in chronological order
+func (repo *GitRepo) ListCommits(ref string) ([]util.Hash, error) {
+	stdout, err := repo.runGitCommand("rev-list", "--first-parent", "--reverse", ref)
+
+	if err != nil {
+		return nil, err
+	}
+
+	splitted := strings.Split(stdout, "\n")
+
+	casted := make([]util.Hash, len(splitted))
+	for i, line := range splitted {
+		casted[i] = util.Hash(line)
+	}
+
+	return casted, nil
+
+}
+
+// ListEntries will return the list of entries in a Git tree
+func (repo *GitRepo) ListEntries(hash util.Hash) ([]TreeEntry, error) {
+	stdout, err := repo.runGitCommand("ls-tree", string(hash))
+
+	if err != nil {
+		return nil, err
+	}
+
+	return readTreeEntries(stdout)
+}

repository/mock_repo.go πŸ”—

@@ -1,14 +1,32 @@
 package repository
 
 import (
+	"crypto/sha1"
+	"fmt"
 	"github.com/MichaelMure/git-bug/util"
+	"github.com/pkg/errors"
 )
 
 // mockRepoForTest defines an instance of Repo that can be used for testing.
-type mockRepoForTest struct{}
+type mockRepoForTest struct {
+	blobs   map[util.Hash][]byte
+	trees   map[util.Hash]string
+	commits map[util.Hash]commit
+	refs    map[string]util.Hash
+}
+
+type commit struct {
+	treeHash util.Hash
+	parent   util.Hash
+}
 
 func NewMockRepoForTest() Repo {
-	return &mockRepoForTest{}
+	return &mockRepoForTest{
+		blobs:   make(map[util.Hash][]byte),
+		trees:   make(map[util.Hash]string),
+		commits: make(map[util.Hash]commit),
+		refs:    make(map[string]util.Hash),
+	}
 }
 
 // GetPath returns the path to the repo.
@@ -39,22 +57,106 @@ func (r *mockRepoForTest) PullRefs(remote string, refPattern string, remoteRefPa
 	return nil
 }
 
-func (r *mockRepoForTest) StoreData([]byte) (util.Hash, error) {
-	return "", nil
+func (r *mockRepoForTest) StoreData(data []byte) (util.Hash, error) {
+	rawHash := sha1.Sum(data)
+	hash := util.Hash(fmt.Sprintf("%x", rawHash))
+	r.blobs[hash] = data
+	return hash, nil
+}
+
+func (r *mockRepoForTest) ReadData(hash util.Hash) ([]byte, error) {
+	data, ok := r.blobs[hash]
+
+	if !ok {
+		return nil, errors.New("unknown hash")
+	}
+
+	return data, nil
 }
 
-func (r *mockRepoForTest) StoreTree(mapping map[string]util.Hash) (util.Hash, error) {
-	return "", nil
+func (r *mockRepoForTest) StoreTree(entries []TreeEntry) (util.Hash, error) {
+	buffer := prepareTreeEntries(entries)
+	rawHash := sha1.Sum(buffer.Bytes())
+	hash := util.Hash(fmt.Sprintf("%x", rawHash))
+	r.trees[hash] = buffer.String()
+
+	return hash, nil
 }
 
 func (r *mockRepoForTest) StoreCommit(treeHash util.Hash) (util.Hash, error) {
-	return "", nil
+	rawHash := sha1.Sum([]byte(treeHash))
+	hash := util.Hash(fmt.Sprintf("%x", rawHash))
+	r.commits[hash] = commit{
+		treeHash: treeHash,
+	}
+	return hash, nil
 }
 
 func (r *mockRepoForTest) StoreCommitWithParent(treeHash util.Hash, parent util.Hash) (util.Hash, error) {
-	return "", nil
+	rawHash := sha1.Sum([]byte(treeHash + parent))
+	hash := util.Hash(fmt.Sprintf("%x", rawHash))
+	r.commits[hash] = commit{
+		treeHash: treeHash,
+		parent:   parent,
+	}
+	return hash, nil
 }
 
 func (r *mockRepoForTest) UpdateRef(ref string, hash util.Hash) error {
+	r.refs[ref] = hash
 	return nil
 }
+
+func (r *mockRepoForTest) ListRefs(refspec string) ([]string, error) {
+	keys := make([]string, len(r.refs))
+
+	i := 0
+	for k := range r.refs {
+		keys[i] = k
+		i++
+	}
+
+	return keys, nil
+}
+
+func (r *mockRepoForTest) ListCommits(ref string) ([]util.Hash, error) {
+	var hashes []util.Hash
+
+	hash := r.refs[ref]
+
+	for {
+		commit, ok := r.commits[hash]
+
+		if !ok {
+			break
+		}
+
+		hashes = append([]util.Hash{hash}, hashes...)
+		hash = commit.parent
+	}
+
+	return hashes, nil
+}
+
+func (r *mockRepoForTest) ListEntries(hash util.Hash) ([]TreeEntry, error) {
+	var data string
+
+	data, ok := r.trees[hash]
+
+	if !ok {
+		// Git will understand a commit hash to reach a tree
+		commit, ok := r.commits[hash]
+
+		if !ok {
+			return nil, errors.New("unknown hash")
+		}
+
+		data, ok = r.trees[commit.treeHash]
+
+		if !ok {
+			return nil, errors.New("unknown hash")
+		}
+	}
+
+	return readTreeEntries(data)
+}

repository/repo.go πŸ”—

@@ -1,7 +1,11 @@
 // Package repository contains helper methods for working with a Git repo.
 package repository
 
-import "github.com/MichaelMure/git-bug/util"
+import (
+	"bytes"
+	"github.com/MichaelMure/git-bug/util"
+	"strings"
+)
 
 // Repo represents a source code repository.
 type Repo interface {
@@ -26,8 +30,11 @@ type Repo interface {
 	// StoreData will store arbitrary data and return the corresponding hash
 	StoreData(data []byte) (util.Hash, error)
 
+	// ReadData will attempt to read arbitrary data from the given hash
+	ReadData(hash util.Hash) ([]byte, error)
+
 	// StoreTree will store a mapping key-->Hash as a Git tree
-	StoreTree(mapping map[string]util.Hash) (util.Hash, error)
+	StoreTree(mapping []TreeEntry) (util.Hash, error)
 
 	// StoreCommit will store a Git commit with the given Git tree
 	StoreCommit(treeHash util.Hash) (util.Hash, error)
@@ -37,4 +44,44 @@ type Repo interface {
 
 	// UpdateRef will create or update a Git reference
 	UpdateRef(ref string, hash util.Hash) error
+
+	// ListRefs will return a list of Git ref matching the given refspec
+	ListRefs(refspec string) ([]string, error)
+
+	// ListCommits will return the list of tree hashes of a ref, in chronological order
+	ListCommits(ref string) ([]util.Hash, error)
+
+	// ListEntries will return the list of entries in a Git tree
+	ListEntries(hash util.Hash) ([]TreeEntry, error)
+}
+
+func prepareTreeEntries(entries []TreeEntry) bytes.Buffer {
+	var buffer bytes.Buffer
+
+	for _, entry := range entries {
+		buffer.WriteString(entry.Format())
+	}
+
+	return buffer
+}
+
+func readTreeEntries(s string) ([]TreeEntry, error) {
+	splitted := strings.Split(s, "\n")
+
+	casted := make([]TreeEntry, len(splitted))
+	for i, line := range splitted {
+		if line == "" {
+			continue
+		}
+
+		entry, err := ParseTreeEntry(line)
+
+		if err != nil {
+			return nil, err
+		}
+
+		casted[i] = entry
+	}
+
+	return casted, nil
 }

tests/bug_test.go πŸ”—

@@ -2,6 +2,8 @@ package tests
 
 import (
 	"github.com/MichaelMure/git-bug/bug"
+	"github.com/MichaelMure/git-bug/repository"
+	"reflect"
 	"testing"
 )
 
@@ -11,7 +13,7 @@ func TestBugId(t *testing.T) {
 		t.Error(err)
 	}
 
-	if len(bug1.HumanId()) == 0 {
+	if len(bug1.Id()) == 0 {
 		t.Fatal("Bug doesn't have a human readable identifier")
 	}
 }
@@ -44,3 +46,28 @@ func TestBugValidity(t *testing.T) {
 		t.Fatal("Bug with multiple CREATE should be invalid")
 	}
 }
+
+func TestBugSerialisation(t *testing.T) {
+	bug1, err := bug.NewBug()
+	if err != nil {
+		t.Error(err)
+	}
+
+	bug1.Append(createOp)
+	bug1.Append(setTitleOp)
+	bug1.Append(setTitleOp)
+	bug1.Append(addCommentOp)
+
+	repo := repository.NewMockRepoForTest()
+
+	bug1.Commit(repo)
+
+	bug2, err := bug.ReadBug(repo, bug1.Id())
+	if err != nil {
+		t.Error(err)
+	}
+
+	if !reflect.DeepEqual(bug1, bug2) {
+		t.Fatalf("%s different than %s", bug1, bug2)
+	}
+}

tests/operation_pack_test.go πŸ”—

@@ -1,9 +1,6 @@
 package tests
 
 import (
-	"bytes"
-	"encoding/json"
-	"fmt"
 	"github.com/MichaelMure/git-bug/bug"
 	"testing"
 )
@@ -15,24 +12,19 @@ func TestOperationPackSerialize(t *testing.T) {
 	opp.Append(setTitleOp)
 	opp.Append(addCommentOp)
 
-	jsonBytes, err := opp.Serialize()
+	data, err := opp.Serialize()
 
 	if err != nil {
 		t.Fatal(err)
 	}
 
-	if len(jsonBytes) == 0 {
-		t.Fatal("empty json")
+	if len(data) == 0 {
+		t.Fatal("empty serialized data")
 	}
 
-	fmt.Println(prettyPrintJSON(jsonBytes))
-}
+	_, err = bug.ParseOperationPack(data)
 
-func prettyPrintJSON(jsonBytes []byte) (string, error) {
-	var prettyBytes bytes.Buffer
-	err := json.Indent(&prettyBytes, jsonBytes, "", "  ")
 	if err != nil {
-		return "", err
+		t.Fatal(err)
 	}
-	return prettyBytes.String(), nil
 }