bug,entity: use a dedicated type to store IDs

Michael MurΓ© created

Change summary

bug/bug.go                     | 59 ++++++++++++---------------
bug/bug_actions.go             |  2 
bug/comment.go                 | 10 +---
bug/interface.go               |  6 -
bug/op_add_comment.go          | 36 ++++------------
bug/op_add_comment_test.go     |  6 +
bug/op_create.go               | 39 ++++-------------
bug/op_create_test.go          | 11 ++--
bug/op_edit_comment.go         | 51 ++++++++----------------
bug/op_edit_comment_test.go    | 23 +++++-----
bug/op_label_change.go         | 38 ++++-------------
bug/op_label_change_test.go    |  5 +
bug/op_noop.go                 | 25 ++---------
bug/op_noop_test.go            |  5 +
bug/op_set_metadata.go         | 47 +++++++---------------
bug/op_set_metadata_test.go    | 19 ++++----
bug/op_set_status.go           | 35 ++++------------
bug/op_set_status_test.go      |  5 +
bug/op_set_title.go            | 38 ++++-------------
bug/op_set_title_test.go       |  5 +
bug/operation.go               | 76 ++++++++++-------------------------
bug/operation_pack_test.go     | 10 ++-
bug/operation_test.go          | 14 +++---
bug/snapshot.go                | 24 ++++------
bug/timeline.go                |  9 ++-
cache/bug_excerpt.go           |  7 +-
entity/err.go                  |  1 
entity/id.go                   | 65 ++++++++++++++++++++++++++++++
entity/interface.go            |  4 -
identity/bare.go               | 51 ++++++++++++++---------
identity/bare_test.go          |  7 ++
identity/common.go             |  6 +-
identity/identity.go           | 51 ++++++++++-------------
identity/identity_actions.go   |  2 
identity/identity_stub.go      | 14 ++----
identity/identity_stub_test.go |  3 +
identity/identity_test.go      |  7 ++
identity/interface.go          |  6 -
identity/resolver.go           |  9 ++-
util/git/hash.go               |  8 ++
40 files changed, 375 insertions(+), 464 deletions(-)

Detailed changes

bug/bug.go πŸ”—

