WIP operation with files

Michael Muré created

Change summary

bug/op_add_comment.go             |  2 +
bug/op_create.go                  |  2 +
bug/op_edit_comment.go            |  2 +
bug/operation.go                  | 17 ++++--------
entity/dag/common_test.go         | 15 +++++++---
entity/dag/operation.go           |  9 ++++++
entity/dag/operation_pack.go      | 45 ++++++++++++++++++++++++++++++--
entity/dag/operation_pack_test.go | 20 +++++++++++++-
8 files changed, 91 insertions(+), 21 deletions(-)

Detailed changes

bug/op_add_comment.go 🔗

@@ -5,6 +5,7 @@ import (
 	"fmt"
 
 	"github.com/MichaelMure/git-bug/entity"
+	"github.com/MichaelMure/git-bug/entity/dag"
 	"github.com/MichaelMure/git-bug/identity"
 	"github.com/MichaelMure/git-bug/repository"
 	"github.com/MichaelMure/git-bug/util/text"
@@ -12,6 +13,7 @@ import (
 )
 
 var _ Operation = &AddCommentOperation{}
+var _ dag.OperationWithFiles = &AddCommentOperation{}
 
 // AddCommentOperation will add a new comment in the bug
 type AddCommentOperation struct {

bug/op_create.go 🔗

@@ -6,6 +6,7 @@ import (
 	"strings"
 
 	"github.com/MichaelMure/git-bug/entity"
+	"github.com/MichaelMure/git-bug/entity/dag"
 	"github.com/MichaelMure/git-bug/identity"
 	"github.com/MichaelMure/git-bug/repository"
 	"github.com/MichaelMure/git-bug/util/text"
@@ -13,6 +14,7 @@ import (
 )
 
 var _ Operation = &CreateOperation{}
+var _ dag.OperationWithFiles = &CreateOperation{}
 
 // CreateOperation define the initial creation of a bug
 type CreateOperation struct {

bug/op_edit_comment.go 🔗

@@ -7,6 +7,7 @@ import (
 	"github.com/pkg/errors"
 
 	"github.com/MichaelMure/git-bug/entity"
+	"github.com/MichaelMure/git-bug/entity/dag"
 	"github.com/MichaelMure/git-bug/identity"
 	"github.com/MichaelMure/git-bug/repository"
 	"github.com/MichaelMure/git-bug/util/timestamp"
@@ -15,6 +16,7 @@ import (
 )
 
 var _ Operation = &EditCommentOperation{}
+var _ dag.OperationWithFiles = &EditCommentOperation{}
 
 // EditCommentOperation will change a comment in the bug
 type EditCommentOperation struct {

bug/operation.go 🔗

@@ -11,7 +11,6 @@ import (
 	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/entity/dag"
 	"github.com/MichaelMure/git-bug/identity"
-	"github.com/MichaelMure/git-bug/repository"
 )
 
 // OperationType is an operation type identifier
@@ -38,10 +37,9 @@ type Operation interface {
 
 	// Time return the time when the operation was added
 	Time() time.Time
-	// GetFiles return the files needed by this operation
-	GetFiles() []repository.Hash
 	// Apply the operation to a Snapshot to create the final state
 	Apply(snapshot *Snapshot)
+
 	// SetMetadata store arbitrary metadata about the operation
 	SetMetadata(key string, value string)
 	// GetMetadata retrieve arbitrary metadata about the operation
@@ -212,11 +210,6 @@ func (base *OpBase) Time() time.Time {
 	return time.Unix(base.UnixTime, 0)
 }
 
-// GetFiles return the files needed by this operation
-func (base *OpBase) GetFiles() []repository.Hash {
-	return nil
-}
-
 // Validate check the OpBase for errors
 func (base *OpBase) Validate(op Operation, opType OperationType) error {
 	if base.OperationType != opType {
@@ -235,9 +228,11 @@ func (base *OpBase) Validate(op Operation, opType OperationType) error {
 		return errors.Wrap(err, "author")
 	}
 
-	for _, hash := range op.GetFiles() {
-		if !hash.IsValid() {
-			return fmt.Errorf("file with invalid hash %v", hash)
+	if op, ok := op.(dag.OperationWithFiles); ok {
+		for _, hash := range op.GetFiles() {
+			if !hash.IsValid() {
+				return fmt.Errorf("file with invalid hash %v", hash)
+			}
 		}
 	}
 

entity/dag/common_test.go 🔗

@@ -23,10 +23,11 @@ type op1 struct {
 
 	OperationType int    `json:"type"`
 	Field1        string `json:"field_1"`
+	Files         []repository.Hash
 }
 
-func newOp1(author identity.Interface, field1 string) *op1 {
-	return &op1{author: author, OperationType: 1, Field1: field1}
+func newOp1(author identity.Interface, field1 string, files ...repository.Hash) *op1 {
+	return &op1{author: author, OperationType: 1, Field1: field1, Files: files}
 }
 
 func (o *op1) Id() entity.Id {
@@ -34,11 +35,15 @@ func (o *op1) Id() entity.Id {
 	return entity.DeriveId(data)
 }
 
+func (o *op1) Validate() error { return nil }
+
 func (o *op1) Author() identity.Interface {
 	return o.author
 }
 
-func (o *op1) Validate() error { return nil }
+func (o *op1) GetFiles() []repository.Hash {
+	return o.Files
+}
 
 type op2 struct {
 	author identity.Interface
@@ -56,12 +61,12 @@ func (o *op2) Id() entity.Id {
 	return entity.DeriveId(data)
 }
 
+func (o *op2) Validate() error { return nil }
+
 func (o *op2) Author() identity.Interface {
 	return o.author
 }
 
-func (o *op2) Validate() error { return nil }
-
 func unmarshaler(author identity.Interface, raw json.RawMessage) (Operation, error) {
 	var t struct {
 		OperationType int `json:"type"`

entity/dag/operation.go 🔗

@@ -3,6 +3,7 @@ package dag
 import (
 	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/identity"
+	"github.com/MichaelMure/git-bug/repository"
 )
 
 // Operation is a piece of data defining a change to reflect on the state of an Entity.
@@ -33,3 +34,11 @@ type Operation interface {
 	// Author returns the author of this operation
 	Author() identity.Interface
 }
+
+// OperationWithFiles is an extended Operation that has files dependency, stored in git.
+type OperationWithFiles interface {
+	Operation
+
+	// GetFiles return the files needed by this operation
+	GetFiles() []repository.Hash
+}

entity/dag/operation_pack.go 🔗

@@ -15,10 +15,8 @@ import (
 	"github.com/MichaelMure/git-bug/util/lamport"
 )
 
-// TODO: extra data tree
-const extraEntryName = "extra"
-
 const opsEntryName = "ops"
+const extraEntryName = "extra"
 const versionEntryPrefix = "version-"
 const createClockEntryPrefix = "create-clock-"
 const editClockEntryPrefix = "edit-clock-"
@@ -118,6 +116,7 @@ func (opp *operationPack) Write(def Definition, repo repository.Repo, parentComm
 	// Make a Git tree referencing this blob and encoding the other values:
 	// - format version
 	// - clocks
+	// - extra data
 	tree := []repository.TreeEntry{
 		{ObjectType: repository.Blob, Hash: emptyBlobHash,
 			Name: fmt.Sprintf(versionEntryPrefix+"%d", def.FormatVersion)},
@@ -133,6 +132,17 @@ func (opp *operationPack) Write(def Definition, repo repository.Repo, parentComm
 			Name:       fmt.Sprintf(createClockEntryPrefix+"%d", opp.CreateTime),
 		})
 	}
+	if extraTree := opp.makeExtraTree(); len(extraTree) > 0 {
+		extraTreeHash, err := repo.StoreTree(extraTree)
+		if err != nil {
+			return "", err
+		}
+		tree = append(tree, repository.TreeEntry{
+			ObjectType: repository.Tree,
+			Hash:       extraTreeHash,
+			Name:       extraEntryName,
+		})
+	}
 
 	// Store the tree
 	treeHash, err := repo.StoreTree(tree)
@@ -163,6 +173,35 @@ func (opp *operationPack) Write(def Definition, repo repository.Repo, parentComm
 	return commitHash, nil
 }
 
+func (opp *operationPack) makeExtraTree() []repository.TreeEntry {
+	var tree []repository.TreeEntry
+	counter := 0
+	added := make(map[repository.Hash]interface{})
+
+	for _, ops := range opp.Operations {
+		ops, ok := ops.(OperationWithFiles)
+		if !ok {
+			continue
+		}
+
+		for _, file := range ops.GetFiles() {
+			if _, has := added[file]; !has {
+				tree = append(tree, repository.TreeEntry{
+					ObjectType: repository.Blob,
+					Hash:       file,
+					// The name is not important here, we only need to
+					// reference the blob.
+					Name: fmt.Sprintf("file%d", counter),
+				})
+				counter++
+				added[file] = struct{}{}
+			}
+		}
+	}
+
+	return tree
+}
+
 // readOperationPack read the operationPack encoded in git at the given Tree hash.
 //
 // Validity of the Lamport clocks is left for the caller to decide.

entity/dag/operation_pack_test.go 🔗

@@ -1,6 +1,7 @@
 package dag
 
 import (
+	"math/rand"
 	"testing"
 
 	"github.com/stretchr/testify/require"
@@ -11,10 +12,16 @@ import (
 func TestOperationPackReadWrite(t *testing.T) {
 	repo, id1, _, resolver, def := makeTestContext()
 
+	blobHash1, err := repo.StoreData(randomData())
+	require.NoError(t, err)
+
+	blobHash2, err := repo.StoreData(randomData())
+	require.NoError(t, err)
+
 	opp := &operationPack{
 		Author: id1,
 		Operations: []Operation{
-			newOp1(id1, "foo"),
+			newOp1(id1, "foo", blobHash1, blobHash2),
 			newOp2(id1, "bar"),
 		},
 		CreateTime: 123,
@@ -36,7 +43,7 @@ func TestOperationPackReadWrite(t *testing.T) {
 	opp3 := &operationPack{
 		Author: id1,
 		Operations: []Operation{
-			newOp1(id1, "foo"),
+			newOp1(id1, "foo", blobHash1, blobHash2),
 			newOp2(id1, "bar"),
 		},
 		CreateTime: 123,
@@ -86,3 +93,12 @@ func TestOperationPackSignedReadWrite(t *testing.T) {
 	}
 	require.Equal(t, opp.Id(), opp3.Id())
 }
+
+func randomData() []byte {
+	var letterRunes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
+	b := make([]byte, 32)
+	for i := range b {
+		b[i] = letterRunes[rand.Intn(len(letterRunes))]
+	}
+	return b
+}