invert package dependency between identity<-->entity

Michael Muré created

Change summary

api/graphql/models/lazy_bug.go             |  2 
api/graphql/resolvers/mutation.go          | 18 ++--
bridge/github/export.go                    |  4 
bridge/github/export_test.go               | 10 +-
bridge/github/import_integration_test.go   |  6 
bridge/github/import_test.go               |  4 
bridge/gitlab/export.go                    |  4 
bridge/gitlab/export_test.go               | 10 +-
bridge/gitlab/import.go                    |  4 
bridge/gitlab/import_test.go               |  2 
bridge/jira/export.go                      |  4 
bridge/jira/import.go                      |  4 
cache/bug_cache.go                         | 23 +++--
cache/bug_excerpt.go                       |  2 
cache/bug_subcache.go                      |  7 -
cache/cached.go                            | 15 +++
cache/identity_cache.go                    |  2 
cache/identity_subcache.go                 |  2 
cache/repo_cache.go                        |  3 
cache/subcache.go                          |  4 
commands/bug/bug_comment.go                |  2 
commands/bug/bug_label.go                  |  2 
commands/bug/bug_select.go                 |  2 
commands/bug/bug_show.go                   |  2 
commands/bug/bug_status.go                 |  2 
commands/bug/bug_title.go                  |  2 
commands/bug/bug_title_edit.go             |  2 
commands/bug/completion.go                 |  2 
commands/cmdjson/json_common.go            |  4 
entities/bug/bug.go                        |  9 +-
entities/bug/op_add_comment.go             |  8 +
entities/bug/op_create.go                  |  8 +
entities/bug/op_edit_comment.go            |  8 +
entities/bug/operation.go                  |  4 
entities/bug/snapshot.go                   |  9 +-
entities/identity/identity.go              | 32 ++++----
entities/identity/identity_actions.go      | 40 +++++-----
entities/identity/identity_actions_test.go |  4 
entities/identity/identity_stub.go         | 10 +-
entities/identity/identity_test.go         |  8 +-
entities/identity/identity_user.go         | 16 ++--
entities/identity/interface.go             |  4 
entities/identity/resolver.go              |  6 
entities/identity/version.go               | 20 ++--
entities/identity/version_test.go          |  4 
entity/boostrap/entity.go                  |  5 
entity/boostrap/err.go                     | 84 ++++++++++++++++++++++
entity/boostrap/id.go                      | 81 +++++++++++++++++++++
entity/boostrap/merge.go                   | 91 ++++++++++++++++++++++++
entity/boostrap/refs.go                    | 20 +++++
entity/boostrap/resolver.go                | 13 +++
entity/boostrap/streamed.go                |  7 +
entity/dag/common_test.go                  |  4 
entity/dag/entity.go                       | 17 ++--
entity/dag/entity_actions.go               |  6 
entity/dag/entity_actions_test.go          |  3 
entity/dag/example_test.go                 |  4 
entity/dag/op_noop.go                      |  8 +-
entity/dag/op_set_metadata.go              |  8 +-
entity/dag/op_set_metadata_test.go         |  8 +-
entity/dag/operation.go                    | 81 ++------------------
entity/dag/operation_pack.go               |  2 
entity/dag/operation_pack_test.go          |  5 
entity/entity.go                           | 47 +++++++-----
entity/err.go                              | 65 ++---------------
entity/id.go                               | 76 +------------------
entity/id_interleaved.go                   |  6 +
entity/identity.go                         | 73 +++++++++++++++++++
entity/merge.go                            | 88 +++-------------------
entity/operations.go                       | 76 ++++++++++++++++++++
entity/refs.go                             | 19 +---
entity/resolver.go                         | 11 +-
entity/snapshot.go                         | 14 +++
termui/label_select.go                     |  4 
termui/show_bug.go                         |  8 +-
termui/termui.go                           |  2 
76 files changed, 762 insertions(+), 514 deletions(-)

Detailed changes

api/graphql/resolvers/mutation.go 🔗

@@ -62,7 +62,7 @@ func (r mutationResolver) NewBug(ctx context.Context, input models.NewBugInput)
 
 	return &models.NewBugPayload{
 		ClientMutationID: input.ClientMutationID,
-		Bug:              models.NewLoadedBug(b.Snapshot()),
+		Bug:              models.NewLoadedBug(b.Compile()),
 		Operation:        op,
 	}, nil
 }
@@ -94,7 +94,7 @@ func (r mutationResolver) AddComment(ctx context.Context, input models.AddCommen
 
 	return &models.AddCommentPayload{
 		ClientMutationID: input.ClientMutationID,
-		Bug:              models.NewLoadedBug(b.Snapshot()),
+		Bug:              models.NewLoadedBug(b.Compile()),
 		Operation:        op,
 	}, nil
 }