@@ -8,6 +8,7 @@ import (
 
 	"github.com/pkg/errors"
 
+	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/identity"
 	"github.com/MichaelMure/git-bug/repository"
 	"github.com/MichaelMure/git-bug/util/git"
@@ -26,20 +27,24 @@ const createClockEntryPattern = "create-clock-%d"
 const editClockEntryPrefix = "edit-clock-"
 const editClockEntryPattern = "edit-clock-%d"
 
-const idLength = 40
-const humanIdLength = 7
-
 var ErrBugNotExist = errors.New("bug doesn't exist")
 
 type ErrMultipleMatch struct {
-	Matching []string
+	Matching []entity.Id
 }
 
 func (e ErrMultipleMatch) Error() string {
-	return fmt.Sprintf("Multiple matching bug found:\n%s", strings.Join(e.Matching, "\n"))
+	matching := make([]string, len(e.Matching))
+
+	for i, match := range e.Matching {
+		matching[i] = match.String()
+	}
+
+	return fmt.Sprintf("Multiple matching bug found:\n%s", strings.Join(matching, "\n"))
 }
 
 var _ Interface = &Bug{}
+var _ entity.Interface = &Bug{}
 
 // Bug hold the data of a bug thread, organized in a way close to
 // how it will be persisted inside Git. This is the data structure
@@ -53,7 +58,7 @@ type Bug struct {
 	editTime   lamport.Time
 
 	// Id used as unique identifier
-	id string
+	id entity.Id
 
 	lastCommit git.Hash
 	rootPack   git.Hash
@@ -82,10 +87,10 @@ func FindLocalBug(repo repository.ClockedRepo, prefix string) (*Bug, error) {
 	}
 
 	// preallocate but empty
-	matching := make([]string, 0, 5)
+	matching := make([]entity.Id, 0, 5)
 
 	for _, id := range ids {
-		if strings.HasPrefix(id, prefix) {
+		if id.HasPrefix(prefix) {
 			matching = append(matching, id)
 		}
 	}
@@ -102,8 +107,8 @@ func FindLocalBug(repo repository.ClockedRepo, prefix string) (*Bug, error) {
 }
 
 // ReadLocalBug will read a local bug from its hash
-func ReadLocalBug(repo repository.ClockedRepo, id string) (*Bug, error) {
-	ref := bugsRefPattern + id
+func ReadLocalBug(repo repository.ClockedRepo, id entity.Id) (*Bug, error) {
+	ref := bugsRefPattern + id.String()
 	return readBug(repo, ref)
 }
 
@@ -116,10 +121,10 @@ func ReadRemoteBug(repo repository.ClockedRepo, remote string, id string) (*Bug,
 // readBug will read and parse a Bug from git
 func readBug(repo repository.ClockedRepo, ref string) (*Bug, error) {
 	refSplit := strings.Split(ref, "/")
-	id := refSplit[len(refSplit)-1]
+	id := entity.Id(refSplit[len(refSplit)-1])
 
-	if len(id) != idLength {
-		return nil, fmt.Errorf("invalid ref length")
+	if err := id.Validate(); err != nil {
+		return nil, errors.Wrap(err, "invalid ref ")
 	}
 
 	hashes, err := repo.ListCommits(ref)
@@ -278,7 +283,7 @@ func readAllBugs(repo repository.ClockedRepo, refPrefix string) <-chan StreamedB
 }
 
 // ListLocalIds list all the available local bug ids
-func ListLocalIds(repo repository.Repo) ([]string, error) {
+func ListLocalIds(repo repository.Repo) ([]entity.Id, error) {
 	refs, err := repo.ListRefs(bugsRefPattern)
 	if err != nil {
 		return nil, err
@@ -287,12 +292,12 @@ func ListLocalIds(repo repository.Repo) ([]string, error) {
 	return refsToIds(refs), nil
 }
 
-func refsToIds(refs []string) []string {
-	ids := make([]string, len(refs))
+func refsToIds(refs []string) []entity.Id {
+	ids := make([]entity.Id, len(refs))
 
 	for i, ref := range refs {
 		split := strings.Split(ref, "/")
-		ids[i] = split[len(split)-1]
+		ids[i] = entity.Id(split[len(split)-1])
 	}
 
 	return ids
@@ -325,8 +330,8 @@ func (bug *Bug) Validate() error {
 		return fmt.Errorf("first operation should be a Create op")
 	}
 
-	// The bug ID should be the hash of the first commit
-	if len(bug.packs) > 0 && string(bug.packs[0].commitHash) != bug.id {
+	// The bug Id should be the hash of the first commit
+	if len(bug.packs) > 0 && string(bug.packs[0].commitHash) != bug.id.String() {
 		return fmt.Errorf("bug id should be the first commit hash")
 	}
 
@@ -456,7 +461,7 @@ func (bug *Bug) Commit(repo repository.ClockedRepo) error {
 
 	// if it was the first commit, use the commit hash as bug id
 	if bug.id == "" {
-		bug.id = string(hash)
+		bug.id = entity.Id(hash)
 	}
 
 	// Create or update the Git reference for this bug
@@ -594,7 +599,7 @@ func (bug *Bug) Merge(repo repository.Repo, other Interface) (bool, error) {
 	}
 
 	// Update the git ref
-	err = repo.UpdateRef(bugsRefPattern+bug.id, bug.lastCommit)
+	err = repo.UpdateRef(bugsRefPattern+bug.id.String(), bug.lastCommit)
 	if err != nil {
 		return false, err
 	}
@@ -603,7 +608,7 @@ func (bug *Bug) Merge(repo repository.Repo, other Interface) (bool, error) {
 }
 
 // Id return the Bug identifier
-func (bug *Bug) Id() string {
+func (bug *Bug) Id() entity.Id {
 	if bug.id == "" {
 		// simply panic as it would be a coding error
 		// (using an id of a bug not stored yet)
@@ -612,16 +617,6 @@ func (bug *Bug) Id() string {
 	return bug.id
 }
 
-// HumanId return the Bug identifier truncated for human consumption
-func (bug *Bug) HumanId() string {
-	return FormatHumanID(bug.Id())
-}
-
-func FormatHumanID(id string) string {
-	format := fmt.Sprintf("%%.%ds", humanIdLength)
-	return fmt.Sprintf(format, id)
-}
-
 // CreateLamportTime return the Lamport time of creation
 func (bug *Bug) CreateLamportTime() lamport.Time {
 	return bug.createTime

bug/bug_actions.go πŸ”—

@@ -81,7 +81,7 @@ func MergeAll(repo repository.ClockedRepo, remote string) <-chan entity.MergeRes
 				continue
 			}
 
-			localRef := bugsRefPattern + remoteBug.Id()
+			localRef := bugsRefPattern + remoteBug.Id().String()
 			localExist, err := repo.RefExist(localRef)
 
 			if err != nil {

bug/comment.go πŸ”—

@@ -1,6 +1,7 @@
 package bug
 
 import (
+	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/identity"
 	"github.com/MichaelMure/git-bug/util/git"
 	"github.com/MichaelMure/git-bug/util/timestamp"
@@ -9,7 +10,7 @@ import (
 
 // Comment represent a comment in a Bug
 type Comment struct {
-	id      string
+	id      entity.Id
 	Author  identity.Interface
 	Message string
 	Files   []git.Hash
@@ -20,7 +21,7 @@ type Comment struct {
 }
 
 // Id return the Comment identifier
-func (c Comment) Id() string {
+func (c Comment) Id() entity.Id {
 	if c.id == "" {
 		// simply panic as it would be a coding error
 		// (using an id of an identity not stored yet)
@@ -29,11 +30,6 @@ func (c Comment) Id() string {
 	return c.id
 }
 
-// HumanId return the Comment identifier truncated for human consumption
-func (c Comment) HumanId() string {
-	return FormatHumanID(c.Id())
-}
-
 // FormatTimeRel format the UnixTime of the comment for human consumption
 func (c Comment) FormatTimeRel() string {
 	return humanize.Time(c.UnixTime.Time())

bug/interface.go πŸ”—

@@ -1,16 +1,14 @@
 package bug
 
 import (
+	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/repository"
 	"github.com/MichaelMure/git-bug/util/lamport"
 )
 
 type Interface interface {
 	// Id return the Bug identifier
-	Id() string
-
-	// HumanId return the Bug identifier truncated for human consumption
-	HumanId() string
+	Id() entity.Id
 
 	// Validate check if the Bug data is valid
 	Validate() error

bug/op_add_comment.go πŸ”—

@@ -4,6 +4,7 @@ import (
 	"encoding/json"
 	"fmt"
 
+	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/identity"
 	"github.com/MichaelMure/git-bug/util/git"
 	"github.com/MichaelMure/git-bug/util/text"
@@ -15,16 +16,16 @@ var _ Operation = &AddCommentOperation{}
 // AddCommentOperation will add a new comment in the bug
 type AddCommentOperation struct {
 	OpBase
-	Message string
+	Message string `json:"message"`
 	// TODO: change for a map[string]util.hash to store the filename ?
-	Files []git.Hash
+	Files []git.Hash `json:"files"`
 }
 
 func (op *AddCommentOperation) base() *OpBase {
 	return &op.OpBase
 }
 
-func (op *AddCommentOperation) ID() string {
+func (op *AddCommentOperation) Id() entity.Id {
 	return idOperation(op)
 }
 
@@ -33,7 +34,7 @@ func (op *AddCommentOperation) Apply(snapshot *Snapshot) {
 	snapshot.addParticipant(op.Author)
 
 	comment := Comment{
-		id:       op.ID(),
+		id:       op.Id(),
 		Message:  op.Message,
 		Author:   op.Author,
 		Files:    op.Files,
@@ -43,7 +44,7 @@ func (op *AddCommentOperation) Apply(snapshot *Snapshot) {
 	snapshot.Comments = append(snapshot.Comments, comment)
 
 	item := &AddCommentTimelineItem{
-		CommentTimelineItem: NewCommentTimelineItem(op.ID(), comment),
+		CommentTimelineItem: NewCommentTimelineItem(op.Id(), comment),
 	}
 
 	snapshot.Timeline = append(snapshot.Timeline, item)
@@ -65,28 +66,9 @@ func (op *AddCommentOperation) Validate() error {
 	return nil
 }
 
-// Workaround to avoid the inner OpBase.MarshalJSON overriding the outer op
-// MarshalJSON
-func (op *AddCommentOperation) MarshalJSON() ([]byte, error) {
-	base, err := json.Marshal(op.OpBase)
-	if err != nil {
-		return nil, err
-	}
-
-	// revert back to a flat map to be able to add our own fields
-	var data map[string]interface{}
-	if err := json.Unmarshal(base, &data); err != nil {
-		return nil, err
-	}
-
-	data["message"] = op.Message
-	data["files"] = op.Files
-
-	return json.Marshal(data)
-}
-
-// Workaround to avoid the inner OpBase.MarshalJSON overriding the outer op
-// MarshalJSON
+// UnmarshalJSON is a two step JSON unmarshaling
+// This workaround is necessary to avoid the inner OpBase.MarshalJSON
+// overriding the outer op's MarshalJSON
 func (op *AddCommentOperation) UnmarshalJSON(data []byte) error {
 	// Unmarshal OpBase and the op separately
 

bug/op_add_comment_test.go πŸ”—

@@ -2,6 +2,7 @@ package bug
 
 import (
 	"encoding/json"
+	"fmt"
 	"testing"
 	"time"
 
@@ -21,8 +22,9 @@ func TestAddCommentSerialize(t *testing.T) {
 	err = json.Unmarshal(data, &after)
 	assert.NoError(t, err)
 
-	// enforce creating the ID
-	before.ID()
+	// enforce creating the IDs
+	before.Id()
+	rene.Id()
 
 	assert.Equal(t, before, &after)
 }

bug/op_create.go πŸ”—

@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"strings"
 
+	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/identity"
 	"github.com/MichaelMure/git-bug/util/git"
 	"github.com/MichaelMure/git-bug/util/text"
@@ -16,16 +17,16 @@ var _ Operation = &CreateOperation{}
 // CreateOperation define the initial creation of a bug
 type CreateOperation struct {
 	OpBase
-	Title   string
-	Message string
-	Files   []git.Hash
+	Title   string     `json:"title"`
+	Message string     `json:"message"`
+	Files   []git.Hash `json:"files"`
 }
 
 func (op *CreateOperation) base() *OpBase {
 	return &op.OpBase
 }
 
-func (op *CreateOperation) ID() string {
+func (op *CreateOperation) Id() entity.Id {
 	return idOperation(op)
 }
 
@@ -36,7 +37,7 @@ func (op *CreateOperation) Apply(snapshot *Snapshot) {
 	snapshot.Title = op.Title
 
 	comment := Comment{
-		id:       op.ID(),
+		id:       op.Id(),
 		Message:  op.Message,
 		Author:   op.Author,
 		UnixTime: timestamp.Timestamp(op.UnixTime),
@@ -48,7 +49,7 @@ func (op *CreateOperation) Apply(snapshot *Snapshot) {
 
 	snapshot.Timeline = []TimelineItem{
 		&CreateTimelineItem{
-			CommentTimelineItem: NewCommentTimelineItem(op.ID(), comment),
+			CommentTimelineItem: NewCommentTimelineItem(op.Id(), comment),
 		},
 	}
 }
@@ -81,29 +82,9 @@ func (op *CreateOperation) Validate() error {
 	return nil
 }
 
-// Workaround to avoid the inner OpBase.MarshalJSON overriding the outer op
-// MarshalJSON
-func (op *CreateOperation) MarshalJSON() ([]byte, error) {
-	base, err := json.Marshal(op.OpBase)
-	if err != nil {
-		return nil, err
-	}
-
-	// revert back to a flat map to be able to add our own fields
-	var data map[string]interface{}
-	if err := json.Unmarshal(base, &data); err != nil {
-		return nil, err
-	}
-
-	data["title"] = op.Title
-	data["message"] = op.Message
-	data["files"] = op.Files
-
-	return json.Marshal(data)
-}
-
-// Workaround to avoid the inner OpBase.MarshalJSON overriding the outer op
-// MarshalJSON
+// UnmarshalJSON is a two step JSON unmarshaling
+// This workaround is necessary to avoid the inner OpBase.MarshalJSON
+// overriding the outer op's MarshalJSON
 func (op *CreateOperation) UnmarshalJSON(data []byte) error {
 	// Unmarshal OpBase and the op separately
 

bug/op_create_test.go πŸ”—

@@ -20,11 +20,11 @@ func TestCreate(t *testing.T) {
 
 	create.Apply(&snapshot)
 
-	id := create.ID()
-	assert.True(t, IDIsValid(id))
+	id := create.Id()
+	assert.NoError(t, id.Validate())
 
 	comment := Comment{
-		id:       string(id),
+		id:       id,
 		Author:   rene,
 		Message:  "message",
 		UnixTime: timestamp.Timestamp(create.UnixTime),
@@ -61,8 +61,9 @@ func TestCreateSerialize(t *testing.T) {
 	err = json.Unmarshal(data, &after)
 	assert.NoError(t, err)
 
-	// enforce creating the ID
-	before.ID()
+	// enforce creating the IDs
+	before.Id()
+	rene.Id()
 
 	assert.Equal(t, before, &after)
 }

bug/op_edit_comment.go πŸ”—

@@ -4,6 +4,9 @@ import (
 	"encoding/json"
 	"fmt"
 
+	"github.com/pkg/errors"
+
+	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/identity"
 	"github.com/MichaelMure/git-bug/util/timestamp"
 
@@ -16,16 +19,16 @@ var _ Operation = &EditCommentOperation{}
 // EditCommentOperation will change a comment in the bug
 type EditCommentOperation struct {
 	OpBase
-	Target  string
-	Message string
-	Files   []git.Hash
+	Target  entity.Id  `json:"target"`
+	Message string     `json:"message"`
+	Files   []git.Hash `json:"files"`
 }
 
 func (op *EditCommentOperation) base() *OpBase {
 	return &op.OpBase
 }
 
-func (op *EditCommentOperation) ID() string {
+func (op *EditCommentOperation) Id() entity.Id {
 	return idOperation(op)
 }
 
@@ -38,7 +41,7 @@ func (op *EditCommentOperation) Apply(snapshot *Snapshot) {
 	var target TimelineItem
 
 	for i, item := range snapshot.Timeline {
-		if item.ID() == op.Target {
+		if item.Id() == op.Target {
 			target = snapshot.Timeline[i]
 			break
 		}
@@ -86,8 +89,8 @@ func (op *EditCommentOperation) Validate() error {
 		return err
 	}
 
-	if !IDIsValid(op.Target) {
-		return fmt.Errorf("target hash is invalid")
+	if err := op.Target.Validate(); err != nil {
+		return errors.Wrap(err, "target hash is invalid")
 	}
 
 	if !text.Safe(op.Message) {
@@ -97,29 +100,9 @@ func (op *EditCommentOperation) Validate() error {
 	return nil
 }
 
-// Workaround to avoid the inner OpBase.MarshalJSON overriding the outer op
-// MarshalJSON
-func (op *EditCommentOperation) MarshalJSON() ([]byte, error) {
-	base, err := json.Marshal(op.OpBase)
-	if err != nil {
-		return nil, err
-	}
-
-	// revert back to a flat map to be able to add our own fields
-	var data map[string]interface{}
-	if err := json.Unmarshal(base, &data); err != nil {
-		return nil, err
-	}
-
-	data["target"] = op.Target
-	data["message"] = op.Message
-	data["files"] = op.Files
-
-	return json.Marshal(data)
-}
-
-// Workaround to avoid the inner OpBase.MarshalJSON overriding the outer op
-// MarshalJSON
+// UnmarshalJSON is a two step JSON unmarshaling
+// This workaround is necessary to avoid the inner OpBase.MarshalJSON
+// overriding the outer op's MarshalJSON
 func (op *EditCommentOperation) UnmarshalJSON(data []byte) error {
 	// Unmarshal OpBase and the op separately
 
@@ -130,7 +113,7 @@ func (op *EditCommentOperation) UnmarshalJSON(data []byte) error {
 	}
 
 	aux := struct {
-		Target  string     `json:"target"`
+		Target  entity.Id  `json:"target"`
 		Message string     `json:"message"`
 		Files   []git.Hash `json:"files"`
 	}{}
@@ -151,7 +134,7 @@ func (op *EditCommentOperation) UnmarshalJSON(data []byte) error {
 // Sign post method for gqlgen
 func (op *EditCommentOperation) IsAuthored() {}
 
-func NewEditCommentOp(author identity.Interface, unixTime int64, target string, message string, files []git.Hash) *EditCommentOperation {
+func NewEditCommentOp(author identity.Interface, unixTime int64, target entity.Id, message string, files []git.Hash) *EditCommentOperation {
 	return &EditCommentOperation{
 		OpBase:  newOpBase(EditCommentOp, author, unixTime),
 		Target:  target,
@@ -161,11 +144,11 @@ func NewEditCommentOp(author identity.Interface, unixTime int64, target string,
 }
 
 // Convenience function to apply the operation
-func EditComment(b Interface, author identity.Interface, unixTime int64, target string, message string) (*EditCommentOperation, error) {
+func EditComment(b Interface, author identity.Interface, unixTime int64, target entity.Id, message string) (*EditCommentOperation, error) {
 	return EditCommentWithFiles(b, author, unixTime, target, message, nil)
 }
 
-func EditCommentWithFiles(b Interface, author identity.Interface, unixTime int64, target string, message string, files []git.Hash) (*EditCommentOperation, error) {
+func EditCommentWithFiles(b Interface, author identity.Interface, unixTime int64, target entity.Id, message string, files []git.Hash) (*EditCommentOperation, error) {
 	editCommentOp := NewEditCommentOp(author, unixTime, target, message, files)
 	if err := editCommentOp.Validate(); err != nil {
 		return nil, err

bug/op_edit_comment_test.go πŸ”—

@@ -20,14 +20,14 @@ func TestEdit(t *testing.T) {
 	create := NewCreateOp(rene, unix, "title", "create", nil)
 	create.Apply(&snapshot)
 
-	hash1 := create.ID()
-	require.True(t, IDIsValid(hash1))
+	id1 := create.Id()
+	require.NoError(t, id1.Validate())
 
 	comment1 := NewAddCommentOp(rene, unix, "comment 1", nil)
 	comment1.Apply(&snapshot)
 
-	hash2 := comment1.ID()
-	require.True(t, IDIsValid(hash2))
+	id2 := comment1.Id()
+	require.NoError(t, id2.Validate())
 
 	// add another unrelated op in between
 	setTitle := NewSetTitleOp(rene, unix, "edited title", "title")
@@ -36,10 +36,10 @@ func TestEdit(t *testing.T) {
 	comment2 := NewAddCommentOp(rene, unix, "comment 2", nil)
 	comment2.Apply(&snapshot)
 
-	hash3 := comment2.ID()
-	require.True(t, IDIsValid(hash3))
+	id3 := comment2.Id()
+	require.NoError(t, id3.Validate())
 
-	edit := NewEditCommentOp(rene, unix, hash1, "create edited", nil)
+	edit := NewEditCommentOp(rene, unix, id1, "create edited", nil)
 	edit.Apply(&snapshot)
 
 	assert.Equal(t, len(snapshot.Timeline), 4)
@@ -50,7 +50,7 @@ func TestEdit(t *testing.T) {
 	assert.Equal(t, snapshot.Comments[1].Message, "comment 1")
 	assert.Equal(t, snapshot.Comments[2].Message, "comment 2")
 
-	edit2 := NewEditCommentOp(rene, unix, hash2, "comment 1 edited", nil)
+	edit2 := NewEditCommentOp(rene, unix, id2, "comment 1 edited", nil)
 	edit2.Apply(&snapshot)
 
 	assert.Equal(t, len(snapshot.Timeline), 4)
@@ -61,7 +61,7 @@ func TestEdit(t *testing.T) {
 	assert.Equal(t, snapshot.Comments[1].Message, "comment 1 edited")
 	assert.Equal(t, snapshot.Comments[2].Message, "comment 2")
 
-	edit3 := NewEditCommentOp(rene, unix, hash3, "comment 2 edited", nil)
+	edit3 := NewEditCommentOp(rene, unix, id3, "comment 2 edited", nil)
 	edit3.Apply(&snapshot)
 
 	assert.Equal(t, len(snapshot.Timeline), 4)
@@ -85,8 +85,9 @@ func TestEditCommentSerialize(t *testing.T) {
 	err = json.Unmarshal(data, &after)
 	assert.NoError(t, err)
 
-	// enforce creating the ID
-	before.ID()
+	// enforce creating the IDs
+	before.Id()
+	rene.Id()
 
 	assert.Equal(t, before, &after)
 }

bug/op_label_change.go πŸ”—

@@ -7,6 +7,7 @@ import (
 
 	"github.com/pkg/errors"
 
+	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/identity"
 	"github.com/MichaelMure/git-bug/util/timestamp"
 )
@@ -16,15 +17,15 @@ var _ Operation = &LabelChangeOperation{}
 // LabelChangeOperation define a Bug operation to add or remove labels
 type LabelChangeOperation struct {
 	OpBase
-	Added   []Label
-	Removed []Label
+	Added   []Label `json:"added"`
+	Removed []Label `json:"removed"`
 }
 
 func (op *LabelChangeOperation) base() *OpBase {
 	return &op.OpBase
 }
 
-func (op *LabelChangeOperation) ID() string {
+func (op *LabelChangeOperation) Id() entity.Id {
 	return idOperation(op)
 }
 
@@ -61,7 +62,7 @@ AddLoop:
 	})
 
 	item := &LabelChangeTimelineItem{
-		id:       op.ID(),
+		id:       op.Id(),
 		Author:   op.Author,
 		UnixTime: timestamp.Timestamp(op.UnixTime),
 		Added:    op.Added,
@@ -95,28 +96,9 @@ func (op *LabelChangeOperation) Validate() error {
 	return nil
 }
 
-// Workaround to avoid the inner OpBase.MarshalJSON overriding the outer op
-// MarshalJSON
-func (op *LabelChangeOperation) MarshalJSON() ([]byte, error) {
-	base, err := json.Marshal(op.OpBase)
-	if err != nil {
-		return nil, err
-	}
-
-	// revert back to a flat map to be able to add our own fields
-	var data map[string]interface{}
-	if err := json.Unmarshal(base, &data); err != nil {
-		return nil, err
-	}
-
-	data["added"] = op.Added
-	data["removed"] = op.Removed
-
-	return json.Marshal(data)
-}
-
-// Workaround to avoid the inner OpBase.MarshalJSON overriding the outer op
-// MarshalJSON
+// UnmarshalJSON is a two step JSON unmarshaling
+// This workaround is necessary to avoid the inner OpBase.MarshalJSON
+// overriding the outer op's MarshalJSON
 func (op *LabelChangeOperation) UnmarshalJSON(data []byte) error {
 	// Unmarshal OpBase and the op separately
 
@@ -155,14 +137,14 @@ func NewLabelChangeOperation(author identity.Interface, unixTime int64, added, r
 }
 
 type LabelChangeTimelineItem struct {
-	id       string
+	id       entity.Id
 	Author   identity.Interface
 	UnixTime timestamp.Timestamp
 	Added    []Label
 	Removed  []Label
 }
 
-func (l LabelChangeTimelineItem) ID() string {
+func (l LabelChangeTimelineItem) Id() entity.Id {
 	return l.id
 }
 

bug/op_label_change_test.go πŸ”—

@@ -21,8 +21,9 @@ func TestLabelChangeSerialize(t *testing.T) {
 	err = json.Unmarshal(data, &after)
 	assert.NoError(t, err)
 
-	// enforce creating the ID
-	before.ID()
+	// enforce creating the IDs
+	before.Id()
+	rene.Id()
 
 	assert.Equal(t, before, &after)
 }

bug/op_noop.go πŸ”—

@@ -3,6 +3,7 @@ package bug
 import (
 	"encoding/json"
 
+	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/identity"
 )
 
@@ -19,7 +20,7 @@ func (op *NoOpOperation) base() *OpBase {
 	return &op.OpBase
 }
 
-func (op *NoOpOperation) ID() string {
+func (op *NoOpOperation) Id() entity.Id {
 	return idOperation(op)
 }
 
@@ -31,25 +32,9 @@ func (op *NoOpOperation) Validate() error {
 	return opBaseValidate(op, NoOpOp)
 }
 
-// Workaround to avoid the inner OpBase.MarshalJSON overriding the outer op
-// MarshalJSON
-func (op *NoOpOperation) MarshalJSON() ([]byte, error) {
-	base, err := json.Marshal(op.OpBase)
-	if err != nil {
-		return nil, err
-	}
-
-	// revert back to a flat map to be able to add our own fields
-	var data map[string]interface{}
-	if err := json.Unmarshal(base, &data); err != nil {
-		return nil, err
-	}
-
-	return json.Marshal(data)
-}
-
-// Workaround to avoid the inner OpBase.MarshalJSON overriding the outer op
-// MarshalJSON
+// UnmarshalJSON is a two step JSON unmarshaling
+// This workaround is necessary to avoid the inner OpBase.MarshalJSON
+// overriding the outer op's MarshalJSON
 func (op *NoOpOperation) UnmarshalJSON(data []byte) error {
 	// Unmarshal OpBase and the op separately
 

bug/op_noop_test.go πŸ”—

@@ -21,8 +21,9 @@ func TestNoopSerialize(t *testing.T) {
 	err = json.Unmarshal(data, &after)
 	assert.NoError(t, err)
 
-	// enforce creating the ID
-	before.ID()
+	// enforce creating the IDs
+	before.Id()
+	rene.Id()
 
 	assert.Equal(t, before, &after)
 }

bug/op_set_metadata.go πŸ”—

@@ -2,8 +2,10 @@ package bug
 
 import (
 	"encoding/json"
-	"fmt"
 
+	"github.com/pkg/errors"
+
+	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/identity"
 )
 
@@ -11,21 +13,21 @@ var _ Operation = &SetMetadataOperation{}
 
 type SetMetadataOperation struct {
 	OpBase
-	Target      string
-	NewMetadata map[string]string
+	Target      entity.Id         `json:"target"`
+	NewMetadata map[string]string `json:"new_metadata"`
 }
 
 func (op *SetMetadataOperation) base() *OpBase {
 	return &op.OpBase
 }
 
-func (op *SetMetadataOperation) ID() string {
+func (op *SetMetadataOperation) Id() entity.Id {
 	return idOperation(op)
 }
 
 func (op *SetMetadataOperation) Apply(snapshot *Snapshot) {
 	for _, target := range snapshot.Operations {
-		if target.ID() == op.Target {
+		if target.Id() == op.Target {
 			base := target.base()
 
 			if base.extraMetadata == nil {
@@ -48,35 +50,16 @@ func (op *SetMetadataOperation) Validate() error {
 		return err
 	}
 
-	if !IDIsValid(op.Target) {
-		return fmt.Errorf("target hash is invalid")
+	if err := op.Target.Validate(); err != nil {
+		return errors.Wrap(err, "target invalid")
 	}
 
 	return nil
 }
 
-// Workaround to avoid the inner OpBase.MarshalJSON overriding the outer op
-// MarshalJSON
-func (op *SetMetadataOperation) MarshalJSON() ([]byte, error) {
-	base, err := json.Marshal(op.OpBase)
-	if err != nil {
-		return nil, err
-	}
-
-	// revert back to a flat map to be able to add our own fields
-	var data map[string]interface{}
-	if err := json.Unmarshal(base, &data); err != nil {
-		return nil, err
-	}
-
-	data["target"] = op.Target
-	data["new_metadata"] = op.NewMetadata
-
-	return json.Marshal(data)
-}
-
-// Workaround to avoid the inner OpBase.MarshalJSON overriding the outer op
-// MarshalJSON
+// UnmarshalJSON is a two step JSON unmarshaling
+// This workaround is necessary to avoid the inner OpBase.MarshalJSON
+// overriding the outer op's MarshalJSON
 func (op *SetMetadataOperation) UnmarshalJSON(data []byte) error {
 	// Unmarshal OpBase and the op separately
 
@@ -87,7 +70,7 @@ func (op *SetMetadataOperation) UnmarshalJSON(data []byte) error {
 	}
 
 	aux := struct {
-		Target      string            `json:"target"`
+		Target      entity.Id         `json:"target"`
 		NewMetadata map[string]string `json:"new_metadata"`
 	}{}
 
@@ -106,7 +89,7 @@ func (op *SetMetadataOperation) UnmarshalJSON(data []byte) error {
 // Sign post method for gqlgen
 func (op *SetMetadataOperation) IsAuthored() {}
 
-func NewSetMetadataOp(author identity.Interface, unixTime int64, target string, newMetadata map[string]string) *SetMetadataOperation {
+func NewSetMetadataOp(author identity.Interface, unixTime int64, target entity.Id, newMetadata map[string]string) *SetMetadataOperation {
 	return &SetMetadataOperation{
 		OpBase:      newOpBase(SetMetadataOp, author, unixTime),
 		Target:      target,
@@ -115,7 +98,7 @@ func NewSetMetadataOp(author identity.Interface, unixTime int64, target string,
 }
 
 // Convenience function to apply the operation
-func SetMetadata(b Interface, author identity.Interface, unixTime int64, target string, newMetadata map[string]string) (*SetMetadataOperation, error) {
+func SetMetadata(b Interface, author identity.Interface, unixTime int64, target entity.Id, newMetadata map[string]string) (*SetMetadataOperation, error) {
 	SetMetadataOp := NewSetMetadataOp(author, unixTime, target, newMetadata)
 	if err := SetMetadataOp.Validate(); err != nil {
 		return nil, err

bug/op_set_metadata_test.go πŸ”—

@@ -21,18 +21,18 @@ func TestSetMetadata(t *testing.T) {
 	create.Apply(&snapshot)
 	snapshot.Operations = append(snapshot.Operations, create)
 
-	hash1 := create.ID()
-	require.True(t, IDIsValid(hash1))
+	id1 := create.Id()
+	require.NoError(t, id1.Validate())
 
 	comment := NewAddCommentOp(rene, unix, "comment", nil)
 	comment.SetMetadata("key2", "value2")
 	comment.Apply(&snapshot)
 	snapshot.Operations = append(snapshot.Operations, comment)
 
-	hash2 := comment.ID()
-	require.True(t, IDIsValid(hash2))
+	id2 := comment.Id()
+	require.NoError(t, id2.Validate())
 
-	op1 := NewSetMetadataOp(rene, unix, hash1, map[string]string{
+	op1 := NewSetMetadataOp(rene, unix, id1, map[string]string{
 		"key":  "override",
 		"key2": "value",
 	})
@@ -51,7 +51,7 @@ func TestSetMetadata(t *testing.T) {
 	assert.Equal(t, len(commentMetadata), 1)
 	assert.Equal(t, commentMetadata["key2"], "value2")
 
-	op2 := NewSetMetadataOp(rene, unix, hash2, map[string]string{
+	op2 := NewSetMetadataOp(rene, unix, id2, map[string]string{
 		"key2": "value",
 		"key3": "value3",
 	})
@@ -71,7 +71,7 @@ func TestSetMetadata(t *testing.T) {
 	// new key is set
 	assert.Equal(t, commentMetadata["key3"], "value3")
 
-	op3 := NewSetMetadataOp(rene, unix, hash1, map[string]string{
+	op3 := NewSetMetadataOp(rene, unix, id1, map[string]string{
 		"key":  "override",
 		"key2": "override",
 	})
@@ -107,8 +107,9 @@ func TestSetMetadataSerialize(t *testing.T) {
 	err = json.Unmarshal(data, &after)
 	assert.NoError(t, err)
 
-	// enforce creating the ID
-	before.ID()
+	// enforce creating the IDs
+	before.Id()
+	rene.Id()
 
 	assert.Equal(t, before, &after)
 }

bug/op_set_status.go πŸ”—

@@ -5,6 +5,7 @@ import (
 
 	"github.com/pkg/errors"
 
+	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/identity"
 	"github.com/MichaelMure/git-bug/util/timestamp"
 )
@@ -14,14 +15,14 @@ var _ Operation = &SetStatusOperation{}
 // SetStatusOperation will change the status of a bug
 type SetStatusOperation struct {
 	OpBase
-	Status Status
+	Status Status `json:"status"`
 }
 
 func (op *SetStatusOperation) base() *OpBase {
 	return &op.OpBase
 }
 
-func (op *SetStatusOperation) ID() string {
+func (op *SetStatusOperation) Id() entity.Id {
 	return idOperation(op)
 }
 
@@ -30,7 +31,7 @@ func (op *SetStatusOperation) Apply(snapshot *Snapshot) {
 	snapshot.addActor(op.Author)
 
 	item := &SetStatusTimelineItem{
-		id:       op.ID(),
+		id:       op.Id(),
 		Author:   op.Author,
 		UnixTime: timestamp.Timestamp(op.UnixTime),
 		Status:   op.Status,
@@ -51,27 +52,9 @@ func (op *SetStatusOperation) Validate() error {
 	return nil
 }
 
-// Workaround to avoid the inner OpBase.MarshalJSON overriding the outer op
-// MarshalJSON
-func (op *SetStatusOperation) MarshalJSON() ([]byte, error) {
-	base, err := json.Marshal(op.OpBase)
-	if err != nil {
-		return nil, err
-	}
-
-	// revert back to a flat map to be able to add our own fields
-	var data map[string]interface{}
-	if err := json.Unmarshal(base, &data); err != nil {
-		return nil, err
-	}
-
-	data["status"] = op.Status
-
-	return json.Marshal(data)
-}
-
-// Workaround to avoid the inner OpBase.MarshalJSON overriding the outer op
-// MarshalJSON
+// UnmarshalJSON is a two step JSON unmarshaling
+// This workaround is necessary to avoid the inner OpBase.MarshalJSON
+// overriding the outer op's MarshalJSON
 func (op *SetStatusOperation) UnmarshalJSON(data []byte) error {
 	// Unmarshal OpBase and the op separately
 
@@ -107,13 +90,13 @@ func NewSetStatusOp(author identity.Interface, unixTime int64, status Status) *S
 }
 
 type SetStatusTimelineItem struct {
-	id       string
+	id       entity.Id
 	Author   identity.Interface
 	UnixTime timestamp.Timestamp
 	Status   Status
 }
 
-func (s SetStatusTimelineItem) ID() string {
+func (s SetStatusTimelineItem) Id() entity.Id {
 	return s.id
 }
 

bug/op_set_status_test.go πŸ”—

@@ -21,8 +21,9 @@ func TestSetStatusSerialize(t *testing.T) {
 	err = json.Unmarshal(data, &after)
 	assert.NoError(t, err)
 
-	// enforce creating the ID
-	before.ID()
+	// enforce creating the IDs
+	before.Id()
+	rene.Id()
 
 	assert.Equal(t, before, &after)
 }

bug/op_set_title.go πŸ”—

@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"strings"
 
+	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/identity"
 	"github.com/MichaelMure/git-bug/util/timestamp"
 
@@ -16,15 +17,15 @@ var _ Operation = &SetTitleOperation{}
 // SetTitleOperation will change the title of a bug
 type SetTitleOperation struct {
 	OpBase
-	Title string
-	Was   string
+	Title string `json:"title"`
+	Was   string `json:"was"`
 }
 
 func (op *SetTitleOperation) base() *OpBase {
 	return &op.OpBase
 }
 
-func (op *SetTitleOperation) ID() string {
+func (op *SetTitleOperation) Id() entity.Id {
 	return idOperation(op)
 }
 
@@ -33,7 +34,7 @@ func (op *SetTitleOperation) Apply(snapshot *Snapshot) {
 	snapshot.addActor(op.Author)
 
 	item := &SetTitleTimelineItem{
-		id:       op.ID(),
+		id:       op.Id(),
 		Author:   op.Author,
 		UnixTime: timestamp.Timestamp(op.UnixTime),
 		Title:    op.Title,
@@ -71,28 +72,9 @@ func (op *SetTitleOperation) Validate() error {
 	return nil
 }
 
-// Workaround to avoid the inner OpBase.MarshalJSON overriding the outer op
-// MarshalJSON
-func (op *SetTitleOperation) MarshalJSON() ([]byte, error) {
-	base, err := json.Marshal(op.OpBase)
-	if err != nil {
-		return nil, err
-	}
-
-	// revert back to a flat map to be able to add our own fields
-	var data map[string]interface{}
-	if err := json.Unmarshal(base, &data); err != nil {
-		return nil, err
-	}
-
-	data["title"] = op.Title
-	data["was"] = op.Was
-
-	return json.Marshal(data)
-}
-
-// Workaround to avoid the inner OpBase.MarshalJSON overriding the outer op
-// MarshalJSON
+// UnmarshalJSON is a two step JSON unmarshaling
+// This workaround is necessary to avoid the inner OpBase.MarshalJSON
+// overriding the outer op's MarshalJSON
 func (op *SetTitleOperation) UnmarshalJSON(data []byte) error {
 	// Unmarshal OpBase and the op separately
 
@@ -131,14 +113,14 @@ func NewSetTitleOp(author identity.Interface, unixTime int64, title string, was
 }
 
 type SetTitleTimelineItem struct {
-	id       string
+	id       entity.Id
 	Author   identity.Interface
 	UnixTime timestamp.Timestamp
 	Title    string
 	Was      string
 }
 
-func (s SetTitleTimelineItem) ID() string {
+func (s SetTitleTimelineItem) Id() entity.Id {
 	return s.id
 }
 

bug/op_set_title_test.go πŸ”—

@@ -21,8 +21,9 @@ func TestSetTitleSerialize(t *testing.T) {
 	err = json.Unmarshal(data, &after)
 	assert.NoError(t, err)
 
-	// enforce creating the ID
-	before.ID()
+	// enforce creating the IDs
+	before.Id()
+	rene.Id()
 
 	assert.Equal(t, before, &after)
 }

bug/operation.go πŸ”—

@@ -6,10 +6,11 @@ import (
 	"fmt"
 	"time"
 
-	"github.com/MichaelMure/git-bug/identity"
+	"github.com/pkg/errors"
 
+	"github.com/MichaelMure/git-bug/entity"
+	"github.com/MichaelMure/git-bug/identity"
 	"github.com/MichaelMure/git-bug/util/git"
-	"github.com/pkg/errors"
 )
 
 // OperationType is an operation type identifier
@@ -27,14 +28,12 @@ const (
 	SetMetadataOp
 )
 
-const unsetIDMarker = "unset"
-
 // Operation define the interface to fulfill for an edit operation of a Bug
 type Operation interface {
 	// base return the OpBase of the Operation, for package internal use
 	base() *OpBase
-	// ID return the identifier of the operation, to be used for back references
-	ID() string
+	// Id return the identifier of the operation, to be used for back references
+	Id() entity.Id
 	// Time return the time when the operation was added
 	Time() time.Time
 	// GetUnixTime return the unix timestamp when the operation was added
@@ -55,57 +54,42 @@ type Operation interface {
 	GetAuthor() identity.Interface
 }
 
-func hashRaw(data []byte) string {
-	hasher := sha256.New()
-	// Write can't fail
-	_, _ = hasher.Write(data)
-	return fmt.Sprintf("%x", hasher.Sum(nil))
+func deriveId(data []byte) entity.Id {
+	sum := sha256.Sum256(data)
+	return entity.Id(fmt.Sprintf("%x", sum))
 }
 
-func idOperation(op Operation) string {
+func idOperation(op Operation) entity.Id {
 	base := op.base()
 
 	if base.id == "" {
 		// something went really wrong
 		panic("op's id not set")
 	}
-	if base.id == "unset" {
-		// This means we are trying to get the op's ID *before* it has been stored, for instance when
+	if base.id == entity.UnsetId {
+		// This means we are trying to get the op's Id *before* it has been stored, for instance when
 		// adding multiple ops in one go in an OperationPack.
-		// As the ID is computed based on the actual bytes written on the disk, we are going to predict
-		// those and then get the ID. This is safe as it will be the exact same code writing on disk later.
+		// As the Id is computed based on the actual bytes written on the disk, we are going to predict
+		// those and then get the Id. This is safe as it will be the exact same code writing on disk later.
 
 		data, err := json.Marshal(op)
 		if err != nil {
 			panic(err)
 		}
 
-		base.id = hashRaw(data)
+		base.id = deriveId(data)
 	}
 	return base.id
 }
 
-func IDIsValid(id string) bool {
-	// IDs have the same format as a git hash
-	if len(id) != 40 && len(id) != 64 {
-		return false
-	}
-	for _, r := range id {
-		if (r < 'a' || r > 'z') && (r < '0' || r > '9') {
-			return false
-		}
-	}
-	return true
-}
-
 // OpBase implement the common code for all operations
 type OpBase struct {
-	OperationType OperationType
-	Author        identity.Interface
-	UnixTime      int64
-	Metadata      map[string]string
+	OperationType OperationType      `json:"type"`
+	Author        identity.Interface `json:"author"`
+	UnixTime      int64              `json:"timestamp"`
+	Metadata      map[string]string  `json:"metadata,omitempty"`
 	// Not serialized. Store the op's id in memory.
-	id string
+	id entity.Id
 	// Not serialized. Store the extra metadata in memory,
 	// compiled from SetMetadataOperation.
 	extraMetadata map[string]string
@@ -117,27 +101,13 @@ func newOpBase(opType OperationType, author identity.Interface, unixTime int64)
 		OperationType: opType,
 		Author:        author,
 		UnixTime:      unixTime,
-		id:            unsetIDMarker,
+		id:            entity.UnsetId,
 	}
 }
 
-func (op OpBase) MarshalJSON() ([]byte, error) {
-	return json.Marshal(struct {
-		OperationType OperationType      `json:"type"`
-		Author        identity.Interface `json:"author"`
-		UnixTime      int64              `json:"timestamp"`
-		Metadata      map[string]string  `json:"metadata,omitempty"`
-	}{
-		OperationType: op.OperationType,
-		Author:        op.Author,
-		UnixTime:      op.UnixTime,
-		Metadata:      op.Metadata,
-	})
-}
-
 func (op *OpBase) UnmarshalJSON(data []byte) error {
-	// Compute the ID when loading the op from disk.
-	op.id = hashRaw(data)
+	// Compute the Id when loading the op from disk.
+	op.id = deriveId(data)
 
 	aux := struct {
 		OperationType OperationType     `json:"type"`
@@ -213,7 +183,7 @@ func (op *OpBase) SetMetadata(key string, value string) {
 	}
 
 	op.Metadata[key] = value
-	op.id = unsetIDMarker
+	op.id = entity.UnsetId
 }
 
 // GetMetadata retrieve arbitrary metadata about the operation

bug/operation_pack_test.go πŸ”—

@@ -48,14 +48,16 @@ func TestOperationPackSerialize(t *testing.T) {
 	err = json.Unmarshal(data, &opp2)
 	assert.NoError(t, err)
 
-	ensureID(t, opp)
+	ensureIDs(t, opp)
 
 	assert.Equal(t, opp, opp2)
 }
 
-func ensureID(t *testing.T, opp *OperationPack) {
+func ensureIDs(t *testing.T, opp *OperationPack) {
 	for _, op := range opp.Operations {
-		id := op.ID()
-		require.True(t, IDIsValid(id))
+		id := op.Id()
+		require.NoError(t, id.Validate())
+		id = op.GetAuthor().Id()
+		require.NoError(t, id.Validate())
 	}
 }

bug/operation_test.go πŸ”—

@@ -94,26 +94,26 @@ func TestID(t *testing.T) {
 		b, op, err := Create(rene, time.Now().Unix(), "title", "message")
 		require.Nil(t, err)
 
-		id1 := op.ID()
-		require.True(t, IDIsValid(id1))
+		id1 := op.Id()
+		require.NoError(t, id1.Validate())
 
 		err = b.Commit(repo)
 		require.Nil(t, err)
 
 		op2 := b.FirstOp()
 
-		id2 := op2.ID()
-		require.True(t, IDIsValid(id2))
+		id2 := op2.Id()
+		require.NoError(t, id2.Validate())
 
 		require.Equal(t, id1, id2)
 
-		b2, err := ReadLocalBug(repo, b.id)
+		b2, err := ReadLocalBug(repo, b.Id())
 		require.Nil(t, err)
 
 		op3 := b2.FirstOp()
 
-		id3 := op3.ID()
-		require.True(t, IDIsValid(id3))
+		id3 := op3.Id()
+		require.NoError(t, id3.Validate())
 
 		require.Equal(t, id1, id3)
 	}

bug/snapshot.go πŸ”—

@@ -4,12 +4,13 @@ import (
 	"fmt"
 	"time"
 
+	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/identity"
 )
 
 // Snapshot is a compiled form of the Bug data structure used for storage and merge
 type Snapshot struct {
-	id string
+	id entity.Id
 
 	Status       Status
 	Title        string
@@ -26,15 +27,10 @@ type Snapshot struct {
 }
 
 // Return the Bug identifier
-func (snap *Snapshot) Id() string {
+func (snap *Snapshot) Id() entity.Id {
 	return snap.id
 }
 
-// Return the Bug identifier truncated for human consumption
-func (snap *Snapshot) HumanId() string {
-	return FormatHumanID(snap.id)
-}
-
 // Return the last time a bug was modified
 func (snap *Snapshot) LastEditTime() time.Time {
 	if len(snap.Operations) == 0 {
@@ -59,9 +55,9 @@ func (snap *Snapshot) GetCreateMetadata(key string) (string, bool) {
 }
 
 // SearchTimelineItem will search in the timeline for an item matching the given hash
-func (snap *Snapshot) SearchTimelineItem(ID string) (TimelineItem, error) {
+func (snap *Snapshot) SearchTimelineItem(id entity.Id) (TimelineItem, error) {
 	for i := range snap.Timeline {
-		if snap.Timeline[i].ID() == ID {
+		if snap.Timeline[i].Id() == id {
 			return snap.Timeline[i], nil
 		}
 	}
@@ -72,7 +68,7 @@ func (snap *Snapshot) SearchTimelineItem(ID string) (TimelineItem, error) {
 // SearchComment will search for a comment matching the given hash
 func (snap *Snapshot) SearchComment(id string) (*Comment, error) {
 	for _, c := range snap.Comments {
-		if c.id == id {
+		if c.id.String() == id {
 			return &c, nil
 		}
 	}
@@ -103,7 +99,7 @@ func (snap *Snapshot) addParticipant(participant identity.Interface) {
 }
 
 // HasParticipant return true if the id is a participant
-func (snap *Snapshot) HasParticipant(id string) bool {
+func (snap *Snapshot) HasParticipant(id entity.Id) bool {
 	for _, p := range snap.Participants {
 		if p.Id() == id {
 			return true
@@ -113,7 +109,7 @@ func (snap *Snapshot) HasParticipant(id string) bool {
 }
 
 // HasAnyParticipant return true if one of the ids is a participant
-func (snap *Snapshot) HasAnyParticipant(ids ...string) bool {
+func (snap *Snapshot) HasAnyParticipant(ids ...entity.Id) bool {
 	for _, id := range ids {
 		if snap.HasParticipant(id) {
 			return true
@@ -123,7 +119,7 @@ func (snap *Snapshot) HasAnyParticipant(ids ...string) bool {
 }
 
 // HasActor return true if the id is a actor
-func (snap *Snapshot) HasActor(id string) bool {
+func (snap *Snapshot) HasActor(id entity.Id) bool {
 	for _, p := range snap.Actors {
 		if p.Id() == id {
 			return true
@@ -133,7 +129,7 @@ func (snap *Snapshot) HasActor(id string) bool {
 }
 
 // HasAnyActor return true if one of the ids is a actor
-func (snap *Snapshot) HasAnyActor(ids ...string) bool {
+func (snap *Snapshot) HasAnyActor(ids ...entity.Id) bool {
 	for _, id := range ids {
 		if snap.HasActor(id) {
 			return true

bug/timeline.go πŸ”—

@@ -3,6 +3,7 @@ package bug
 import (
 	"strings"
 
+	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/identity"
 	"github.com/MichaelMure/git-bug/util/git"
 	"github.com/MichaelMure/git-bug/util/timestamp"
@@ -10,7 +11,7 @@ import (
 
 type TimelineItem interface {
 	// ID return the identifier of the item
-	ID() string
+	Id() entity.Id
 }
 
 // CommentHistoryStep hold one version of a message in the history
@@ -25,7 +26,7 @@ type CommentHistoryStep struct {
 
 // CommentTimelineItem is a TimelineItem that holds a Comment and its edition history
 type CommentTimelineItem struct {
-	id        string
+	id        entity.Id
 	Author    identity.Interface
 	Message   string
 	Files     []git.Hash
@@ -34,7 +35,7 @@ type CommentTimelineItem struct {
 	History   []CommentHistoryStep
 }
 
-func NewCommentTimelineItem(ID string, comment Comment) CommentTimelineItem {
+func NewCommentTimelineItem(ID entity.Id, comment Comment) CommentTimelineItem {
 	return CommentTimelineItem{
 		id:        ID,
 		Author:    comment.Author,
@@ -51,7 +52,7 @@ func NewCommentTimelineItem(ID string, comment Comment) CommentTimelineItem {
 	}
 }
 
-func (c *CommentTimelineItem) ID() string {
+func (c *CommentTimelineItem) Id() entity.Id {
 	return c.id
 }
 

cache/bug_excerpt.go πŸ”—

@@ -5,6 +5,7 @@ import (
 	"fmt"
 
 	"github.com/MichaelMure/git-bug/bug"
+	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/identity"
 	"github.com/MichaelMure/git-bug/util/lamport"
 )
@@ -17,7 +18,7 @@ func init() {
 // BugExcerpt hold a subset of the bug values to be able to sort and filter bugs
 // efficiently without having to read and compile each raw bugs.
 type BugExcerpt struct {
-	Id string
+	Id entity.ID
 
 	CreateLamportTime lamport.Time
 	EditLamportTime   lamport.Time
@@ -28,8 +29,8 @@ type BugExcerpt struct {
 	Labels       []bug.Label
 	Title        string
 	LenComments  int
-	Actors       []string
-	Participants []string
+	Actors       []entity.ID
+	Participants []entity.ID
 
 	// If author is identity.Bare, LegacyAuthor is set
 	// If author is identity.Identity, AuthorId is set and data is deported

entity/id.go πŸ”—

@@ -0,0 +1,65 @@
+package entity
+
+import (
+	"fmt"
+	"io"
+	"strings"
+
+	"github.com/pkg/errors"
+)
+
+const IdLengthSHA1 = 40
+const IdLengthSHA256 = 64
+const humanIdLength = 7
+
+const UnsetId = Id("unset")
+
+// Id is an identifier for an entity or part of an entity
+type Id string
+
+func (i Id) String() string {
+	return string(i)
+}
+
+func (i Id) Human() string {
+	format := fmt.Sprintf("%%.%ds", humanIdLength)
+	return fmt.Sprintf(format, i)
+}
+
+func (i Id) HasPrefix(prefix string) bool {
+	return strings.HasPrefix(string(i), prefix)
+}
+
+// UnmarshalGQL implement the Unmarshaler interface for gqlgen
+func (i *Id) UnmarshalGQL(v interface{}) error {
+	_, ok := v.(string)
+	if !ok {
+		return fmt.Errorf("IDs must be strings")
+	}
+
+	*i = v.(Id)
+
+	if err := i.Validate(); err != nil {
+		return errors.Wrap(err, "invalid ID")
+	}
+
+	return nil
+}
+
+// MarshalGQL implement the Marshaler interface for gqlgen
+func (i Id) MarshalGQL(w io.Writer) {
+	_, _ = w.Write([]byte(`"` + i.String() + `"`))
+}
+
+// IsValid tell if the Id is valid
+func (i Id) Validate() error {
+	if len(i) != IdLengthSHA1 && len(i) != IdLengthSHA256 {
+		return fmt.Errorf("invalid length")
+	}
+	for _, r := range i {
+		if (r < 'a' || r > 'z') && (r < '0' || r > '9') {
+			return fmt.Errorf("invalid character")
+		}
+	}
+	return nil
+}

entity/interface.go πŸ”—

@@ -2,7 +2,5 @@ package entity
 
 type Interface interface {
 	// Id return the Entity identifier
-	Id() string
-	// HumanId return the Entity identifier truncated for human consumption
-	HumanId() string
+	Id() Id
 }

identity/bare.go πŸ”—

@@ -6,6 +6,7 @@ import (
 	"fmt"
 	"strings"
 
+	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/repository"
 	"github.com/MichaelMure/git-bug/util/lamport"
 	"github.com/MichaelMure/git-bug/util/text"
@@ -13,6 +14,7 @@ import (
 )
 
 var _ Interface = &Bare{}
+var _ entity.Interface = &Bare{}
 
 // Bare is a very minimal identity, designed to be fully embedded directly along
 // other data.
@@ -20,7 +22,7 @@ var _ Interface = &Bare{}
 // in particular, this identity is designed to be compatible with the handling of
 // identities in the early version of git-bug.
 type Bare struct {
-	id        string
+	id        entity.Id
 	name      string
 	email     string
 	login     string
@@ -28,11 +30,16 @@ type Bare struct {
 }
 
 func NewBare(name string, email string) *Bare {
-	return &Bare{name: name, email: email}
+	return &Bare{id: entity.UnsetId, name: name, email: email}
 }
 
 func NewBareFull(name string, email string, login string, avatarUrl string) *Bare {
-	return &Bare{name: name, email: email, login: login, avatarUrl: avatarUrl}
+	return &Bare{id: entity.UnsetId, name: name, email: email, login: login, avatarUrl: avatarUrl}
+}
+
+func deriveId(data []byte) entity.Id {
+	sum := sha256.Sum256(data)
+	return entity.Id(fmt.Sprintf("%x", sum))
 }
 
 type bareIdentityJSON struct {
@@ -43,15 +50,19 @@ type bareIdentityJSON struct {
 }
 
 func (i *Bare) MarshalJSON() ([]byte, error) {
-	return json.Marshal(bareIdentityJSON{
+	data, err := json.Marshal(bareIdentityJSON{
 		Name:      i.name,
 		Email:     i.email,
 		Login:     i.login,
 		AvatarUrl: i.avatarUrl,
 	})
+	return data, err
 }
 
 func (i *Bare) UnmarshalJSON(data []byte) error {
+	// Compute the Id when loading the op from disk.
+	i.id = deriveId(data)
+
 	aux := bareIdentityJSON{}
 
 	if err := json.Unmarshal(data, &aux); err != nil {
@@ -67,30 +78,28 @@ func (i *Bare) UnmarshalJSON(data []byte) error {
 }
 
 // Id return the Identity identifier
-func (i *Bare) Id() string {
-	// We don't have a proper ID at hand, so let's hash all the data to get one.
-	// Hopefully the
-
-	if i.id != "" {
-		return i.id
-	}
+func (i *Bare) Id() entity.Id {
+	// We don't have a proper Id at hand, so let's hash all the data to get one.
 
-	data, err := json.Marshal(i)
-	if err != nil {
-		panic(err)
+	if i.id == "" {
+		// something went really wrong
+		panic("identity's id not set")
 	}
+	if i.id == entity.UnsetId {
+		// This means we are trying to get the identity identifier *before* it has been stored
+		// As the Id is computed based on the actual bytes written on the disk, we are going to predict
+		// those and then get the Id. This is safe as it will be the exact same code writing on disk later.
 
-	h := fmt.Sprintf("%x", sha256.New().Sum(data)[:16])
-	i.id = string(h)
+		data, err := json.Marshal(i)
+		if err != nil {
+			panic(err)
+		}
 
+		i.id = deriveId(data)
+	}
 	return i.id
 }
 
-// HumanId return the Identity identifier truncated for human consumption
-func (i *Bare) HumanId() string {
-	return FormatHumanID(i.Id())
-}
-
 // Name return the last version of the name
 func (i *Bare) Name() string {
 	return i.name

identity/bare_test.go πŸ”—

@@ -5,12 +5,15 @@ import (
 	"testing"
 
 	"github.com/stretchr/testify/assert"
+
+	"github.com/MichaelMure/git-bug/entity"
 )
 
 func TestBare_Id(t *testing.T) {
 	i := NewBare("name", "email")
 	id := i.Id()
-	assert.Equal(t, "7b226e616d65223a226e616d65222c22", id)
+	expected := entity.Id("e18b853fbd89d5d40ca24811539c9a800c705abd9232f396954e8ca8bb63fa8a")
+	assert.Equal(t, expected, id)
 }
 
 func TestBareSerialize(t *testing.T) {
@@ -28,5 +31,7 @@ func TestBareSerialize(t *testing.T) {
 	err = json.Unmarshal(data, &after)
 	assert.NoError(t, err)
 
+	before.id = after.id
+
 	assert.Equal(t, before, &after)
 }

identity/common.go πŸ”—

@@ -37,11 +37,11 @@ func UnmarshalJSON(raw json.RawMessage) (Interface, error) {
 	}
 
 	// Fallback on a legacy Bare identity
-	var b Bare
+	b := &Bare{}
 
-	err = json.Unmarshal(raw, &b)
+	err = json.Unmarshal(raw, b)
 	if err == nil && (b.name != "" || b.login != "") {
-		return &b, nil
+		return b, nil
 	}
 
 	// abort if we have an error other than the wrong type

identity/identity.go πŸ”—

@@ -10,6 +10,7 @@ import (
 
 	"github.com/pkg/errors"
 
+	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/repository"
 	"github.com/MichaelMure/git-bug/util/git"
 	"github.com/MichaelMure/git-bug/util/lamport"
@@ -21,18 +22,16 @@ const identityRemoteRefPattern = "refs/remotes/%s/identities/"
 const versionEntryName = "version"
 const identityConfigKey = "git-bug.identity"
 
-const idLength = 40
-const humanIdLength = 7
-
 var ErrNonFastForwardMerge = errors.New("non fast-forward identity merge")
 var ErrNoIdentitySet = errors.New("to interact with bugs, an identity first needs to be created using \"git bug user create\" or \"git bug user adopt\"")
 var ErrMultipleIdentitiesSet = errors.New("multiple user identities set")
 
 var _ Interface = &Identity{}
+var _ entity.Interface = &Identity{}
 
 type Identity struct {
 	// Id used as unique identifier
-	id string
+	id entity.Id
 
 	// all the successive version of the identity
 	versions []*Version
@@ -43,6 +42,7 @@ type Identity struct {
 
 func NewIdentity(name string, email string) *Identity {
 	return &Identity{
+		id: entity.UnsetId,
 		versions: []*Version{
 			{
 				name:  name,
@@ -55,6 +55,7 @@ func NewIdentity(name string, email string) *Identity {
 
 func NewIdentityFull(name string, email string, login string, avatarUrl string) *Identity {
 	return &Identity{
+		id: entity.UnsetId,
 		versions: []*Version{
 			{
 				name:      name,
@@ -70,7 +71,7 @@ func NewIdentityFull(name string, email string, login string, avatarUrl string)
 // MarshalJSON will only serialize the id
 func (i *Identity) MarshalJSON() ([]byte, error) {
 	return json.Marshal(&IdentityStub{
-		id: i.Id(),
+		id: i.id,
 	})
 }
 
@@ -82,7 +83,7 @@ func (i *Identity) UnmarshalJSON(data []byte) error {
 }
 
 // ReadLocal load a local Identity from the identities data available in git
-func ReadLocal(repo repository.Repo, id string) (*Identity, error) {
+func ReadLocal(repo repository.Repo, id entity.Id) (*Identity, error) {
 	ref := fmt.Sprintf("%s%s", identityRefPattern, id)
 	return read(repo, ref)
 }
@@ -96,10 +97,10 @@ func ReadRemote(repo repository.Repo, remote string, id string) (*Identity, erro
 // read will load and parse an identity from git
 func read(repo repository.Repo, ref string) (*Identity, error) {
 	refSplit := strings.Split(ref, "/")
-	id := refSplit[len(refSplit)-1]
+	id := entity.Id(refSplit[len(refSplit)-1])
 
-	if len(id) != idLength {
-		return nil, fmt.Errorf("invalid ref length")
+	if err := id.Validate(); err != nil {
+		return nil, errors.Wrap(err, "invalid ref")
 	}
 
 	hashes, err := repo.ListCommits(ref)
@@ -233,7 +234,7 @@ func IsUserIdentitySet(repo repository.RepoCommon) (bool, error) {
 
 // SetUserIdentity store the user identity's id in the git config
 func SetUserIdentity(repo repository.RepoCommon, identity *Identity) error {
-	return repo.StoreConfig(identityConfigKey, identity.Id())
+	return repo.StoreConfig(identityConfigKey, identity.Id().String())
 }
 
 // GetUserIdentity read the current user identity, set with a git config entry
@@ -251,9 +252,13 @@ func GetUserIdentity(repo repository.Repo) (*Identity, error) {
 		return nil, ErrMultipleIdentitiesSet
 	}
 
-	var id string
+	var id entity.Id
 	for _, val := range configs {
-		id = val
+		id = entity.Id(val)
+	}
+
+	if err := id.Validate(); err != nil {
+		return nil, err
 	}
 
 	i, err := ReadLocal(repo, id)
@@ -326,8 +331,8 @@ func (i *Identity) Commit(repo repository.ClockedRepo) error {
 		v.commitHash = commitHash
 
 		// if it was the first commit, use the commit hash as the Identity id
-		if i.id == "" {
-			i.id = string(commitHash)
+		if i.id == "" || i.id == entity.UnsetId {
+			i.id = entity.Id(commitHash)
 		}
 	}
 
@@ -410,7 +415,7 @@ func (i *Identity) Merge(repo repository.Repo, other *Identity) (bool, error) {
 	}
 
 	if modified {
-		err := repo.UpdateRef(identityRefPattern+i.id, i.lastCommit)
+		err := repo.UpdateRef(identityRefPattern+i.id.String(), i.lastCommit)
 		if err != nil {
 			return false, err
 		}
@@ -439,8 +444,8 @@ func (i *Identity) Validate() error {
 		lastTime = v.time
 	}
 
-	// The identity ID should be the hash of the first commit
-	if i.versions[0].commitHash != "" && string(i.versions[0].commitHash) != i.id {
+	// The identity Id should be the hash of the first commit
+	if i.versions[0].commitHash != "" && string(i.versions[0].commitHash) != i.id.String() {
 		return fmt.Errorf("identity id should be the first commit hash")
 	}
 
@@ -456,7 +461,7 @@ func (i *Identity) lastVersion() *Version {
 }
 
 // Id return the Identity identifier
-func (i *Identity) Id() string {
+func (i *Identity) Id() entity.Id {
 	if i.id == "" {
 		// simply panic as it would be a coding error
 		// (using an id of an identity not stored yet)
@@ -465,16 +470,6 @@ func (i *Identity) Id() string {
 	return i.id
 }
 
-// HumanId return the Identity identifier truncated for human consumption
-func (i *Identity) HumanId() string {
-	return FormatHumanID(i.Id())
-}
-
-func FormatHumanID(id string) string {
-	format := fmt.Sprintf("%%.%ds", humanIdLength)
-	return fmt.Sprintf(format, id)
-}
-
 // Name return the last version of the name
 func (i *Identity) Name() string {
 	return i.lastVersion().name

identity/identity_actions.go πŸ”—

@@ -75,7 +75,7 @@ func MergeAll(repo repository.ClockedRepo, remote string) <-chan entity.MergeRes
 				continue
 			}
 
-			localRef := identityRefPattern + remoteIdentity.Id()
+			localRef := identityRefPattern + remoteIdentity.Id().String()
 			localExist, err := repo.RefExist(localRef)
 
 			if err != nil {

identity/identity_stub.go πŸ”—

@@ -3,6 +3,7 @@ package identity
 import (
 	"encoding/json"
 
+	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/repository"
 	"github.com/MichaelMure/git-bug/util/lamport"
 	"github.com/MichaelMure/git-bug/util/timestamp"
@@ -16,13 +17,13 @@ var _ Interface = &IdentityStub{}
 // When this JSON is deserialized, an IdentityStub is returned instead, to be replaced
 // later by the proper Identity, loaded from the Repo.
 type IdentityStub struct {
-	id string
+	id entity.Id
 }
 
 func (i *IdentityStub) MarshalJSON() ([]byte, error) {
 	// TODO: add a type marker
 	return json.Marshal(struct {
-		Id string `json:"id"`
+		Id entity.Id `json:"id"`
 	}{
 		Id: i.id,
 	})
@@ -30,7 +31,7 @@ func (i *IdentityStub) MarshalJSON() ([]byte, error) {
 
 func (i *IdentityStub) UnmarshalJSON(data []byte) error {
 	aux := struct {
-		Id string `json:"id"`
+		Id entity.Id `json:"id"`
 	}{}
 
 	if err := json.Unmarshal(data, &aux); err != nil {
@@ -43,15 +44,10 @@ func (i *IdentityStub) UnmarshalJSON(data []byte) error {
 }
 
 // Id return the Identity identifier
-func (i *IdentityStub) Id() string {
+func (i *IdentityStub) Id() entity.Id {
 	return i.id
 }
 
-// HumanId return the Identity identifier truncated for human consumption
-func (i *IdentityStub) HumanId() string {
-	return FormatHumanID(i.Id())
-}
-
 func (IdentityStub) Name() string {
 	panic("identities needs to be properly loaded with identity.ReadLocal()")
 }

identity/identity_stub_test.go πŸ”—

@@ -19,5 +19,8 @@ func TestIdentityStubSerialize(t *testing.T) {
 	err = json.Unmarshal(data, &after)
 	assert.NoError(t, err)
 
+	// enforce creating the Id
+	before.Id()
+
 	assert.Equal(t, before, &after)
 }

identity/identity_test.go πŸ”—

@@ -4,6 +4,7 @@ import (
 	"encoding/json"
 	"testing"
 
+	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/repository"
 	"github.com/stretchr/testify/assert"
 )
@@ -15,6 +16,7 @@ func TestIdentityCommitLoad(t *testing.T) {
 	// single version
 
 	identity := &Identity{
+		id: entity.UnsetId,
 		versions: []*Version{
 			{
 				name:  "RenΓ© Descartes",
@@ -36,6 +38,7 @@ func TestIdentityCommitLoad(t *testing.T) {
 	// multiple version
 
 	identity = &Identity{
+		id: entity.UnsetId,
 		versions: []*Version{
 			{
 				time:  100,
@@ -114,6 +117,7 @@ func commitsAreSet(t *testing.T, identity *Identity) {
 // Test that the correct crypto keys are returned for a given lamport time
 func TestIdentity_ValidKeysAtTime(t *testing.T) {
 	identity := Identity{
+		id: entity.UnsetId,
 		versions: []*Version{
 			{
 				time:  100,
@@ -215,6 +219,7 @@ func TestJSON(t *testing.T) {
 	mockRepo := repository.NewMockRepoForTest()
 
 	identity := &Identity{
+		id: entity.UnsetId,
 		versions: []*Version{
 			{
 				name:  "RenΓ© Descartes",
@@ -223,7 +228,7 @@ func TestJSON(t *testing.T) {
 		},
 	}
 
-	// commit to make sure we have an ID
+	// commit to make sure we have an Id
 	err := identity.Commit(mockRepo)
 	assert.Nil(t, err)
 	assert.NotEmpty(t, identity.id)

identity/interface.go πŸ”—

@@ -1,6 +1,7 @@
 package identity
 
 import (
+	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/repository"
 	"github.com/MichaelMure/git-bug/util/lamport"
 	"github.com/MichaelMure/git-bug/util/timestamp"
@@ -8,10 +9,7 @@ import (
 
 type Interface interface {
 	// Id return the Identity identifier
-	Id() string
-
-	// HumanId return the Identity identifier truncated for human consumption
-	HumanId() string
+	Id() entity.Id
 
 	// Name return the last version of the name
 	Name() string

identity/resolver.go πŸ”—

@@ -1,11 +1,14 @@
 package identity
 
-import "github.com/MichaelMure/git-bug/repository"
+import (
+	"github.com/MichaelMure/git-bug/entity"
+	"github.com/MichaelMure/git-bug/repository"
+)
 
 // Resolver define the interface of an Identity resolver, able to load
 // an identity from, for example, a repo or a cache.
 type Resolver interface {
-	ResolveIdentity(id string) (Interface, error)
+	ResolveIdentity(id entity.Id) (Interface, error)
 }
 
 // DefaultResolver is a Resolver loading Identities directly from a Repo
@@ -17,6 +20,6 @@ func NewSimpleResolver(repo repository.Repo) *SimpleResolver {
 	return &SimpleResolver{repo: repo}
 }
 
-func (r *SimpleResolver) ResolveIdentity(id string) (Interface, error) {
+func (r *SimpleResolver) ResolveIdentity(id entity.Id) (Interface, error) {
 	return ReadLocal(r.repo, id)
 }

util/git/hash.go πŸ”—

@@ -5,6 +5,9 @@ import (
 	"io"
 )
 
+const idLengthSHA1 = 40
+const idLengthSHA256 = 64
+
 // Hash is a git hash
 type Hash string
 
@@ -16,7 +19,7 @@ func (h Hash) String() string {
 func (h *Hash) UnmarshalGQL(v interface{}) error {
 	_, ok := v.(string)
 	if !ok {
-		return fmt.Errorf("labels must be strings")
+		return fmt.Errorf("hashes must be strings")
 	}
 
 	*h = v.(Hash)
@@ -35,7 +38,8 @@ func (h Hash) MarshalGQL(w io.Writer) {
 
 // IsValid tell if the hash is valid
 func (h *Hash) IsValid() bool {
-	if len(*h) != 40 && len(*h) != 64 {
+	// Support for both sha1 and sha256 git hashes
+	if len(*h) != idLengthSHA1 && len(*h) != idLengthSHA256 {
 		return false
 	}
 	for _, r := range *h {