@@ -131,7 +131,7 @@ func (r mutationResolver) AddCommentAndClose(ctx context.Context, input models.A
 
 	return &models.AddCommentAndCloseBugPayload{
 		ClientMutationID: input.ClientMutationID,
-		Bug:              models.NewLoadedBug(b.Snapshot()),
+		Bug:              models.NewLoadedBug(b.Compile()),
 		CommentOperation: opAddComment,
 		StatusOperation:  opClose,
 	}, nil
@@ -169,7 +169,7 @@ func (r mutationResolver) AddCommentAndReopen(ctx context.Context, input models.
 
 	return &models.AddCommentAndReopenBugPayload{
 		ClientMutationID: input.ClientMutationID,
-		Bug:              models.NewLoadedBug(b.Snapshot()),
+		Bug:              models.NewLoadedBug(b.Compile()),
 		CommentOperation: opAddComment,
 		StatusOperation:  opReopen,
 	}, nil
@@ -209,7 +209,7 @@ func (r mutationResolver) EditComment(ctx context.Context, input models.EditComm
 
 	return &models.EditCommentPayload{
 		ClientMutationID: input.ClientMutationID,
-		Bug:              models.NewLoadedBug(b.Snapshot()),
+		Bug:              models.NewLoadedBug(b.Compile()),
 		Operation:        op,
 	}, nil
 }
@@ -248,7 +248,7 @@ func (r mutationResolver) ChangeLabels(ctx context.Context, input *models.Change
 
 	return &models.ChangeLabelPayload{
 		ClientMutationID: input.ClientMutationID,
-		Bug:              models.NewLoadedBug(b.Snapshot()),
+		Bug:              models.NewLoadedBug(b.Compile()),
 		Operation:        op,
 		Results:          resultsPtr,
 	}, nil
@@ -277,7 +277,7 @@ func (r mutationResolver) OpenBug(ctx context.Context, input models.OpenBugInput
 
 	return &models.OpenBugPayload{
 		ClientMutationID: input.ClientMutationID,
-		Bug:              models.NewLoadedBug(b.Snapshot()),
+		Bug:              models.NewLoadedBug(b.Compile()),
 		Operation:        op,
 	}, nil
 }
@@ -305,7 +305,7 @@ func (r mutationResolver) CloseBug(ctx context.Context, input models.CloseBugInp
 
 	return &models.CloseBugPayload{
 		ClientMutationID: input.ClientMutationID,
-		Bug:              models.NewLoadedBug(b.Snapshot()),
+		Bug:              models.NewLoadedBug(b.Compile()),
 		Operation:        op,
 	}, nil
 }
@@ -338,7 +338,7 @@ func (r mutationResolver) SetTitle(ctx context.Context, input models.SetTitleInp
 
 	return &models.SetTitlePayload{
 		ClientMutationID: input.ClientMutationID,
-		Bug:              models.NewLoadedBug(b.Snapshot()),
+		Bug:              models.NewLoadedBug(b.Compile()),
 		Operation:        op,
 	}, nil
 }

bridge/github/export.go 🔗

@@ -175,7 +175,7 @@ func (ge *githubExporter) ExportAll(ctx context.Context, repo *cache.RepoCache,
 				return
 
 			default:
-				snapshot := b.Snapshot()
+				snapshot := b.Compile()
 
 				// ignore issues created before since date
 				// TODO: compare the Lamport time instead of using the unix time
@@ -197,7 +197,7 @@ func (ge *githubExporter) ExportAll(ctx context.Context, repo *cache.RepoCache,
 
 // exportBug publish bugs and related events
 func (ge *githubExporter) exportBug(ctx context.Context, b *cache.BugCache, out chan<- core.ExportResult) {
-	snapshot := b.Snapshot()
+	snapshot := b.Compile()
 	var bugUpdated bool
 
 	var bugGithubID string

bridge/github/export_test.go 🔗

@@ -249,10 +249,10 @@ func TestGithubPushPull(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
 			// for each operation a SetMetadataOperation will be added
 			// so number of operations should double
-			require.Len(t, tt.bug.Snapshot().Operations, tt.numOrOp*2)
+			require.Len(t, tt.bug.Compile().Operations, tt.numOrOp*2)
 
 			// verify operation have correct metadata
-			for _, op := range tt.bug.Snapshot().Operations {
+			for _, op := range tt.bug.Compile().Operations {
 				// Check if the originals operations (*not* SetMetadata) are tagged properly
 				if _, ok := op.(dag.OperationDoesntChangeSnapshot); !ok {
 					_, haveIDMetadata := op.GetMetadata(metaKeyGithubId)
@@ -264,7 +264,7 @@ func TestGithubPushPull(t *testing.T) {
 			}
 
 			// get bug github ID
-			bugGithubID, ok := tt.bug.Snapshot().GetCreateMetadata(metaKeyGithubId)
+			bugGithubID, ok := tt.bug.Compile().GetCreateMetadata(metaKeyGithubId)
 			require.True(t, ok)
 
 			// retrieve bug from backendTwo
@@ -272,10 +272,10 @@ func TestGithubPushPull(t *testing.T) {
 			require.NoError(t, err)
 
 			// verify bug have same number of original operations
-			require.Len(t, importedBug.Snapshot().Operations, tt.numOrOp)
+			require.Len(t, importedBug.Compile().Operations, tt.numOrOp)
 
 			// verify bugs are tagged with origin=github
-			issueOrigin, ok := importedBug.Snapshot().GetCreateMetadata(core.MetaKeyOrigin)
+			issueOrigin, ok := importedBug.Compile().GetCreateMetadata(core.MetaKeyOrigin)
 			require.True(t, ok)
 			require.Equal(t, issueOrigin, target)
 

bridge/github/import_integration_test.go 🔗

@@ -54,14 +54,14 @@ func TestGithubImporterIntegration(t *testing.T) {
 
 	b1, err := backend.Bugs().ResolveBugCreateMetadata(metaKeyGithubUrl, "https://github.com/marcus/to-himself/issues/1")
 	require.NoError(t, err)
-	ops1 := b1.Snapshot().Operations
+	ops1 := b1.Compile().Operations
 	require.Equal(t, "marcus", ops1[0].Author().Name())
 	require.Equal(t, "title 1", ops1[0].(*bug.CreateOperation).Title)
 	require.Equal(t, "body text 1", ops1[0].(*bug.CreateOperation).Message)
 
 	b3, err := backend.Bugs().ResolveBugCreateMetadata(metaKeyGithubUrl, "https://github.com/marcus/to-himself/issues/3")
 	require.NoError(t, err)
-	ops3 := b3.Snapshot().Operations
+	ops3 := b3.Compile().Operations
 	require.Equal(t, "issue 3 comment 1", ops3[1].(*bug.AddCommentOperation).Message)
 	require.Equal(t, "issue 3 comment 2", ops3[2].(*bug.AddCommentOperation).Message)
 	require.Equal(t, []bug.Label{"bug"}, ops3[3].(*bug.LabelChangeOperation).Added)
@@ -69,7 +69,7 @@ func TestGithubImporterIntegration(t *testing.T) {
 
 	b4, err := backend.Bugs().ResolveBugCreateMetadata(metaKeyGithubUrl, "https://github.com/marcus/to-himself/issues/4")
 	require.NoError(t, err)
-	ops4 := b4.Snapshot().Operations
+	ops4 := b4.Compile().Operations
 	require.Equal(t, "edited", ops4[1].(*bug.EditCommentOperation).Message)
 
 }

bridge/github/import_test.go 🔗

@@ -178,8 +178,8 @@ func TestGithubImporter(t *testing.T) {
 			b, err := backend.Bugs().ResolveBugCreateMetadata(metaKeyGithubUrl, tt.url)
 			require.NoError(t, err)
 
-			ops := b.Snapshot().Operations
-			require.Len(t, tt.bug.Operations, len(b.Snapshot().Operations))
+			ops := b.Compile().Operations
+			require.Len(t, tt.bug.Operations, len(b.Compile().Operations))
 
 			for i, op := range tt.bug.Operations {
 				require.IsType(t, ops[i], op)

bridge/gitlab/export.go 🔗

@@ -128,7 +128,7 @@ func (ge *gitlabExporter) ExportAll(ctx context.Context, repo *cache.RepoCache,
 					return
 				}
 
-				snapshot := b.Snapshot()
+				snapshot := b.Compile()
 
 				// ignore issues created before since date
 				// TODO: compare the Lamport time instead of using the unix time
@@ -150,7 +150,7 @@ func (ge *gitlabExporter) ExportAll(ctx context.Context, repo *cache.RepoCache,
 
 // exportBug publish bugs and related events
 func (ge *gitlabExporter) exportBug(ctx context.Context, b *cache.BugCache, out chan<- core.ExportResult) {
-	snapshot := b.Snapshot()
+	snapshot := b.Compile()
 
 	var bugUpdated bool
 	var err error

bridge/gitlab/export_test.go 🔗

@@ -245,10 +245,10 @@ func TestGitlabPushPull(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
 			// for each operation a SetMetadataOperation will be added
 			// so number of operations should double
-			require.Len(t, tt.bug.Snapshot().Operations, tt.numOpExp)
+			require.Len(t, tt.bug.Compile().Operations, tt.numOpExp)
 
 			// verify operation have correct metadata
-			for _, op := range tt.bug.Snapshot().Operations {
+			for _, op := range tt.bug.Compile().Operations {
 				// Check if the originals operations (*not* SetMetadata) are tagged properly
 				if _, ok := op.(dag.OperationDoesntChangeSnapshot); !ok {
 					_, haveIDMetadata := op.GetMetadata(metaKeyGitlabId)
@@ -260,7 +260,7 @@ func TestGitlabPushPull(t *testing.T) {
 			}
 
 			// get bug gitlab ID
-			bugGitlabID, ok := tt.bug.Snapshot().GetCreateMetadata(metaKeyGitlabId)
+			bugGitlabID, ok := tt.bug.Compile().GetCreateMetadata(metaKeyGitlabId)
 			require.True(t, ok)
 
 			// retrieve bug from backendTwo
@@ -268,10 +268,10 @@ func TestGitlabPushPull(t *testing.T) {
 			require.NoError(t, err)
 
 			// verify bug have same number of original operations
-			require.Len(t, importedBug.Snapshot().Operations, tt.numOpImp)
+			require.Len(t, importedBug.Compile().Operations, tt.numOpImp)
 
 			// verify bugs are tagged with origin=gitlab
-			issueOrigin, ok := importedBug.Snapshot().GetCreateMetadata(core.MetaKeyOrigin)
+			issueOrigin, ok := importedBug.Compile().GetCreateMetadata(core.MetaKeyOrigin)
 			require.True(t, ok)
 			require.Equal(t, issueOrigin, target)
 

bridge/gitlab/import.go 🔗

@@ -198,7 +198,7 @@ func (gi *gitlabImporter) ensureIssueEvent(repo *cache.RepoCache, b *cache.BugCa
 		gi.out <- core.NewImportStatusChange(b.Id(), op.Id())
 
 	case EventDescriptionChanged:
-		firstComment := b.Snapshot().Comments[0]
+		firstComment := b.Compile().Comments[0]
 		// since gitlab doesn't provide the issue history
 		// we should check for "changed the description" notes and compare issue texts
 		// TODO: Check only one time and ignore next 'description change' within one issue
@@ -247,7 +247,7 @@ func (gi *gitlabImporter) ensureIssueEvent(repo *cache.RepoCache, b *cache.BugCa
 		// if comment was already exported
 
 		// search for last comment update
-		comment, err := b.Snapshot().SearchCommentByOpId(id)
+		comment, err := b.Compile().SearchCommentByOpId(id)
 		if err != nil {
 			return err
 		}

bridge/gitlab/import_test.go 🔗

@@ -133,7 +133,7 @@ func TestGitlabImport(t *testing.T) {
 			b, err := backend.Bugs().ResolveBugCreateMetadata(metaKeyGitlabUrl, tt.url)
 			require.NoError(t, err)
 
-			ops := b.Snapshot().Operations
+			ops := b.Compile().Operations
 			require.Len(t, tt.bug.Operations, len(ops))
 
 			for i, op := range tt.bug.Operations {

bridge/jira/export.go 🔗

@@ -161,7 +161,7 @@ func (je *jiraExporter) ExportAll(ctx context.Context, repo *cache.RepoCache, si
 				return
 
 			default:
-				snapshot := b.Snapshot()
+				snapshot := b.Compile()
 
 				// ignore issues whose last modification date is before the query date
 				// TODO: compare the Lamport time instead of using the unix time
@@ -189,7 +189,7 @@ func (je *jiraExporter) ExportAll(ctx context.Context, repo *cache.RepoCache, si
 
 // exportBug publish bugs and related events
 func (je *jiraExporter) exportBug(ctx context.Context, b *cache.BugCache, out chan<- core.ExportResult) error {
-	snapshot := b.Snapshot()
+	snapshot := b.Compile()
 
 	var bugJiraID string
 

bridge/jira/import.go 🔗

@@ -124,7 +124,7 @@ func (ji *jiraImporter) ImportAll(ctx context.Context, repo *cache.RepoCache, si
 				out <- core.NewImportError(commentIter.Err, "")
 			}
 
-			snapshot := b.Snapshot()
+			snapshot := b.Compile()
 			opIdx := 0
 
 			var changelogIter *ChangeLogIterator
@@ -466,7 +466,7 @@ func (ji *jiraImporter) ensureChange(repo *cache.RepoCache, b *cache.BugCache, e
 			// title but it's actually the body
 			opr, isRightType := potentialOp.(*bug.EditCommentOperation)
 			if isRightType &&
-				opr.Target == b.Snapshot().Operations[0].Id() &&
+				opr.Target == b.Compile().Operations[0].Id() &&
 				opr.Message == item.ToString {
 				_, err := b.SetMetadata(opr.Id(), map[string]string{
 					metaKeyJiraDerivedId: entry.ID,

cache/bug_cache.go 🔗

@@ -5,7 +5,6 @@ import (
 	"time"
 
 	"github.com/MichaelMure/git-bug/entities/bug"
-	"github.com/MichaelMure/git-bug/entities/identity"
 	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/entity/dag"
 	"github.com/MichaelMure/git-bug/repository"
@@ -13,6 +12,8 @@ import (
 
 var ErrNoMatchingOp = fmt.Errorf("no matching operation found")
 
+var _ bug.Interface = &BugCache{}
+
 // BugCache is a wrapper around a Bug. It provides multiple functions:
 //
 // 1. Provide a higher level API to use than the raw API from Bug.
@@ -46,7 +47,7 @@ func (c *BugCache) AddCommentWithFiles(message string, files []repository.Hash)
 	return c.AddCommentRaw(author, time.Now().Unix(), message, files, nil)
 }
 
-func (c *BugCache) AddCommentRaw(author identity.Interface, unixTime int64, message string, files []repository.Hash, metadata map[string]string) (entity.CombinedId, *bug.AddCommentOperation, error) {
+func (c *BugCache) AddCommentRaw(author entity.Interface, unixTime int64, message string, files []repository.Hash, metadata map[string]string) (entity.CombinedId, *bug.AddCommentOperation, error) {
 	c.mu.Lock()
 	commentId, op, err := bug.AddComment(c.entity, author, unixTime, message, files, metadata)
 	c.mu.Unlock()
@@ -65,7 +66,7 @@ func (c *BugCache) ChangeLabels(added []string, removed []string) ([]bug.LabelCh
 	return c.ChangeLabelsRaw(author, time.Now().Unix(), added, removed, nil)
 }
 
-func (c *BugCache) ChangeLabelsRaw(author identity.Interface, unixTime int64, added []string, removed []string, metadata map[string]string) ([]bug.LabelChangeResult, *bug.LabelChangeOperation, error) {
+func (c *BugCache) ChangeLabelsRaw(author entity.Interface, unixTime int64, added []string, removed []string, metadata map[string]string) ([]bug.LabelChangeResult, *bug.LabelChangeOperation, error) {
 	c.mu.Lock()
 	changes, op, err := bug.ChangeLabels(c.entity, author, unixTime, added, removed, metadata)
 	c.mu.Unlock()
@@ -84,7 +85,7 @@ func (c *BugCache) ForceChangeLabels(added []string, removed []string) (*bug.Lab
 	return c.ForceChangeLabelsRaw(author, time.Now().Unix(), added, removed, nil)
 }
 
-func (c *BugCache) ForceChangeLabelsRaw(author identity.Interface, unixTime int64, added []string, removed []string, metadata map[string]string) (*bug.LabelChangeOperation, error) {
+func (c *BugCache) ForceChangeLabelsRaw(author entity.Interface, unixTime int64, added []string, removed []string, metadata map[string]string) (*bug.LabelChangeOperation, error) {
 	c.mu.Lock()
 	op, err := bug.ForceChangeLabels(c.entity, author, unixTime, added, removed, metadata)
 	c.mu.Unlock()
@@ -103,7 +104,7 @@ func (c *BugCache) Open() (*bug.SetStatusOperation, error) {
 	return c.OpenRaw(author, time.Now().Unix(), nil)
 }
 
-func (c *BugCache) OpenRaw(author identity.Interface, unixTime int64, metadata map[string]string) (*bug.SetStatusOperation, error) {
+func (c *BugCache) OpenRaw(author entity.Interface, unixTime int64, metadata map[string]string) (*bug.SetStatusOperation, error) {
 	c.mu.Lock()
 	op, err := bug.Open(c.entity, author, unixTime, metadata)
 	c.mu.Unlock()
@@ -122,7 +123,7 @@ func (c *BugCache) Close() (*bug.SetStatusOperation, error) {
 	return c.CloseRaw(author, time.Now().Unix(), nil)
 }
 
-func (c *BugCache) CloseRaw(author identity.Interface, unixTime int64, metadata map[string]string) (*bug.SetStatusOperation, error) {
+func (c *BugCache) CloseRaw(author entity.Interface, unixTime int64, metadata map[string]string) (*bug.SetStatusOperation, error) {
 	c.mu.Lock()
 	op, err := bug.Close(c.entity, author, unixTime, metadata)
 	c.mu.Unlock()
@@ -141,7 +142,7 @@ func (c *BugCache) SetTitle(title string) (*bug.SetTitleOperation, error) {
 	return c.SetTitleRaw(author, time.Now().Unix(), title, nil)
 }
 
-func (c *BugCache) SetTitleRaw(author identity.Interface, unixTime int64, title string, metadata map[string]string) (*bug.SetTitleOperation, error) {
+func (c *BugCache) SetTitleRaw(author entity.Interface, unixTime int64, title string, metadata map[string]string) (*bug.SetTitleOperation, error) {
 	c.mu.Lock()
 	op, err := bug.SetTitle(c.entity, author, unixTime, title, metadata)
 	c.mu.Unlock()
@@ -162,7 +163,7 @@ func (c *BugCache) EditCreateComment(body string) (entity.CombinedId, *bug.EditC
 }
 
 // EditCreateCommentRaw is a convenience function to edit the body of a bug (the first comment)
-func (c *BugCache) EditCreateCommentRaw(author identity.Interface, unixTime int64, body string, metadata map[string]string) (entity.CombinedId, *bug.EditCommentOperation, error) {
+func (c *BugCache) EditCreateCommentRaw(author entity.Interface, unixTime int64, body string, metadata map[string]string) (entity.CombinedId, *bug.EditCommentOperation, error) {
 	c.mu.Lock()
 	commentId, op, err := bug.EditCreateComment(c.entity, author, unixTime, body, nil, metadata)
 	c.mu.Unlock()
@@ -181,8 +182,8 @@ func (c *BugCache) EditComment(target entity.CombinedId, message string) (*bug.E
 	return c.EditCommentRaw(author, time.Now().Unix(), target, message, nil)
 }
 
-func (c *BugCache) EditCommentRaw(author identity.Interface, unixTime int64, target entity.CombinedId, message string, metadata map[string]string) (*bug.EditCommentOperation, error) {
-	comment, err := c.Snapshot().SearchComment(target)
+func (c *BugCache) EditCommentRaw(author entity.Interface, unixTime int64, target entity.CombinedId, message string, metadata map[string]string) (*bug.EditCommentOperation, error) {
+	comment, err := c.Compile().SearchComment(target)
 	if err != nil {
 		return nil, err
 	}
@@ -208,7 +209,7 @@ func (c *BugCache) SetMetadata(target entity.Id, newMetadata map[string]string)
 	return c.SetMetadataRaw(author, time.Now().Unix(), target, newMetadata)
 }
 
-func (c *BugCache) SetMetadataRaw(author identity.Interface, unixTime int64, target entity.Id, newMetadata map[string]string) (*dag.SetMetadataOperation[*bug.Snapshot], error) {
+func (c *BugCache) SetMetadataRaw(author entity.Interface, unixTime int64, target entity.Id, newMetadata map[string]string) (*dag.SetMetadataOperation[*bug.Snapshot], error) {
 	c.mu.Lock()
 	op, err := bug.SetMetadata(c.entity, author, unixTime, target, newMetadata)
 	c.mu.Unlock()

cache/bug_excerpt.go 🔗

@@ -39,7 +39,7 @@ type BugExcerpt struct {
 }
 
 func NewBugExcerpt(b *BugCache) *BugExcerpt {
-	snap := b.Snapshot()
+	snap := b.Compile()
 	participantsIds := make([]entity.Id, 0, len(snap.Participants))
 	for _, participant := range snap.Participants {
 		participantsIds = append(participantsIds, participant.Id())

cache/bug_subcache.go 🔗

@@ -6,7 +6,6 @@ import (
 	"time"
 
 	"github.com/MichaelMure/git-bug/entities/bug"
-	"github.com/MichaelMure/git-bug/entities/identity"
 	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/query"
 	"github.com/MichaelMure/git-bug/repository"
@@ -25,7 +24,7 @@ func NewRepoCacheBug(repo repository.ClockedRepo,
 	}
 
 	makeIndexData := func(b *BugCache) []string {
-		snap := b.Snapshot()
+		snap := b.Compile()
 		var res []string
 		for _, comment := range snap.Comments {
 			res = append(res, comment.Message)
@@ -90,7 +89,7 @@ func (c *RepoCacheBug) ResolveComment(prefix string) (*BugCache, entity.Combined
 			return nil, entity.UnsetCombinedId, err
 		}
 
-		for _, comment := range b.Snapshot().Comments {
+		for _, comment := range b.Compile().Comments {
 			if comment.CombinedId().HasPrefix(prefix) {
 				matchingBugIds = append(matchingBugIds, bugId)
 				matchingBug = b
@@ -235,7 +234,7 @@ func (c *RepoCacheBug) NewWithFiles(title string, message string, files []reposi
 // NewRaw create a new bug with attached files for the message, as
 // well as metadata for the Create operation.
 // The new bug is written in the repository (commit)
-func (c *RepoCacheBug) NewRaw(author identity.Interface, unixTime int64, title string, message string, files []repository.Hash, metadata map[string]string) (*BugCache, *bug.CreateOperation, error) {
+func (c *RepoCacheBug) NewRaw(author entity.Interface, unixTime int64, title string, message string, files []repository.Hash, metadata map[string]string) (*BugCache, *bug.CreateOperation, error) {
 	b, op, err := bug.Create(author, unixTime, title, message, files, metadata)
 	if err != nil {
 		return nil, nil, err

cache/cached.go 🔗

@@ -9,6 +9,7 @@ import (
 	"github.com/MichaelMure/git-bug/util/lamport"
 )
 
+var _ dag.Interface[dag.Snapshot, dag.Operation] = &CachedEntityBase[dag.Snapshot, dag.Operation]{}
 var _ CacheEntity = &CachedEntityBase[dag.Snapshot, dag.Operation]{}
 
 // CachedEntityBase provide the base function of an entity managed by the cache.
@@ -25,7 +26,7 @@ func (e *CachedEntityBase[SnapT, OpT]) Id() entity.Id {
 	return e.entity.Id()
 }
 
-func (e *CachedEntityBase[SnapT, OpT]) Snapshot() SnapT {
+func (e *CachedEntityBase[SnapT, OpT]) Compile() SnapT {
 	e.mu.RLock()
 	defer e.mu.RUnlock()
 	return e.entity.Compile()
@@ -66,6 +67,18 @@ func (e *CachedEntityBase[SnapT, OpT]) Validate() error {
 	return e.entity.Validate()
 }
 
+func (e *CachedEntityBase[SnapT, OpT]) Append(op OpT) {
+	e.mu.Lock()
+	defer e.mu.Unlock()
+	e.entity.Append(op)
+}
+
+func (e *CachedEntityBase[SnapT, OpT]) Operations() []OpT {
+	e.mu.RLock()
+	defer e.mu.RUnlock()
+	return e.entity.Operations()
+}
+
 func (e *CachedEntityBase[SnapT, OpT]) Commit() error {
 	e.mu.Lock()
 	err := e.entity.Commit(e.repo)

cache/identity_cache.go 🔗

@@ -8,7 +8,7 @@ import (
 	"github.com/MichaelMure/git-bug/repository"
 )
 
-var _ identity.Interface = &IdentityCache{}
+var _ entity.Interface = &IdentityCache{}
 var _ CacheEntity = &IdentityCache{}
 
 // IdentityCache is a wrapper around an Identity for caching.

cache/identity_subcache.go 🔗

@@ -41,7 +41,7 @@ func NewRepoCacheIdentity(repo repository.ClockedRepo,
 		},
 		Remove:    identity.Remove,
 		RemoveAll: identity.RemoveAll,
-		MergeAll: func(repo repository.ClockedRepo, resolvers entity.Resolvers, remote string, mergeAuthor identity.Interface) <-chan entity.MergeResult {
+		MergeAll: func(repo repository.ClockedRepo, resolvers entity.Resolvers, remote string, mergeAuthor entity.Interface) <-chan entity.MergeResult {
 			return identity.MergeAll(repo, remote)
 		},
 	}

cache/repo_cache.go 🔗

@@ -7,6 +7,7 @@ import (
 	"strconv"
 	"sync"
 
+	"github.com/MichaelMure/git-bug/entities/bug"
 	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/repository"
 	"github.com/MichaelMure/git-bug/util/multierr"
@@ -97,6 +98,8 @@ func NewNamedRepoCache(r repository.ClockedRepo, name string) (*RepoCache, chan
 	c.resolvers = entity.Resolvers{
 		&IdentityCache{}:   entity.ResolverFunc[*IdentityCache](c.identities.Resolve),
 		&IdentityExcerpt{}: entity.ResolverFunc[*IdentityExcerpt](c.identities.ResolveExcerpt),
+
+		bug.Interface(nil): entity.ResolverFunc[*BugCache](c.bugs.Resolve),
 		&BugCache{}:        entity.ResolverFunc[*BugCache](c.bugs.Resolve),
 		&BugExcerpt{}:      entity.ResolverFunc[*BugExcerpt](c.bugs.ResolveExcerpt),
 	}

cache/subcache.go 🔗

@@ -9,7 +9,6 @@ import (
 
 	"github.com/pkg/errors"
 
-	"github.com/MichaelMure/git-bug/entities/identity"
 	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/repository"
 )
@@ -35,7 +34,7 @@ type Actions[EntityT entity.Interface] struct {
 	ReadAllWithResolver func(repo repository.ClockedRepo, resolvers entity.Resolvers) <-chan entity.StreamedEntity[EntityT]
 	Remove              func(repo repository.ClockedRepo, id entity.Id) error
 	RemoveAll           func(repo repository.ClockedRepo) error
-	MergeAll            func(repo repository.ClockedRepo, resolvers entity.Resolvers, remote string, mergeAuthor identity.Interface) <-chan entity.MergeResult
+	MergeAll            func(repo repository.ClockedRepo, resolvers entity.Resolvers, remote string, mergeAuthor entity.Interface) <-chan entity.MergeResult
 }
 
 var _ cacheMgmt = &SubCache[entity.Interface, Excerpt, CacheEntity]{}
@@ -597,7 +596,6 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) entityUpdated(id entity.Id) error
 		return errors.New("entity missing from cache")
 	}
 	sc.lru.Get(id)
-	// sc.excerpts[id] = bug2.NewBugExcerpt(b.bug, b.Snapshot())
 	sc.excerpts[id] = sc.makeExcerpt(e)
 	sc.mu.Unlock()
 

commands/bug/bug_comment.go 🔗

@@ -31,7 +31,7 @@ func runBugComment(env *execenv.Env, args []string) error {
 		return err
 	}
 
-	snap := b.Snapshot()
+	snap := b.Compile()
 
 	for i, comment := range snap.Comments {
 		if i != 0 {

commands/bug/bug_label.go 🔗

@@ -29,7 +29,7 @@ func runBugLabel(env *execenv.Env, args []string) error {
 		return err
 	}
 
-	snap := b.Snapshot()
+	snap := b.Compile()
 
 	for _, l := range snap.Labels {
 		env.Out.Println(l)

commands/bug/bug_select.go 🔗

@@ -59,7 +59,7 @@ func runBugSelect(env *execenv.Env, args []string) error {
 		return err
 	}
 
-	env.Out.Printf("selected bug %s: %s\n", b.Id().Human(), b.Snapshot().Title)
+	env.Out.Printf("selected bug %s: %s\n", b.Id().Human(), b.Compile().Title)
 
 	return nil
 }

commands/bug/bug_show.go 🔗

@@ -52,7 +52,7 @@ func runBugShow(env *execenv.Env, opts bugShowOptions, args []string) error {
 		return err
 	}
 
-	snap := b.Snapshot()
+	snap := b.Compile()
 
 	if len(snap.Comments) == 0 {
 		return errors.New("invalid bug: no comment")

commands/bug/bug_status.go 🔗

@@ -29,7 +29,7 @@ func runBugStatus(env *execenv.Env, args []string) error {
 		return err
 	}
 
-	snap := b.Snapshot()
+	snap := b.Compile()
 
 	env.Out.Println(snap.Status)
 

commands/bug/bug_title.go 🔗

@@ -28,7 +28,7 @@ func runBugTitle(env *execenv.Env, args []string) error {
 		return err
 	}
 
-	snap := b.Snapshot()
+	snap := b.Compile()
 
 	env.Out.Println(snap.Title)
 

commands/bug/bug_title_edit.go 🔗

@@ -43,7 +43,7 @@ func runBugTitleEdit(env *execenv.Env, opts bugTitleEditOptions, args []string)
 		return err
 	}
 
-	snap := b.Snapshot()
+	snap := b.Compile()
 
 	if opts.title == "" {
 		if opts.nonInteractive {

commands/bug/completion.go 🔗

@@ -59,7 +59,7 @@ func BugAndLabelsCompletion(env *execenv.Env, addOrRemove bool) completion.Valid
 			return completion.HandleError(err)
 		}
 
-		snap := b.Snapshot()
+		snap := b.Compile()
 
 		seenLabels := map[bug.Label]bool{}
 		for _, label := range cleanArgs {

commands/cmdjson/json_common.go 🔗

@@ -4,7 +4,7 @@ import (
 	"time"
 
 	"github.com/MichaelMure/git-bug/cache"
-	"github.com/MichaelMure/git-bug/entities/identity"
+	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/util/lamport"
 )
 
@@ -15,7 +15,7 @@ type Identity struct {
 	Login   string `json:"login"`
 }
 
-func NewIdentity(i identity.Interface) Identity {
+func NewIdentity(i entity.Interface) Identity {
 	return Identity{
 		Id:      i.Id().String(),
 		HumanId: i.Id().Human(),

entities/bug/bug.go 🔗

@@ -7,12 +7,13 @@ import (
 	"github.com/MichaelMure/git-bug/entities/common"
 	"github.com/MichaelMure/git-bug/entities/identity"
 	"github.com/MichaelMure/git-bug/entity"
+	bootstrap "github.com/MichaelMure/git-bug/entity/boostrap"
 	"github.com/MichaelMure/git-bug/entity/dag"
 	"github.com/MichaelMure/git-bug/repository"
 )
 
 var _ Interface = &Bug{}
-var _ entity.Interface = &Bug{}
+var _ entity.Interface[*Snapshot, Operation] = &Bug{}
 
 // 1: original format
 // 2: no more legacy identities
@@ -33,7 +34,7 @@ var def = dag.Definition{
 var ClockLoader = dag.ClockLoader(def)
 
 type Interface interface {
-	dag.Interface[*Snapshot, Operation]
+	entity.Interface[*Snapshot, Operation]
 }
 
 // Bug holds the data of a bug thread, organized in a way close to
@@ -69,12 +70,12 @@ func ReadWithResolver(repo repository.ClockedRepo, resolvers entity.Resolvers, i
 }
 
 // ReadAll read and parse all local bugs
-func ReadAll(repo repository.ClockedRepo) <-chan entity.StreamedEntity[*Bug] {
+func ReadAll(repo repository.ClockedRepo) <-chan bootstrap.StreamedEntity[*Bug] {
 	return dag.ReadAll(def, wrapper, repo, simpleResolvers(repo))
 }
 
 // ReadAllWithResolver read and parse all local bugs
-func ReadAllWithResolver(repo repository.ClockedRepo, resolvers entity.Resolvers) <-chan entity.StreamedEntity[*Bug] {
+func ReadAllWithResolver(repo repository.ClockedRepo, resolvers entity.Resolvers) <-chan bootstrap.StreamedEntity[*Bug] {
 	return dag.ReadAll(def, wrapper, repo, resolvers)
 }
 

entities/bug/op_add_comment.go 🔗

@@ -12,7 +12,7 @@ import (
 )
 
 var _ Operation = &AddCommentOperation{}
-var _ dag.OperationWithFiles = &AddCommentOperation{}
+var _ entity.OperationWithFiles = &AddCommentOperation{}
 
 // AddCommentOperation will add a new comment in the bug
 type AddCommentOperation struct {
@@ -63,6 +63,12 @@ func (op *AddCommentOperation) Validate() error {
 		return fmt.Errorf("message is not fully printable")
 	}
 
+	for _, file := range op.Files {
+		if !file.IsValid() {
+			return fmt.Errorf("invalid file hash")
+		}
+	}
+
 	return nil
 }
 

entities/bug/op_create.go 🔗

@@ -12,7 +12,7 @@ import (
 )
 
 var _ Operation = &CreateOperation{}
-var _ dag.OperationWithFiles = &CreateOperation{}
+var _ entity.OperationWithFiles = &CreateOperation{}
 
 // CreateOperation define the initial creation of a bug
 type CreateOperation struct {
@@ -80,6 +80,12 @@ func (op *CreateOperation) Validate() error {
 		return fmt.Errorf("message is not fully printable")
 	}
 
+	for _, file := range op.Files {
+		if !file.IsValid() {
+			return fmt.Errorf("invalid file hash")
+		}
+	}
+
 	return nil
 }
 

entities/bug/op_edit_comment.go 🔗

@@ -15,7 +15,7 @@ import (
 )
 
 var _ Operation = &EditCommentOperation{}
-var _ dag.OperationWithFiles = &EditCommentOperation{}
+var _ entity.OperationWithFiles = &EditCommentOperation{}
 
 // EditCommentOperation will change a comment in the bug
 type EditCommentOperation struct {
@@ -98,6 +98,12 @@ func (op *EditCommentOperation) Validate() error {
 		return fmt.Errorf("message is not fully printable")
 	}
 
+	for _, file := range op.Files {
+		if !file.IsValid() {
+			return fmt.Errorf("invalid file hash")
+		}
+	}
+
 	return nil
 }
 

entities/bug/operation.go 🔗

@@ -9,7 +9,7 @@ import (
 )
 
 const (
-	_ dag.OperationType = iota
+	_ entity.OperationType = iota
 	CreateOp
 	SetTitleOp
 	AddCommentOp
@@ -29,7 +29,7 @@ var _ Operation = &dag.SetMetadataOperation[*Snapshot]{}
 
 func operationUnmarshaler(raw json.RawMessage, resolvers entity.Resolvers) (dag.Operation, error) {
 	var t struct {
-		OperationType dag.OperationType `json:"type"`
+		OperationType entity.OperationType `json:"type"`
 	}
 
 	if err := json.Unmarshal(raw, &t); err != nil {

entities/bug/snapshot.go 🔗

@@ -7,10 +7,9 @@ import (
 	"github.com/MichaelMure/git-bug/entities/common"
 	"github.com/MichaelMure/git-bug/entities/identity"
 	"github.com/MichaelMure/git-bug/entity"
-	"github.com/MichaelMure/git-bug/entity/dag"
 )
 
-var _ dag.Snapshot = &Snapshot{}
+var _ entity.Snapshot = &Snapshot{}
 
 // Snapshot is a compiled form of the Bug data structure used for storage and merge
 type Snapshot struct {
@@ -27,7 +26,7 @@ type Snapshot struct {
 
 	Timeline []TimelineItem
 
-	Operations []dag.Operation
+	Operations []entity.Operation
 }
 
 // Id returns the Bug identifier
@@ -39,11 +38,11 @@ func (snap *Snapshot) Id() entity.Id {
 	return snap.id
 }
 
-func (snap *Snapshot) AllOperations() []dag.Operation {
+func (snap *Snapshot) AllOperations() []entity.Operation {
 	return snap.Operations
 }
 
-func (snap *Snapshot) AppendOperation(op dag.Operation) {
+func (snap *Snapshot) AppendOperation(op entity.Operation) {
 	snap.Operations = append(snap.Operations, op)
 }
 

entities/identity/identity.go 🔗

@@ -8,7 +8,7 @@ import (
 
 	"github.com/pkg/errors"
 
-	"github.com/MichaelMure/git-bug/entity"
+	bootstrap "github.com/MichaelMure/git-bug/entity/boostrap"
 	"github.com/MichaelMure/git-bug/repository"
 	"github.com/MichaelMure/git-bug/util/lamport"
 	"github.com/MichaelMure/git-bug/util/timestamp"
@@ -29,7 +29,7 @@ var ErrNoIdentitySet = errors.New("No identity is set.\n" +
 var ErrMultipleIdentitiesSet = errors.New("multiple user identities set")
 
 var _ Interface = &Identity{}
-var _ entity.Interface = &Identity{}
+var _ bootstrap.Entity = &Identity{}
 
 type Identity struct {
 	// all the successive version of the identity
@@ -87,7 +87,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 entity.Id) (*Identity, error) {
+func ReadLocal(repo repository.Repo, id bootstrap.Id) (*Identity, error) {
 	ref := fmt.Sprintf("%s%s", identityRefPattern, id)
 	return read(repo, ref)
 }
@@ -100,7 +100,7 @@ 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) {
-	id := entity.RefToId(ref)
+	id := bootstrap.RefToId(ref)
 
 	if err := id.Validate(); err != nil {
 		return nil, errors.Wrap(err, "invalid ref")
@@ -108,7 +108,7 @@ func read(repo repository.Repo, ref string) (*Identity, error) {
 
 	hashes, err := repo.ListCommits(ref)
 	if err != nil {
-		return nil, entity.NewErrNotFound(Typename)
+		return nil, bootstrap.NewErrNotFound(Typename)
 	}
 	if len(hashes) == 0 {
 		return nil, fmt.Errorf("empty identity")
@@ -155,36 +155,36 @@ func read(repo repository.Repo, ref string) (*Identity, error) {
 }
 
 // ListLocalIds list all the available local identity ids
-func ListLocalIds(repo repository.Repo) ([]entity.Id, error) {
+func ListLocalIds(repo repository.Repo) ([]bootstrap.Id, error) {
 	refs, err := repo.ListRefs(identityRefPattern)
 	if err != nil {
 		return nil, err
 	}
 
-	return entity.RefsToIds(refs), nil
+	return bootstrap.RefsToIds(refs), nil
 }
 
 // ReadAllLocal read and parse all local Identity
-func ReadAllLocal(repo repository.ClockedRepo) <-chan entity.StreamedEntity[*Identity] {
+func ReadAllLocal(repo repository.ClockedRepo) <-chan bootstrap.StreamedEntity[*Identity] {
 	return readAll(repo, identityRefPattern)
 }
 
 // ReadAllRemote read and parse all remote Identity for a given remote
-func ReadAllRemote(repo repository.ClockedRepo, remote string) <-chan entity.StreamedEntity[*Identity] {
+func ReadAllRemote(repo repository.ClockedRepo, remote string) <-chan bootstrap.StreamedEntity[*Identity] {
 	refPrefix := fmt.Sprintf(identityRemoteRefPattern, remote)
 	return readAll(repo, refPrefix)
 }
 
 // readAll read and parse all available bug with a given ref prefix
-func readAll(repo repository.ClockedRepo, refPrefix string) <-chan entity.StreamedEntity[*Identity] {
-	out := make(chan entity.StreamedEntity[*Identity])
+func readAll(repo repository.ClockedRepo, refPrefix string) <-chan bootstrap.StreamedEntity[*Identity] {
+	out := make(chan bootstrap.StreamedEntity[*Identity])
 
 	go func() {
 		defer close(out)
 
 		refs, err := repo.ListRefs(refPrefix)
 		if err != nil {
-			out <- entity.StreamedEntity[*Identity]{Err: err}
+			out <- bootstrap.StreamedEntity[*Identity]{Err: err}
 			return
 		}
 
@@ -195,11 +195,11 @@ func readAll(repo repository.ClockedRepo, refPrefix string) <-chan entity.Stream
 			i, err := read(repo, ref)
 
 			if err != nil {
-				out <- entity.StreamedEntity[*Identity]{Err: err}
+				out <- bootstrap.StreamedEntity[*Identity]{Err: err}
 				return
 			}
 
-			out <- entity.StreamedEntity[*Identity]{
+			out <- bootstrap.StreamedEntity[*Identity]{
 				Entity:        i,
 				CurrentEntity: current,
 				TotalEntities: total,
@@ -425,7 +425,7 @@ func (i *Identity) lastVersion() *version {
 }
 
 // Id return the Identity identifier
-func (i *Identity) Id() entity.Id {
+func (i *Identity) Id() bootstrap.Id {
 	// id is the id of the first version
 	return i.versions[0].Id()
 }
@@ -534,7 +534,7 @@ func (i *Identity) SetMetadata(key string, value string) {
 		i.versions = append(i.versions, i.lastVersion().Clone())
 	}
 	// if Id() has been called, we can't change the first version anymore, so we create a new version
-	if len(i.versions) == 1 && i.versions[0].id != entity.UnsetId && i.versions[0].id != "" {
+	if len(i.versions) == 1 && i.versions[0].id != bootstrap.UnsetId && i.versions[0].id != "" {
 		i.versions = append(i.versions, i.lastVersion().Clone())
 	}
 

entities/identity/identity_actions.go 🔗

@@ -6,7 +6,7 @@ import (
 
 	"github.com/pkg/errors"
 
-	"github.com/MichaelMure/git-bug/entity"
+	bootstrap "github.com/MichaelMure/git-bug/entity/boostrap"
 	"github.com/MichaelMure/git-bug/repository"
 )
 
@@ -33,7 +33,7 @@ func Pull(repo repository.ClockedRepo, remote string) error {
 		if merge.Err != nil {
 			return merge.Err
 		}
-		if merge.Status == entity.MergeStatusInvalid {
+		if merge.Status == bootstrap.MergeStatusInvalid {
 			return errors.Errorf("merge failure: %s", merge.Reason)
 		}
 	}
@@ -42,8 +42,8 @@ func Pull(repo repository.ClockedRepo, remote string) error {
 }
 
 // MergeAll will merge all the available remote identity
-func MergeAll(repo repository.ClockedRepo, remote string) <-chan entity.MergeResult {
-	out := make(chan entity.MergeResult)
+func MergeAll(repo repository.ClockedRepo, remote string) <-chan bootstrap.MergeResult {
+	out := make(chan bootstrap.MergeResult)
 
 	go func() {
 		defer close(out)
@@ -52,29 +52,29 @@ func MergeAll(repo repository.ClockedRepo, remote string) <-chan entity.MergeRes
 		remoteRefs, err := repo.ListRefs(remoteRefSpec)
 
 		if err != nil {
-			out <- entity.MergeResult{Err: err}
+			out <- bootstrap.MergeResult{Err: err}
 			return
 		}
 
 		for _, remoteRef := range remoteRefs {
 			refSplit := strings.Split(remoteRef, "/")
-			id := entity.Id(refSplit[len(refSplit)-1])
+			id := bootstrap.Id(refSplit[len(refSplit)-1])
 
 			if err := id.Validate(); err != nil {
-				out <- entity.NewMergeInvalidStatus(id, errors.Wrap(err, "invalid ref").Error())
+				out <- bootstrap.NewMergeInvalidStatus(id, errors.Wrap(err, "invalid ref").Error())
 				continue
 			}
 
 			remoteIdentity, err := read(repo, remoteRef)
 
 			if err != nil {
-				out <- entity.NewMergeInvalidStatus(id, errors.Wrap(err, "remote identity is not readable").Error())
+				out <- bootstrap.NewMergeInvalidStatus(id, errors.Wrap(err, "remote identity is not readable").Error())
 				continue
 			}
 
 			// Check for error in remote data
 			if err := remoteIdentity.Validate(); err != nil {
-				out <- entity.NewMergeInvalidStatus(id, errors.Wrap(err, "remote identity is invalid").Error())
+				out <- bootstrap.NewMergeInvalidStatus(id, errors.Wrap(err, "remote identity is invalid").Error())
 				continue
 			}
 
@@ -82,7 +82,7 @@ func MergeAll(repo repository.ClockedRepo, remote string) <-chan entity.MergeRes
 			localExist, err := repo.RefExist(localRef)
 
 			if err != nil {
-				out <- entity.NewMergeError(err, id)
+				out <- bootstrap.NewMergeError(err, id)
 				continue
 			}
 
@@ -91,32 +91,32 @@ func MergeAll(repo repository.ClockedRepo, remote string) <-chan entity.MergeRes
 				err := repo.CopyRef(remoteRef, localRef)
 
 				if err != nil {
-					out <- entity.NewMergeError(err, id)
+					out <- bootstrap.NewMergeError(err, id)
 					return
 				}
 
-				out <- entity.NewMergeNewStatus(id, remoteIdentity)
+				out <- bootstrap.NewMergeNewStatus(id, remoteIdentity)
 				continue
 			}
 
 			localIdentity, err := read(repo, localRef)
 
 			if err != nil {
-				out <- entity.NewMergeError(errors.Wrap(err, "local identity is not readable"), id)
+				out <- bootstrap.NewMergeError(errors.Wrap(err, "local identity is not readable"), id)
 				return
 			}
 
 			updated, err := localIdentity.Merge(repo, remoteIdentity)
 
 			if err != nil {
-				out <- entity.NewMergeInvalidStatus(id, errors.Wrap(err, "merge failed").Error())
+				out <- bootstrap.NewMergeInvalidStatus(id, errors.Wrap(err, "merge failed").Error())
 				return
 			}
 
 			if updated {
-				out <- entity.NewMergeUpdatedStatus(id, localIdentity)
+				out <- bootstrap.NewMergeUpdatedStatus(id, localIdentity)
 			} else {
-				out <- entity.NewMergeNothingStatus(id)
+				out <- bootstrap.NewMergeNothingStatus(id)
 			}
 		}
 	}()
@@ -128,7 +128,7 @@ func MergeAll(repo repository.ClockedRepo, remote string) <-chan entity.MergeRes
 // It is left as a responsibility to the caller to make sure that this identities is not
 // linked from another entity, otherwise it would break it.
 // Remove is idempotent.
-func Remove(repo repository.ClockedRepo, id entity.Id) error {
+func Remove(repo repository.ClockedRepo, id bootstrap.Id) error {
 	var fullMatches []string
 
 	refs, err := repo.ListRefs(identityRefPattern + id.String())
@@ -136,7 +136,7 @@ func Remove(repo repository.ClockedRepo, id entity.Id) error {
 		return err
 	}
 	if len(refs) > 1 {
-		return entity.NewErrMultipleMatch(Typename, entity.RefsToIds(refs))
+		return bootstrap.NewErrMultipleMatch(Typename, bootstrap.RefsToIds(refs))
 	}
 	if len(refs) == 1 {
 		// we have the identity locally
@@ -155,7 +155,7 @@ func Remove(repo repository.ClockedRepo, id entity.Id) error {
 			return err
 		}
 		if len(remoteRefs) > 1 {
-			return entity.NewErrMultipleMatch(Typename, entity.RefsToIds(refs))
+			return bootstrap.NewErrMultipleMatch(Typename, bootstrap.RefsToIds(refs))
 		}
 		if len(remoteRefs) == 1 {
 			// found the identity in a remote
@@ -164,7 +164,7 @@ func Remove(repo repository.ClockedRepo, id entity.Id) error {
 	}
 
 	if len(fullMatches) == 0 {
-		return entity.NewErrNotFound(Typename)
+		return bootstrap.NewErrNotFound(Typename)
 	}
 
 	for _, ref := range fullMatches {

entities/identity/identity_actions_test.go 🔗

@@ -5,7 +5,7 @@ import (
 
 	"github.com/stretchr/testify/require"
 
-	"github.com/MichaelMure/git-bug/entity"
+	bootstrap "github.com/MichaelMure/git-bug/entity/boostrap"
 	"github.com/MichaelMure/git-bug/repository"
 )
 
@@ -146,7 +146,7 @@ func TestIdentityPushPull(t *testing.T) {
 	}
 }
 
-func allIdentities(t testing.TB, identities <-chan entity.StreamedEntity[*Identity]) []*Identity {
+func allIdentities(t testing.TB, identities <-chan bootstrap.StreamedEntity[*Identity]) []*Identity {
 	var result []*Identity
 	for streamed := range identities {
 		if streamed.Err != nil {

entities/identity/identity_stub.go 🔗

@@ -3,7 +3,7 @@ package identity
 import (
 	"encoding/json"
 
-	"github.com/MichaelMure/git-bug/entity"
+	bootstrap "github.com/MichaelMure/git-bug/entity/boostrap"
 	"github.com/MichaelMure/git-bug/repository"
 	"github.com/MichaelMure/git-bug/util/lamport"
 	"github.com/MichaelMure/git-bug/util/timestamp"
@@ -17,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 entity.Id
+	id bootstrap.Id
 }
 
 func (i *IdentityStub) MarshalJSON() ([]byte, error) {
 	// TODO: add a type marker
 	return json.Marshal(struct {
-		Id entity.Id `json:"id"`
+		Id bootstrap.Id `json:"id"`
 	}{
 		Id: i.id,
 	})
@@ -31,7 +31,7 @@ func (i *IdentityStub) MarshalJSON() ([]byte, error) {
 
 func (i *IdentityStub) UnmarshalJSON(data []byte) error {
 	aux := struct {
-		Id entity.Id `json:"id"`
+		Id bootstrap.Id `json:"id"`
 	}{}
 
 	if err := json.Unmarshal(data, &aux); err != nil {
@@ -44,7 +44,7 @@ func (i *IdentityStub) UnmarshalJSON(data []byte) error {
 }
 
 // Id return the Identity identifier
-func (i *IdentityStub) Id() entity.Id {
+func (i *IdentityStub) Id() bootstrap.Id {
 	return i.id
 }
 

entities/identity/identity_test.go 🔗

@@ -6,7 +6,7 @@ import (
 
 	"github.com/stretchr/testify/require"
 
-	"github.com/MichaelMure/git-bug/entity"
+	bootstrap "github.com/MichaelMure/git-bug/entity/boostrap"
 	"github.com/MichaelMure/git-bug/repository"
 	"github.com/MichaelMure/git-bug/util/lamport"
 )
@@ -279,13 +279,13 @@ func TestIdentityRemove(t *testing.T) {
 	require.NoError(t, err)
 
 	_, err = ReadLocal(repo, rene.Id())
-	require.ErrorAs(t, entity.ErrNotFound{}, err)
+	require.ErrorAs(t, bootstrap.ErrNotFound{}, err)
 
 	_, err = ReadRemote(repo, "remoteA", string(rene.Id()))
-	require.ErrorAs(t, entity.ErrNotFound{}, err)
+	require.ErrorAs(t, bootstrap.ErrNotFound{}, err)
 
 	_, err = ReadRemote(repo, "remoteB", string(rene.Id()))
-	require.ErrorAs(t, entity.ErrNotFound{}, err)
+	require.ErrorAs(t, bootstrap.ErrNotFound{}, err)
 
 	ids, err := ListLocalIds(repo)
 	require.NoError(t, err)

entities/identity/identity_user.go 🔗

@@ -6,7 +6,7 @@ import (
 
 	"github.com/pkg/errors"
 
-	"github.com/MichaelMure/git-bug/entity"
+	bootstrap "github.com/MichaelMure/git-bug/entity/boostrap"
 	"github.com/MichaelMure/git-bug/repository"
 )
 
@@ -27,7 +27,7 @@ func GetUserIdentity(repo repository.Repo) (*Identity, error) {
 	}
 
 	i, err := ReadLocal(repo, id)
-	if entity.IsErrNotFound(err) {
+	if bootstrap.IsErrNotFound(err) {
 		innerErr := repo.LocalConfig().RemoveAll(identityConfigKey)
 		if innerErr != nil {
 			_, _ = fmt.Fprintln(os.Stderr, errors.Wrap(innerErr, "can't clear user identity").Error())
@@ -38,22 +38,22 @@ func GetUserIdentity(repo repository.Repo) (*Identity, error) {
 	return i, nil
 }
 
-func GetUserIdentityId(repo repository.Repo) (entity.Id, error) {
+func GetUserIdentityId(repo repository.Repo) (bootstrap.Id, error) {
 	val, err := repo.LocalConfig().ReadString(identityConfigKey)
 	if errors.Is(err, repository.ErrNoConfigEntry) {
-		return entity.UnsetId, ErrNoIdentitySet
+		return bootstrap.UnsetId, ErrNoIdentitySet
 	}
 	if errors.Is(err, repository.ErrMultipleConfigEntry) {
-		return entity.UnsetId, ErrMultipleIdentitiesSet
+		return bootstrap.UnsetId, ErrMultipleIdentitiesSet
 	}
 	if err != nil {
-		return entity.UnsetId, err
+		return bootstrap.UnsetId, err
 	}
 
-	var id = entity.Id(val)
+	var id = bootstrap.Id(val)
 
 	if err := id.Validate(); err != nil {
-		return entity.UnsetId, err
+		return bootstrap.UnsetId, err
 	}
 
 	return id, nil

entities/identity/interface.go 🔗

@@ -1,14 +1,14 @@
 package identity
 
 import (
-	"github.com/MichaelMure/git-bug/entity"
+	bootstrap "github.com/MichaelMure/git-bug/entity/boostrap"
 	"github.com/MichaelMure/git-bug/repository"
 	"github.com/MichaelMure/git-bug/util/lamport"
 	"github.com/MichaelMure/git-bug/util/timestamp"
 )
 
 type Interface interface {
-	entity.Interface
+	bootstrap.Entity
 
 	// Name return the last version of the name
 	// Can be empty.

entities/identity/resolver.go 🔗

@@ -1,11 +1,11 @@
 package identity
 
 import (
-	"github.com/MichaelMure/git-bug/entity"
+	bootstrap "github.com/MichaelMure/git-bug/entity/boostrap"
 	"github.com/MichaelMure/git-bug/repository"
 )
 
-var _ entity.Resolver = &SimpleResolver{}
+var _ bootstrap.Resolver = &SimpleResolver{}
 
 // SimpleResolver is a Resolver loading Identities directly from a Repo
 type SimpleResolver struct {
@@ -16,6 +16,6 @@ func NewSimpleResolver(repo repository.Repo) *SimpleResolver {
 	return &SimpleResolver{repo: repo}
 }
 
-func (r *SimpleResolver) Resolve(id entity.Id) (entity.Resolved, error) {
+func (r *SimpleResolver) Resolve(id bootstrap.Id) (bootstrap.Resolved, error) {
 	return ReadLocal(r.repo, id)
 }

entities/identity/version.go 🔗

@@ -8,7 +8,7 @@ import (
 
 	"github.com/pkg/errors"
 
-	"github.com/MichaelMure/git-bug/entity"
+	bootstrap "github.com/MichaelMure/git-bug/entity/boostrap"
 	"github.com/MichaelMure/git-bug/repository"
 	"github.com/MichaelMure/git-bug/util/lamport"
 	"github.com/MichaelMure/git-bug/util/text"
@@ -46,7 +46,7 @@ type version struct {
 	metadata map[string]string
 
 	// Not serialized. Store the version's id in memory.
-	id entity.Id
+	id bootstrap.Id
 	// Not serialized
 	commitHash repository.Hash
 }
@@ -63,7 +63,7 @@ func newVersion(repo repository.RepoClock, name string, email string, login stri
 	}
 
 	return &version{
-		id:        entity.UnsetId,
+		id:        bootstrap.UnsetId,
 		name:      name,
 		email:     email,
 		login:     login,
@@ -91,12 +91,12 @@ type versionJSON struct {
 }
 
 // Id return the identifier of the version
-func (v *version) Id() entity.Id {
+func (v *version) Id() bootstrap.Id {
 	if v.id == "" {
 		// something went really wrong
 		panic("version's id not set")
 	}
-	if v.id == entity.UnsetId {
+	if v.id == bootstrap.UnsetId {
 		// This means we are trying to get the version's Id *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.
@@ -104,7 +104,7 @@ func (v *version) Id() entity.Id {
 		if err != nil {
 			panic(err)
 		}
-		v.id = entity.DeriveId(data)
+		v.id = bootstrap.DeriveId(data)
 	}
 	return v.id
 }
@@ -116,7 +116,7 @@ func (v *version) Clone() *version {
 
 	// reset some fields
 	clone.commitHash = ""
-	clone.id = entity.UnsetId
+	clone.id = bootstrap.UnsetId
 
 	clone.times = make(map[string]lamport.Time)
 	for name, t := range v.times {
@@ -159,10 +159,10 @@ func (v *version) UnmarshalJSON(data []byte) error {
 	}
 
 	if aux.FormatVersion != formatVersion {
-		return entity.NewErrInvalidFormat(aux.FormatVersion, formatVersion)
+		return bootstrap.NewErrInvalidFormat(aux.FormatVersion, formatVersion)
 	}
 
-	v.id = entity.DeriveId(data)
+	v.id = bootstrap.DeriveId(data)
 	v.times = aux.Times
 	v.unixTime = aux.UnixTime
 	v.name = aux.Name
@@ -237,7 +237,7 @@ func (v *version) Write(repo repository.Repo) (repository.Hash, error) {
 	}
 
 	// make sure we set the Id when writing in the repo
-	v.id = entity.DeriveId(data)
+	v.id = bootstrap.DeriveId(data)
 
 	return hash, nil
 }

entities/identity/version_test.go 🔗

@@ -8,7 +8,7 @@ import (
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 
-	"github.com/MichaelMure/git-bug/entity"
+	bootstrap "github.com/MichaelMure/git-bug/entity/boostrap"
 	"github.com/MichaelMure/git-bug/repository"
 	"github.com/MichaelMure/git-bug/util/lamport"
 )
@@ -44,7 +44,7 @@ func TestVersionJSON(t *testing.T) {
 	before.SetMetadata("key2", "value2")
 
 	expected := &version{
-		id:        entity.UnsetId,
+		id:        bootstrap.UnsetId,
 		name:      "name",
 		email:     "email",
 		login:     "login",

entity/interface.go → entity/boostrap/entity.go 🔗

@@ -1,6 +1,6 @@
-package entity
+package bootstrap
 
-type Interface interface {
+type Entity interface {
 	// Id return the Entity identifier
 	//
 	// This Id need to be immutable without having to store the entity somewhere (ie, an entity only in memory
@@ -9,6 +9,7 @@ type Interface interface {
 	// the root of the entity.
 	// It is acceptable to use such a hash and keep mutating that data as long as Id() is not called.
 	Id() Id
+
 	// Validate check if the Entity data is valid
 	Validate() error
 }

entity/boostrap/err.go 🔗

@@ -0,0 +1,84 @@
+package bootstrap
+
+import (
+	"fmt"
+	"strings"
+)
+
+// ErrNotFound is to be returned when an entity, item, element is
+// not found.
+type ErrNotFound struct {
+	typename string
+}
+
+func NewErrNotFound(typename string) *ErrNotFound {
+	return &ErrNotFound{typename: typename}
+}
+
+func (e ErrNotFound) Error() string {
+	return fmt.Sprintf("%s doesn't exist", e.typename)
+}
+
+func IsErrNotFound(err error) bool {
+	_, ok := err.(*ErrNotFound)
+	return ok
+}
+
+// ErrMultipleMatch is to be returned when more than one entity, item, element
+// is found, where only one was expected.
+type ErrMultipleMatch struct {
+	typename string
+	Matching []Id
+}
+
+func NewErrMultipleMatch(typename string, matching []Id) *ErrMultipleMatch {
+	return &ErrMultipleMatch{typename: typename, Matching: matching}
+}
+
+func (e ErrMultipleMatch) Error() string {
+	matching := make([]string, len(e.Matching))
+
+	for i, match := range e.Matching {
+		matching[i] = match.String()
+	}
+
+	return fmt.Sprintf("Multiple matching %s found:\n%s",
+		e.typename,
+		strings.Join(matching, "\n"))
+}
+
+func IsErrMultipleMatch(err error) bool {
+	_, ok := err.(*ErrMultipleMatch)
+	return ok
+}
+
+// ErrInvalidFormat is to be returned when reading on-disk data with an unexpected
+// format or version.
+type ErrInvalidFormat struct {
+	version  uint
+	expected uint
+}
+
+func NewErrInvalidFormat(version uint, expected uint) *ErrInvalidFormat {
+	return &ErrInvalidFormat{
+		version:  version,
+		expected: expected,
+	}
+}
+
+func NewErrUnknownFormat(expected uint) *ErrInvalidFormat {
+	return &ErrInvalidFormat{
+		version:  0,
+		expected: expected,
+	}
+}
+
+func (e ErrInvalidFormat) Error() string {
+	if e.version == 0 {
+		return fmt.Sprintf("unreadable data, you likely have an outdated repository format, please use https://github.com/MichaelMure/git-bug-migration to upgrade to format version %v", e.expected)
+	}
+	if e.version < e.expected {
+		return fmt.Sprintf("outdated repository format %v, please use https://github.com/MichaelMure/git-bug-migration to upgrade to format version %v", e.version, e.expected)
+	}
+	return fmt.Sprintf("your version of git-bug is too old for this repository (format version %v, expected %v), please upgrade to the latest version", e.version, e.expected)
+}

entity/boostrap/id.go 🔗

@@ -0,0 +1,81 @@
+package bootstrap
+
+import (
+	"crypto/sha256"
+	"fmt"
+	"io"
+	"strings"
+
+	"github.com/pkg/errors"
+)
+
+// sha-256
+const IdLength = 64
+const HumanIdLength = 7
+
+const UnsetId = Id("unset")
+
+// Id is an identifier for an entity or part of an entity
+type Id string
+
+// DeriveId generate an Id from the serialization of the object or part of the object.
+func DeriveId(data []byte) Id {
+	// My understanding is that sha256 is enough to prevent collision (git use that, so ...?)
+	// If you read this code, I'd be happy to be schooled.
+
+	sum := sha256.Sum256(data)
+	return Id(fmt.Sprintf("%x", sum))
+}
+
+// String return the identifier as a string
+func (i Id) String() string {
+	return string(i)
+}
+
+// Human return the identifier, shortened for human consumption
+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() + `"`))
+}
+
+// Validate tell if the Id is valid
+func (i Id) Validate() error {
+	// Special case to detect outdated repo
+	if len(i) == 40 {
+		return fmt.Errorf("outdated repository format, please use https://github.com/MichaelMure/git-bug-migration to upgrade")
+	}
+	if len(i) != IdLength {
+		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/boostrap/merge.go 🔗

@@ -0,0 +1,91 @@
+package bootstrap
+
+import (
+	"fmt"
+)
+
+// MergeStatus represent the result of a merge operation of an entity
+type MergeStatus int
+
+const (
+	_                  MergeStatus = iota
+	MergeStatusNew                 // a new Entity was created locally
+	MergeStatusInvalid             // the remote data is invalid
+	MergeStatusUpdated             // a local Entity has been updated
+	MergeStatusNothing             // no changes were made to a local Entity (already up to date)
+	MergeStatusError               // a terminal error happened
+)
+
+// MergeResult hold the result of a merge operation on an Entity.
+type MergeResult struct {
+	// Err is set when a terminal error occur in the process
+	Err error
+
+	Id     Id
+	Status MergeStatus
+
+	// Only set for Invalid status
+	Reason string
+
+	// Only set for New or Updated status
+	Entity Entity
+}
+
+func (mr MergeResult) String() string {
+	switch mr.Status {
+	case MergeStatusNew:
+		return "new"
+	case MergeStatusInvalid:
+		return fmt.Sprintf("invalid data: %s", mr.Reason)
+	case MergeStatusUpdated:
+		return "updated"
+	case MergeStatusNothing:
+		return "nothing to do"
+	case MergeStatusError:
+		if mr.Id != "" {
+			return fmt.Sprintf("merge error on %s: %s", mr.Id, mr.Err.Error())
+		}
+		return fmt.Sprintf("merge error: %s", mr.Err.Error())
+	default:
+		panic("unknown merge status")
+	}
+}
+
+func NewMergeNewStatus(id Id, entity Entity) MergeResult {
+	return MergeResult{
+		Id:     id,
+		Status: MergeStatusNew,
+		Entity: entity,
+	}
+}
+
+func NewMergeInvalidStatus(id Id, reason string) MergeResult {
+	return MergeResult{
+		Id:     id,
+		Status: MergeStatusInvalid,
+		Reason: reason,
+	}
+}
+
+func NewMergeUpdatedStatus(id Id, entity Entity) MergeResult {
+	return MergeResult{
+		Id:     id,
+		Status: MergeStatusUpdated,
+		Entity: entity,
+	}
+}
+
+func NewMergeNothingStatus(id Id) MergeResult {
+	return MergeResult{
+		Id:     id,
+		Status: MergeStatusNothing,
+	}
+}
+
+func NewMergeError(err error, id Id) MergeResult {
+	return MergeResult{
+		Id:     id,
+		Status: MergeStatusError,
+		Err:    err,
+	}
+}

entity/boostrap/refs.go 🔗

@@ -0,0 +1,20 @@
+package bootstrap
+
+import "strings"
+
+// RefsToIds parse a slice of git references and return the corresponding Entity's Id.
+func RefsToIds(refs []string) []Id {
+	ids := make([]Id, len(refs))
+
+	for i, ref := range refs {
+		ids[i] = RefToId(ref)
+	}
+
+	return ids
+}
+
+// RefToId parse a git reference and return the corresponding Entity's Id.
+func RefToId(ref string) Id {
+	split := strings.Split(ref, "/")
+	return Id(split[len(split)-1])
+}

entity/boostrap/resolver.go 🔗

@@ -0,0 +1,13 @@
+package bootstrap
+
+// Resolved is a minimal interface on which Resolver operates on.
+// Notably, this operates on Entity and Excerpt in the cache.
+type Resolved interface {
+	// Id returns the object identifier.
+	Id() Id
+}
+
+// Resolver is an interface to find an Entity from its Id
+type Resolver interface {
+	Resolve(id Id) (Resolved, error)
+}

entity/streamed.go → entity/boostrap/streamed.go 🔗

@@ -1,6 +1,9 @@
-package entity
+package bootstrap
 
-type StreamedEntity[EntityT Interface] struct {
+// TODO: type alias not possible on generics for now
+// https://github.com/golang/go/issues/46477
+
+type StreamedEntity[EntityT Entity] struct {
 	Err    error
 	Entity EntityT
 

entity/dag/common_test.go 🔗

@@ -19,7 +19,7 @@ import (
 */
 
 const (
-	_ OperationType = iota
+	_ entity.OperationType = iota
 	Op1
 	Op2
 )
@@ -61,7 +61,7 @@ func (op *op2) Validate() error { return nil }
 
 func unmarshaler(raw json.RawMessage, resolvers entity.Resolvers) (Operation, error) {
 	var t struct {
-		OperationType OperationType `json:"type"`
+		OperationType entity.OperationType `json:"type"`
 	}
 
 	if err := json.Unmarshal(raw, &t); err != nil {

entity/dag/entity.go 🔗

@@ -11,6 +11,7 @@ import (
 
 	"github.com/MichaelMure/git-bug/entities/identity"
 	"github.com/MichaelMure/git-bug/entity"
+	bootstrap "github.com/MichaelMure/git-bug/entity/boostrap"
 	"github.com/MichaelMure/git-bug/repository"
 	"github.com/MichaelMure/git-bug/util/lamport"
 )
@@ -59,7 +60,7 @@ func New(definition Definition) *Entity {
 }
 
 // Read will read and decode a stored local Entity from a repository
-func Read[EntityT entity.Interface](def Definition, wrapper func(e *Entity) EntityT, repo repository.ClockedRepo, resolvers entity.Resolvers, id entity.Id) (EntityT, error) {
+func Read[EntityT entity.Bare](def Definition, wrapper func(e *Entity) EntityT, repo repository.ClockedRepo, resolvers entity.Resolvers, id entity.Id) (EntityT, error) {
 	if err := id.Validate(); err != nil {
 		return *new(EntityT), errors.Wrap(err, "invalid id")
 	}
@@ -70,7 +71,7 @@ func Read[EntityT entity.Interface](def Definition, wrapper func(e *Entity) Enti
 }
 
 // readRemote will read and decode a stored remote Entity from a repository
-func readRemote[EntityT entity.Interface](def Definition, wrapper func(e *Entity) EntityT, repo repository.ClockedRepo, resolvers entity.Resolvers, remote string, id entity.Id) (EntityT, error) {
+func readRemote[EntityT entity.Bare](def Definition, wrapper func(e *Entity) EntityT, repo repository.ClockedRepo, resolvers entity.Resolvers, remote string, id entity.Id) (EntityT, error) {
 	if err := id.Validate(); err != nil {
 		return *new(EntityT), errors.Wrap(err, "invalid id")
 	}
@@ -81,7 +82,7 @@ func readRemote[EntityT entity.Interface](def Definition, wrapper func(e *Entity
 }
 
 // read fetch from git and decode an Entity at an arbitrary git reference.
-func read[EntityT entity.Interface](def Definition, wrapper func(e *Entity) EntityT, repo repository.ClockedRepo, resolvers entity.Resolvers, ref string) (EntityT, error) {
+func read[EntityT entity.Bare](def Definition, wrapper func(e *Entity) EntityT, repo repository.ClockedRepo, resolvers entity.Resolvers, ref string) (EntityT, error) {
 	rootHash, err := repo.ResolveRef(ref)
 	if err == repository.ErrNotFound {
 		return *new(EntityT), entity.NewErrNotFound(def.Typename)
@@ -300,8 +301,8 @@ func readClockNoCheck(def Definition, repo repository.ClockedRepo, ref string) e
 }
 
 // ReadAll read and parse all local Entity
-func ReadAll[EntityT entity.Interface](def Definition, wrapper func(e *Entity) EntityT, repo repository.ClockedRepo, resolvers entity.Resolvers) <-chan entity.StreamedEntity[EntityT] {
-	out := make(chan entity.StreamedEntity[EntityT])
+func ReadAll[EntityT entity.Bare](def Definition, wrapper func(e *Entity) EntityT, repo repository.ClockedRepo, resolvers entity.Resolvers) <-chan bootstrap.StreamedEntity[EntityT] {
+	out := make(chan bootstrap.StreamedEntity[EntityT])
 
 	go func() {
 		defer close(out)
@@ -310,7 +311,7 @@ func ReadAll[EntityT entity.Interface](def Definition, wrapper func(e *Entity) E
 
 		refs, err := repo.ListRefs(refPrefix)
 		if err != nil {
-			out <- entity.StreamedEntity[EntityT]{Err: err}
+			out <- bootstrap.StreamedEntity[EntityT]{Err: err}
 			return
 		}
 
@@ -321,11 +322,11 @@ func ReadAll[EntityT entity.Interface](def Definition, wrapper func(e *Entity) E
 			e, err := read[EntityT](def, wrapper, repo, resolvers, ref)
 
 			if err != nil {
-				out <- entity.StreamedEntity[EntityT]{Err: err}
+				out <- bootstrap.StreamedEntity[EntityT]{Err: err}
 				return
 			}
 
-			out <- entity.StreamedEntity[EntityT]{
+			out <- bootstrap.StreamedEntity[EntityT]{
 				Entity:        e,
 				CurrentEntity: current,
 				TotalEntities: total,

entity/dag/entity_actions.go 🔗

@@ -32,7 +32,7 @@ func Push(def Definition, repo repository.Repo, remote string) (string, error) {
 
 // Pull will do a Fetch + MergeAll
 // Contrary to MergeAll, this function will return an error if a merge fail.
-func Pull[EntityT entity.Interface](def Definition, wrapper func(e *Entity) EntityT, repo repository.ClockedRepo, resolvers entity.Resolvers, remote string, author identity.Interface) error {
+func Pull[EntityT entity.Bare](def Definition, wrapper func(e *Entity) EntityT, repo repository.ClockedRepo, resolvers entity.Resolvers, remote string, author identity.Interface) error {
 	_, err := Fetch(def, repo, remote)
 	if err != nil {
 		return err
@@ -68,7 +68,7 @@ func Pull[EntityT entity.Interface](def Definition, wrapper func(e *Entity) Enti
 //
 // Note: an author is necessary for the case where a merge commit is created, as this commit will
 // have an author and may be signed if a signing key is available.
-func MergeAll[EntityT entity.Interface](def Definition, wrapper func(e *Entity) EntityT, repo repository.ClockedRepo, resolvers entity.Resolvers, remote string, author identity.Interface) <-chan entity.MergeResult {
+func MergeAll[EntityT entity.Bare](def Definition, wrapper func(e *Entity) EntityT, repo repository.ClockedRepo, resolvers entity.Resolvers, remote string, author identity.Interface) <-chan entity.MergeResult {
 	out := make(chan entity.MergeResult)
 
 	go func() {
@@ -91,7 +91,7 @@ func MergeAll[EntityT entity.Interface](def Definition, wrapper func(e *Entity)
 
 // merge perform a merge to make sure a local Entity is up-to-date.
 // See MergeAll for more details.
-func merge[EntityT entity.Interface](def Definition, wrapper func(e *Entity) EntityT, repo repository.ClockedRepo, resolvers entity.Resolvers, remoteRef string, author identity.Interface) entity.MergeResult {
+func merge[EntityT entity.Bare](def Definition, wrapper func(e *Entity) EntityT, repo repository.ClockedRepo, resolvers entity.Resolvers, remoteRef string, author identity.Interface) entity.MergeResult {
 	id := entity.RefToId(remoteRef)
 
 	if err := id.Validate(); err != nil {

entity/dag/entity_actions_test.go 🔗

@@ -8,10 +8,11 @@ import (
 	"github.com/stretchr/testify/require"
 
 	"github.com/MichaelMure/git-bug/entity"
+	bootstrap "github.com/MichaelMure/git-bug/entity/boostrap"
 	"github.com/MichaelMure/git-bug/repository"
 )
 
-func allEntities(t testing.TB, bugs <-chan entity.StreamedEntity[*Foo]) []*Foo {
+func allEntities(t testing.TB, bugs <-chan bootstrap.StreamedEntity[*Foo]) []*Foo {
 	t.Helper()
 
 	var result []*Foo

entity/dag/example_test.go 🔗

@@ -66,7 +66,7 @@ type Operation interface {
 }
 
 const (
-	_ dag.OperationType = iota
+	_ entity.OperationType = iota
 	SetSignatureRequiredOp
 	AddAdministratorOp
 	RemoveAdministratorOp
@@ -220,7 +220,7 @@ var def = dag.Definition{
 // concrete Operations. If needed, we can use the resolver to connect to other entities.
 func operationUnmarshaler(raw json.RawMessage, resolvers entity.Resolvers) (dag.Operation, error) {
 	var t struct {
-		OperationType dag.OperationType `json:"type"`
+		OperationType entity.OperationType `json:"type"`
 	}
 
 	if err := json.Unmarshal(raw, &t); err != nil {

entity/dag/op_noop.go 🔗

@@ -5,17 +5,17 @@ import (
 	"github.com/MichaelMure/git-bug/entity"
 )
 
-var _ Operation = &NoOpOperation[Snapshot]{}
-var _ OperationDoesntChangeSnapshot = &NoOpOperation[Snapshot]{}
+var _ Operation = &NoOpOperation[entity.Snapshot]{}
+var _ entity.OperationDoesntChangeSnapshot = &NoOpOperation[entity.Snapshot]{}
 
 // NoOpOperation is an operation that does not change the entity state. It can
 // however be used to store arbitrary metadata in the entity history, for example
 // to support a bridge feature.
-type NoOpOperation[SnapT Snapshot] struct {
+type NoOpOperation[SnapT entity.Snapshot] struct {
 	OpBase
 }
 
-func NewNoOpOp[SnapT Snapshot](opType OperationType, author identity.Interface, unixTime int64) *NoOpOperation[SnapT] {
+func NewNoOpOp[SnapT entity.Snapshot](opType entity.OperationType, author identity.Interface, unixTime int64) *NoOpOperation[SnapT] {
 	return &NoOpOperation[SnapT]{
 		OpBase: NewOpBase(opType, author, unixTime),
 	}

entity/dag/op_set_metadata.go 🔗

@@ -10,16 +10,16 @@ import (
 	"github.com/MichaelMure/git-bug/util/text"
 )
 
-var _ Operation = &SetMetadataOperation[Snapshot]{}
-var _ OperationDoesntChangeSnapshot = &SetMetadataOperation[Snapshot]{}
+var _ Operation = &SetMetadataOperation[entity.Snapshot]{}
+var _ entity.OperationDoesntChangeSnapshot = &SetMetadataOperation[entity.Snapshot]{}
 
-type SetMetadataOperation[SnapT Snapshot] struct {
+type SetMetadataOperation[SnapT entity.Snapshot] struct {
 	OpBase
 	Target      entity.Id         `json:"target"`
 	NewMetadata map[string]string `json:"new_metadata"`
 }
 
-func NewSetMetadataOp[SnapT Snapshot](opType OperationType, author identity.Interface, unixTime int64, target entity.Id, newMetadata map[string]string) *SetMetadataOperation[SnapT] {
+func NewSetMetadataOp[SnapT entity.Snapshot](opType entity.OperationType, author identity.Interface, unixTime int64, target entity.Id, newMetadata map[string]string) *SetMetadataOperation[SnapT] {
 	return &SetMetadataOperation[SnapT]{
 		OpBase:      NewOpBase(opType, author, unixTime),
 		Target:      target,

entity/dag/op_set_metadata_test.go 🔗

@@ -12,17 +12,17 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
-var _ Snapshot = &snapshotMock{}
+var _ entity.Snapshot = &snapshotMock{}
 
 type snapshotMock struct {
-	ops []Operation
+	ops []entity.Operation
 }
 
-func (s *snapshotMock) AllOperations() []Operation {
+func (s *snapshotMock) AllOperations() []entity.Operation {
 	return s.ops
 }
 
-func (s *snapshotMock) AppendOperation(op Operation) {
+func (s *snapshotMock) AppendOperation(op entity.Operation) {
 	s.ops = append(s.ops, op)
 }
 

entity/dag/operation.go 🔗

@@ -10,50 +10,11 @@ import (
 
 	"github.com/MichaelMure/git-bug/entities/identity"
 	"github.com/MichaelMure/git-bug/entity"
-	"github.com/MichaelMure/git-bug/repository"
 )
 
-// OperationType is an operation type identifier
-type OperationType int
-
-// Operation is a piece of data defining a change to reflect on the state of an Entity.
-// What this Operation or Entity's state looks like is not of the resort of this package as it only deals with the
-// data structure and storage.
+// Operation is an extended interface for an entity.Operation working with the dag package.
 type Operation interface {
-	// Id return the Operation identifier
-	//
-	// Some care need to be taken to define a correct Id derivation and enough entropy in the data used to avoid
-	// collisions. Notably:
-	// - the Id of the first Operation will be used as the Id of the Entity. Collision need to be avoided across entities
-	//   of the same type (example: no collision within the "bug" namespace).
-	// - collisions can also happen within the set of Operations of an Entity. Simple Operation might not have enough
-	//   entropy to yield unique Ids (example: two "close" operation within the same second, same author).
-	//   If this is a concern, it is recommended to include a piece of random data in the operation's data, to guarantee
-	//   a minimal amount of entropy and avoid collision.
-	//
-	//   Author's note: I tried to find a clever way around that inelegance (stuffing random useless data into the stored
-	//   structure is not exactly elegant), but I failed to find a proper way. Essentially, anything that would reuse some
-	//   other data (parent operation's Id, lamport clock) or the graph structure (depth) impose that the Id would only
-	//   make sense in the context of the graph and yield some deep coupling between Entity and Operation. This in turn
-	//   make the whole thing even less elegant.
-	//
-	// A common way to derive an Id will be to use the entity.DeriveId() function on the serialized operation data.
-	Id() entity.Id
-	// Type return the type of the operation
-	Type() OperationType
-	// Validate check if the Operation data is valid
-	Validate() error
-	// Author returns the author of this operation
-	Author() identity.Interface
-	// Time return the time when the operation was added
-	Time() time.Time
-
-	// SetMetadata store arbitrary metadata about the operation
-	SetMetadata(key string, value string)
-	// GetMetadata retrieve arbitrary metadata about the operation
-	GetMetadata(key string) (string, bool)
-	// AllMetadata return all metadata for this operation
-	AllMetadata() map[string]string
+	entity.Operation
 
 	// setId allow to set the Id, used when unmarshalling only
 	setId(id entity.Id)
@@ -63,37 +24,13 @@ type Operation interface {
 	setExtraMetadataImmutable(key string, value string)
 }
 
-type OperationWithApply[SnapT Snapshot] interface {
+type OperationWithApply[SnapT entity.Snapshot] interface {
 	Operation
 
 	// Apply the operation to a Snapshot to create the final state
 	Apply(snapshot SnapT)
 }
 
-// OperationWithFiles is an optional extension for an Operation that has files dependency, stored in git.
-type OperationWithFiles interface {
-	// GetFiles return the files needed by this operation
-	// This implies that the Operation maintain and store internally the references to those files. This is how
-	// this information is read later, when loading from storage.
-	// For example, an operation that has a text value referencing some files would maintain a mapping (text ref -->
-	// hash).
-	GetFiles() []repository.Hash
-}
-
-// OperationDoesntChangeSnapshot is an interface signaling that the Operation implementing it doesn't change the
-// snapshot, for example a metadata operation that act on other operations.
-type OperationDoesntChangeSnapshot interface {
-	DoesntChangeSnapshot()
-}
-
-// Snapshot is the minimal interface that a snapshot need to implement
-type Snapshot interface {
-	// AllOperations returns all the operations that have been applied to that snapshot, in order
-	AllOperations() []Operation
-	// AppendOperation add an operation in the list
-	AppendOperation(op Operation)
-}
-
 // OpBase implement the common feature that every Operation should support.
 type OpBase struct {
 	// Not serialized. Store the op's id in memory.
@@ -101,8 +38,8 @@ type OpBase struct {
 	// Not serialized
 	author identity.Interface
 
-	OperationType OperationType `json:"type"`
-	UnixTime      int64         `json:"timestamp"`
+	OperationType entity.OperationType `json:"type"`
+	UnixTime      int64                `json:"timestamp"`
 
 	// mandatory random bytes to ensure a better randomness of the data used to later generate the ID
 	// len(Nonce) should be > 20 and < 64 bytes
@@ -115,7 +52,7 @@ type OpBase struct {
 	extraMetadata map[string]string
 }
 
-func NewOpBase(opType OperationType, author identity.Interface, unixTime int64) OpBase {
+func NewOpBase(opType entity.OperationType, author identity.Interface, unixTime int64) OpBase {
 	return OpBase{
 		OperationType: opType,
 		author:        author,
@@ -155,7 +92,7 @@ func IdOperation(op Operation, base *OpBase) entity.Id {
 	return base.id
 }
 
-func (base *OpBase) Type() OperationType {
+func (base *OpBase) Type() entity.OperationType {
 	return base.OperationType
 }
 
@@ -165,7 +102,7 @@ func (base *OpBase) Time() time.Time {
 }
 
 // Validate check the OpBase for errors
-func (base *OpBase) Validate(op Operation, opType OperationType) error {
+func (base *OpBase) Validate(op entity.Operation, opType entity.OperationType) error {
 	if base.OperationType == 0 {
 		return fmt.Errorf("operation type unset")
 	}
@@ -185,7 +122,7 @@ func (base *OpBase) Validate(op Operation, opType OperationType) error {
 		return errors.Wrap(err, "author")
 	}
 
-	if op, ok := op.(OperationWithFiles); ok {
+	if op, ok := op.(entity.OperationWithFiles); ok {
 		for _, hash := range op.GetFiles() {
 			if !hash.IsValid() {
 				return fmt.Errorf("file with invalid hash %v", hash)

entity/dag/operation_pack.go 🔗

@@ -180,7 +180,7 @@ func (opp *operationPack) makeExtraTree() []repository.TreeEntry {
 	added := make(map[repository.Hash]interface{})
 
 	for _, ops := range opp.Operations {
-		ops, ok := ops.(OperationWithFiles)
+		ops, ok := ops.(entity.OperationWithFiles)
 		if !ok {
 			continue
 		}

entity/dag/operation_pack_test.go 🔗

@@ -7,6 +7,7 @@ import (
 	"github.com/stretchr/testify/require"
 
 	"github.com/MichaelMure/git-bug/entities/identity"
+	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/repository"
 )
 
@@ -107,11 +108,11 @@ func TestOperationPackFiles(t *testing.T) {
 	}
 	require.Equal(t, opp, opp2)
 
-	require.ElementsMatch(t, opp2.Operations[0].(OperationWithFiles).GetFiles(), []repository.Hash{
+	require.ElementsMatch(t, opp2.Operations[0].(entity.OperationWithFiles).GetFiles(), []repository.Hash{
 		blobHash1,
 		blobHash2,
 	})
-	require.ElementsMatch(t, opp2.Operations[1].(OperationWithFiles).GetFiles(), []repository.Hash{
+	require.ElementsMatch(t, opp2.Operations[1].(entity.OperationWithFiles).GetFiles(), []repository.Hash{
 		blobHash2,
 	})
 

entity/dag/interface.go → entity/entity.go 🔗

@@ -1,14 +1,17 @@
-package dag
+package entity
 
 import (
-	"github.com/MichaelMure/git-bug/entity"
+	bootstrap "github.com/MichaelMure/git-bug/entity/boostrap"
 	"github.com/MichaelMure/git-bug/repository"
 	"github.com/MichaelMure/git-bug/util/lamport"
 )
 
-// Interface define the extended interface of a dag.Entity
+type Bare bootstrap.Entity
+
+// Interface define the extended interface of an Entity
 type Interface[SnapT Snapshot, OpT Operation] interface {
-	entity.Interface
+	Bare
+	CompileToSnapshot[SnapT]
 
 	// Validate checks if the Entity data is valid
 	Validate() error
@@ -19,25 +22,12 @@ type Interface[SnapT Snapshot, OpT Operation] interface {
 	// Operations returns the ordered operations
 	Operations() []OpT
 
-	// NeedCommit indicates that the in-memory state changed and need to be committed in the repository
-	NeedCommit() bool
-
-	// Commit writes the staging area in Git and move the operations to the packs
-	Commit(repo repository.ClockedRepo) error
-
-	// CommitAsNeeded execute a Commit only if necessary. This function is useful to avoid getting an error if the Entity
-	// is already in sync with the repository.
-	CommitAsNeeded(repo repository.ClockedRepo) error
-
 	// FirstOp lookup for the very first operation of the Entity.
 	FirstOp() OpT
 
-	// LastOp lookup for the very last operation of the Entity.
-	// For a valid Entity, should never be nil
-	LastOp() OpT
-
-	// Compile an Entity in an easily usable snapshot
-	Compile() SnapT
+	// // LastOp lookup for the very last operation of the Entity.
+	// // For a valid Entity, should never be nil
+	// LastOp() OpT
 
 	// CreateLamportTime return the Lamport time of creation
 	CreateLamportTime() lamport.Time
@@ -45,3 +35,20 @@ type Interface[SnapT Snapshot, OpT Operation] interface {
 	// EditLamportTime return the Lamport time of the last edit
 	EditLamportTime() lamport.Time
 }
+
+type WithCommit[SnapT Snapshot, OpT Operation] interface {
+	Interface[SnapT, OpT]
+	Committer
+}
+
+type Committer interface {
+	// NeedCommit indicates that the in-memory state changed and need to be committed in the repository
+	NeedCommit() bool
+
+	// Commit writes the staging area in Git and move the operations to the packs
+	Commit(repo repository.ClockedRepo) error
+
+	// CommitAsNeeded execute a Commit only if necessary. This function is useful to avoid getting an error if the Entity
+	// is already in sync with the repository.
+	CommitAsNeeded(repo repository.ClockedRepo) error
+}

entity/err.go 🔗

@@ -1,23 +1,14 @@
 package entity
 
 import (
-	"fmt"
-	"strings"
+	bootstrap "github.com/MichaelMure/git-bug/entity/boostrap"
 )
 
 // ErrNotFound is to be returned when an entity, item, element is
 // not found.
-type ErrNotFound struct {
-	typename string
-}
-
-func NewErrNotFound(typename string) *ErrNotFound {
-	return &ErrNotFound{typename: typename}
-}
+type ErrNotFound = bootstrap.ErrNotFound
 
-func (e ErrNotFound) Error() string {
-	return fmt.Sprintf("%s doesn't exist", e.typename)
-}
+var NewErrNotFound = bootstrap.NewErrNotFound
 
 func IsErrNotFound(err error) bool {
 	_, ok := err.(*ErrNotFound)
@@ -26,26 +17,9 @@ func IsErrNotFound(err error) bool {
 
 // ErrMultipleMatch is to be returned when more than one entity, item, element
 // is found, where only one was expected.
-type ErrMultipleMatch struct {
-	typename string
-	Matching []Id
-}
-
-func NewErrMultipleMatch(typename string, matching []Id) *ErrMultipleMatch {
-	return &ErrMultipleMatch{typename: typename, Matching: matching}
-}
-
-func (e ErrMultipleMatch) Error() string {
-	matching := make([]string, len(e.Matching))
+type ErrMultipleMatch = bootstrap.ErrMultipleMatch
 
-	for i, match := range e.Matching {
-		matching[i] = match.String()
-	}
-
-	return fmt.Sprintf("Multiple matching %s found:\n%s",
-		e.typename,
-		strings.Join(matching, "\n"))
-}
+var NewErrMultipleMatch = bootstrap.NewErrMultipleMatch
 
 func IsErrMultipleMatch(err error) bool {
 	_, ok := err.(*ErrMultipleMatch)
@@ -54,31 +28,8 @@ func IsErrMultipleMatch(err error) bool {
 
 // ErrInvalidFormat is to be returned when reading on-disk data with an unexpected
 // format or version.
-type ErrInvalidFormat struct {
-	version  uint
-	expected uint
-}
+type ErrInvalidFormat = bootstrap.ErrInvalidFormat
 
-func NewErrInvalidFormat(version uint, expected uint) *ErrInvalidFormat {
-	return &ErrInvalidFormat{
-		version:  version,
-		expected: expected,
-	}
-}
+var NewErrInvalidFormat = bootstrap.NewErrInvalidFormat
 
-func NewErrUnknownFormat(expected uint) *ErrInvalidFormat {
-	return &ErrInvalidFormat{
-		version:  0,
-		expected: expected,
-	}
-}
-
-func (e ErrInvalidFormat) Error() string {
-	if e.version == 0 {
-		return fmt.Sprintf("unreadable data, you likely have an outdated repository format, please use https://github.com/MichaelMure/git-bug-migration to upgrade to format version %v", e.expected)
-	}
-	if e.version < e.expected {
-		return fmt.Sprintf("outdated repository format %v, please use https://github.com/MichaelMure/git-bug-migration to upgrade to format version %v", e.version, e.expected)
-	}
-	return fmt.Sprintf("your version of git-bug is too old for this repository (format version %v, expected %v), please upgrade to the latest version", e.version, e.expected)
-}
+var NewErrUnknownFormat = bootstrap.NewErrUnknownFormat

entity/id.go 🔗

@@ -1,81 +1,15 @@
 package entity
 
 import (
-	"crypto/sha256"
-	"fmt"
-	"io"
-	"strings"
-
-	"github.com/pkg/errors"
+	bootstrap "github.com/MichaelMure/git-bug/entity/boostrap"
 )
 
-// sha-256
-const idLength = 64
-const HumanIdLength = 7
+const HumanIdLength = bootstrap.HumanIdLength
 
-const UnsetId = Id("unset")
+const UnsetId = bootstrap.UnsetId
 
 // Id is an identifier for an entity or part of an entity
-type Id string
+type Id = bootstrap.Id
 
 // DeriveId generate an Id from the serialization of the object or part of the object.
-func DeriveId(data []byte) Id {
-	// My understanding is that sha256 is enough to prevent collision (git use that, so ...?)
-	// If you read this code, I'd be happy to be schooled.
-
-	sum := sha256.Sum256(data)
-	return Id(fmt.Sprintf("%x", sum))
-}
-
-// String return the identifier as a string
-func (i Id) String() string {
-	return string(i)
-}
-
-// Human return the identifier, shortened for human consumption
-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() + `"`))
-}
-
-// Validate tell if the Id is valid
-func (i Id) Validate() error {
-	// Special case to detect outdated repo
-	if len(i) == 40 {
-		return fmt.Errorf("outdated repository format, please use https://github.com/MichaelMure/git-bug-migration to upgrade")
-	}
-	if len(i) != idLength {
-		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
-}
+var DeriveId = bootstrap.DeriveId

entity/id_interleaved.go 🔗

@@ -6,6 +6,8 @@ import (
 	"strings"
 
 	"github.com/pkg/errors"
+
+	bootstrap "github.com/MichaelMure/git-bug/entity/boostrap"
 )
 
 const UnsetCombinedId = CombinedId("unset")
@@ -57,7 +59,7 @@ func (ci CombinedId) Validate() error {
 	if len(ci) == 40 {
 		return fmt.Errorf("outdated repository format, please use https://github.com/MichaelMure/git-bug-migration to upgrade")
 	}
-	if len(ci) != idLength {
+	if len(ci) != bootstrap.IdLength {
 		return fmt.Errorf("invalid length")
 	}
 	for _, r := range ci {
@@ -113,7 +115,7 @@ func (ci CombinedId) SecondaryPrefix() string {
 func CombineIds(primary Id, secondary Id) CombinedId {
 	var id strings.Builder
 
-	for i := 0; i < idLength; i++ {
+	for i := 0; i < bootstrap.IdLength; i++ {
 		switch {
 		default:
 			id.WriteByte(primary[0])

entity/identity.go 🔗

@@ -0,0 +1,73 @@
+package entity
+
+//
+// import (
+// 	"github.com/ProtonMail/go-crypto/openpgp"
+// 	"github.com/ProtonMail/go-crypto/openpgp/packet"
+//
+// 	"github.com/MichaelMure/git-bug/repository"
+// 	"github.com/MichaelMure/git-bug/util/lamport"
+// 	"github.com/MichaelMure/git-bug/util/timestamp"
+// )
+//
+// type Key interface {
+// 	Public() *packet.PublicKey
+// 	Private() *packet.PrivateKey
+// 	Validate() error
+// 	Clone() Key
+// 	PGPEntity() *openpgp.Entity
+// }
+//
+// type Identity interface {
+// 	Bare
+//
+// 	// Name return the last version of the name
+// 	// Can be empty.
+// 	Name() string
+//
+// 	// DisplayName return a non-empty string to display, representing the
+// 	// identity, based on the non-empty values.
+// 	DisplayName() string
+//
+// 	// Email return the last version of the email
+// 	// Can be empty.
+// 	Email() string
+//
+// 	// Login return the last version of the login
+// 	// Can be empty.
+// 	// Warning: this login can be defined when importing from a bridge but should *not* be
+// 	// used to identify an identity as multiple bridge with different login can map to the same
+// 	// identity. Use the metadata system for that usage instead.
+// 	Login() string
+//
+// 	// AvatarUrl return the last version of the Avatar URL
+// 	// Can be empty.
+// 	AvatarUrl() string
+//
+// 	// Keys return the last version of the valid keys
+// 	// Can be empty.
+// 	Keys() []Key
+//
+// 	// SigningKey return the key that should be used to sign new messages. If no key is available, return nil.
+// 	SigningKey(repo repository.RepoKeyring) (Key, error)
+//
+// 	// ValidKeysAtTime return the set of keys valid at a given lamport time for a given clock of another entity
+// 	// Can be empty.
+// 	ValidKeysAtTime(clockName string, time lamport.Time) []Key
+//
+// 	// LastModification return the timestamp at which the last version of the identity became valid.
+// 	LastModification() timestamp.Timestamp
+//
+// 	// LastModificationLamports return the lamport times at which the last version of the identity became valid.
+// 	LastModificationLamports() map[string]lamport.Time
+//
+// 	// IsProtected return true if the chain of git commits started to be signed.
+// 	// If that's the case, only signed commit with a valid key for this identity can be added.
+// 	IsProtected() bool
+//
+// 	// Validate check if the Identity data is valid
+// 	Validate() error
+//
+// 	// NeedCommit indicate that the in-memory state changed and need to be committed in the repository
+// 	NeedCommit() bool
+// }

entity/merge.go 🔗

@@ -1,91 +1,29 @@
 package entity
 
 import (
-	"fmt"
+	bootstrap "github.com/MichaelMure/git-bug/entity/boostrap"
 )
 
 // MergeStatus represent the result of a merge operation of an entity
-type MergeStatus int
+type MergeStatus = bootstrap.MergeStatus
 
 const (
-	_                  MergeStatus = iota
-	MergeStatusNew                 // a new Entity was created locally
-	MergeStatusInvalid             // the remote data is invalid
-	MergeStatusUpdated             // a local Entity has been updated
-	MergeStatusNothing             // no changes were made to a local Entity (already up to date)
-	MergeStatusError               // a terminal error happened
+	MergeStatusNew     = bootstrap.MergeStatusNew     // a new Entity was created locally
+	MergeStatusInvalid = bootstrap.MergeStatusInvalid // the remote data is invalid
+	MergeStatusUpdated = bootstrap.MergeStatusUpdated // a local Entity has been updated
+	MergeStatusNothing = bootstrap.MergeStatusNothing // no changes were made to a local Entity (already up to date)
+	MergeStatusError   = bootstrap.MergeStatusError   // a terminal error happened
 )
 
 // MergeResult hold the result of a merge operation on an Entity.
-type MergeResult struct {
-	// Err is set when a terminal error occur in the process
-	Err error
+type MergeResult = bootstrap.MergeResult
 
-	Id     Id
-	Status MergeStatus
+var NewMergeNewStatus = bootstrap.NewMergeNewStatus
 
-	// Only set for Invalid status
-	Reason string
+var NewMergeInvalidStatus = bootstrap.NewMergeInvalidStatus
 
-	// Only set for New or Updated status
-	Entity Interface
-}
+var NewMergeUpdatedStatus = bootstrap.NewMergeUpdatedStatus
 
-func (mr MergeResult) String() string {
-	switch mr.Status {
-	case MergeStatusNew:
-		return "new"
-	case MergeStatusInvalid:
-		return fmt.Sprintf("invalid data: %s", mr.Reason)
-	case MergeStatusUpdated:
-		return "updated"
-	case MergeStatusNothing:
-		return "nothing to do"
-	case MergeStatusError:
-		if mr.Id != "" {
-			return fmt.Sprintf("merge error on %s: %s", mr.Id, mr.Err.Error())
-		}
-		return fmt.Sprintf("merge error: %s", mr.Err.Error())
-	default:
-		panic("unknown merge status")
-	}
-}
+var NewMergeNothingStatus = bootstrap.NewMergeNothingStatus
 
-func NewMergeNewStatus(id Id, entity Interface) MergeResult {
-	return MergeResult{
-		Id:     id,
-		Status: MergeStatusNew,
-		Entity: entity,
-	}
-}
-
-func NewMergeInvalidStatus(id Id, reason string) MergeResult {
-	return MergeResult{
-		Id:     id,
-		Status: MergeStatusInvalid,
-		Reason: reason,
-	}
-}
-
-func NewMergeUpdatedStatus(id Id, entity Interface) MergeResult {
-	return MergeResult{
-		Id:     id,
-		Status: MergeStatusUpdated,
-		Entity: entity,
-	}
-}
-
-func NewMergeNothingStatus(id Id) MergeResult {
-	return MergeResult{
-		Id:     id,
-		Status: MergeStatusNothing,
-	}
-}
-
-func NewMergeError(err error, id Id) MergeResult {
-	return MergeResult{
-		Id:     id,
-		Status: MergeStatusError,
-		Err:    err,
-	}
-}
+var NewMergeError = bootstrap.NewMergeError

entity/operations.go 🔗

@@ -0,0 +1,76 @@
+package entity
+
+import (
+	"time"
+
+	"github.com/MichaelMure/git-bug/entities/identity"
+	"github.com/MichaelMure/git-bug/repository"
+)
+
+// OperationType is an operation type identifier
+type OperationType int
+
+// Operation is a piece of data defining a change to reflect on the state of an Entity.
+// What this Operation or Entity's state looks like is not of the resort of this package as it only deals with the
+// data structure and storage.
+type Operation interface {
+	// Id return the Operation identifier
+	//
+	// Some care need to be taken to define a correct Id derivation and enough entropy in the data used to avoid
+	// collisions. Notably:
+	// - the Id of the first Operation will be used as the Id of the Entity. Collision need to be avoided across entities
+	//   of the same type (example: no collision within the "bug" namespace).
+	// - collisions can also happen within the set of Operations of an Entity. Simple Operation might not have enough
+	//   entropy to yield unique Ids (example: two "close" operation within the same second, same author).
+	//   If this is a concern, it is recommended to include a piece of random data in the operation's data, to guarantee
+	//   a minimal amount of entropy and avoid collision.
+	//
+	//   Author's note: I tried to find a clever way around that inelegance (stuffing random useless data into the stored
+	//   structure is not exactly elegant), but I failed to find a proper way. Essentially, anything that would reuse some
+	//   other data (parent operation's Id, lamport clock) or the graph structure (depth) impose that the Id would only
+	//   make sense in the context of the graph and yield some deep coupling between Entity and Operation. This in turn
+	//   make the whole thing even less elegant.
+	//
+	// A common way to derive an Id will be to use the entity.DeriveId() function on the serialized operation data.
+	Id() Id
+	// Type return the type of the operation
+	Type() OperationType
+	// Validate check if the Operation data is valid
+	Validate() error
+	// Author returns the author of this operation
+	Author() identity.Interface
+	// Time return the time when the operation was added
+	Time() time.Time
+
+	// SetMetadata store arbitrary metadata about the operation
+	SetMetadata(key string, value string)
+	// GetMetadata retrieve arbitrary metadata about the operation
+	GetMetadata(key string) (string, bool)
+	// AllMetadata return all metadata for this operation
+	AllMetadata() map[string]string
+}
+
+type OperationWithApply[SnapT Snapshot] interface {
+	Operation
+
+	// Apply the operation to a Snapshot to create the final state
+	Apply(snapshot SnapT)
+}
+
+// OperationWithFiles is an optional extension for an Operation that has files dependency, stored in git.
+type OperationWithFiles interface {
+	Operation
+
+	// GetFiles return the files needed by this operation
+	// This implies that the Operation maintain and store internally the references to those files. This is how
+	// this information is read later, when loading from storage.
+	// For example, an operation that has a text value referencing some files would maintain a mapping (text ref -->
+	// hash).
+	GetFiles() []repository.Hash
+}
+
+// OperationDoesntChangeSnapshot is an interface signaling that the Operation implementing it doesn't change the
+// snapshot, for example a metadata operation that act on other operations.
+type OperationDoesntChangeSnapshot interface {
+	DoesntChangeSnapshot()
+}

entity/refs.go 🔗

@@ -1,20 +1,11 @@
 package entity
 
-import "strings"
+import (
+	bootstrap "github.com/MichaelMure/git-bug/entity/boostrap"
+)
 
 // RefsToIds parse a slice of git references and return the corresponding Entity's Id.
-func RefsToIds(refs []string) []Id {
-	ids := make([]Id, len(refs))
-
-	for i, ref := range refs {
-		ids[i] = RefToId(ref)
-	}
-
-	return ids
-}
+var RefsToIds = bootstrap.RefsToIds
 
 // RefToId parse a git reference and return the corresponding Entity's Id.
-func RefToId(ref string) Id {
-	split := strings.Split(ref, "/")
-	return Id(split[len(split)-1])
-}
+var RefToId = bootstrap.RefToId

entity/resolver.go 🔗

@@ -3,19 +3,16 @@ package entity
 import (
 	"fmt"
 	"sync"
+
+	bootstrap "github.com/MichaelMure/git-bug/entity/boostrap"
 )
 
 // Resolved is a minimal interface on which Resolver operates on.
 // Notably, this operates on Entity and Excerpt in the cache.
-type Resolved interface {
-	// Id returns the object identifier.
-	Id() Id
-}
+type Resolved = bootstrap.Resolved
 
 // Resolver is an interface to find an Entity from its Id
-type Resolver interface {
-	Resolve(id Id) (Resolved, error)
-}
+type Resolver = bootstrap.Resolver
 
 // Resolvers is a collection of Resolver, for different type of Entity
 type Resolvers map[Resolved]Resolver

entity/snapshot.go 🔗

@@ -0,0 +1,14 @@
+package entity
+
+// Snapshot is the minimal interface that a snapshot need to implement
+type Snapshot interface {
+	// AllOperations returns all the operations that have been applied to that snapshot, in order
+	AllOperations() []Operation
+	// AppendOperation add an operation in the list
+	AppendOperation(op Operation)
+}
+
+type CompileToSnapshot[SnapT Snapshot] interface {
+	// Compile an Entity in an easily usable snapshot
+	Compile() SnapT
+}

termui/label_select.go 🔗

@@ -40,7 +40,7 @@ func (ls *labelSelect) SetBug(cache *cache.RepoCache, bug *cache.BugCache) {
 	ls.labels = cache.Bugs().ValidLabels()
 
 	// Find which labels are currently applied to the bug
-	bugLabels := bug.Snapshot().Labels
+	bugLabels := bug.Compile().Labels
 	labelSelect := make([]bool, len(ls.labels))
 	for i, label := range ls.labels {
 		for _, bugLabel := range bugLabels {
@@ -271,7 +271,7 @@ func (ls *labelSelect) abort(g *gocui.Gui, v *gocui.View) error {
 }
 
 func (ls *labelSelect) saveAndReturn(g *gocui.Gui, v *gocui.View) error {
-	bugLabels := ls.bug.Snapshot().Labels
+	bugLabels := ls.bug.Compile().Labels
 	var selectedLabels []bug.Label
 	for i, label := range ls.labels {
 		if ls.labelSelect[i] {

termui/show_bug.go 🔗

@@ -214,7 +214,7 @@ func (sb *showBug) renderMain(g *gocui.Gui, mainView *gocui.View) error {
 
 	y0 -= sb.scroll
 
-	snap := sb.bug.Snapshot()
+	snap := sb.bug.Compile()
 
 	sb.mainSelectableView = nil
 
@@ -425,7 +425,7 @@ func (sb *showBug) renderSidebar(g *gocui.Gui, sideView *gocui.View) error {
 	x0, y0, _, _, _ := g.ViewPosition(sideView.Name())
 	maxX += x0
 
-	snap := sb.bug.Snapshot()
+	snap := sb.bug.Compile()
 
 	sb.sideSelectableView = nil
 
@@ -624,7 +624,7 @@ func (sb *showBug) setTitle(g *gocui.Gui, v *gocui.View) error {
 }
 
 func (sb *showBug) toggleOpenClose(g *gocui.Gui, v *gocui.View) error {
-	switch sb.bug.Snapshot().Status {
+	switch sb.bug.Compile().Status {
 	case common.OpenStatus:
 		_, err := sb.bug.Close()
 		return err
@@ -637,7 +637,7 @@ func (sb *showBug) toggleOpenClose(g *gocui.Gui, v *gocui.View) error {
 }
 
 func (sb *showBug) edit(g *gocui.Gui, v *gocui.View) error {
-	snap := sb.bug.Snapshot()
+	snap := sb.bug.Compile()
 
 	if sb.isOnSide {
 		return sb.editLabels(g, snap)

termui/termui.go 🔗

@@ -296,7 +296,7 @@ func setTitleWithEditor(bug *cache.BugCache) error {
 	ui.g.Close()
 	ui.g = nil
 
-	snap := bug.Snapshot()
+	snap := bug.Compile()
 
 	title, err := buginput.BugTitleEditorInput(ui.cache, snap.Title)