cache: tie up the refactor up to compiling

Michael MurΓ© created

Change summary

api/auth/context.go                        |   2 
api/graphql/graphql_test.go                |   2 
api/graphql/models/lazy_bug.go             |   6 
api/graphql/models/lazy_identity.go        |   6 
api/graphql/resolvers/mutation.go          |   6 
api/graphql/resolvers/repo.go              |  14 +-
api/http/git_file_handlers_test.go         |   4 
bridge/core/config.go                      |   4 
bridge/github/config.go                    |  12 -
bridge/github/export.go                    |   7 
bridge/github/export_test.go               |  28 ++-
bridge/github/import.go                    |  13 -
bridge/github/import_integration_test.go   |  15 +
bridge/github/import_test.go               |   9 
bridge/gitlab/export.go                    |   7 
bridge/gitlab/export_test.go               |  28 ++-
bridge/gitlab/import.go                    |   9 
bridge/gitlab/import_test.go               |   9 
bridge/jira/export.go                      |   7 
bridge/jira/import.go                      |   8 
bridge/launchpad/import.go                 |   9 
cache/bug_excerpt.go                       |   9 +
cache/bug_subcache.go                      |  21 +-
cache/cached.go                            |  16 ++
cache/filter.go                            |  56 +++----
cache/identity_excerpt.go                  |   9 +
cache/identity_subcache.go                 |  58 +++++---
cache/multi_repo_cache.go                  |  16 +-
cache/repo_cache.go                        |  49 +++---
cache/repo_cache_common.go                 |  97 ++++----------
cache/repo_cache_test.go                   | 163 ++++++++++++-----------
cache/subcache.go                          | 160 +++++++++++++++++-----
commands/bridge/bridge_auth_addtoken.go    |   2 
commands/bug/bug.go                        |  38 ++--
commands/bug/bug_comment_edit.go           |   2 
commands/bug/bug_new.go                    |   2 
commands/bug/bug_rm.go                     |   2 
commands/bug/bug_select.go                 |   2 
commands/bug/select/select.go              |   5 
commands/bug/select/select_test.go         |  13 +
commands/bug/testenv/testenv.go            |   6 
commands/cmdjson/json_common.go            |   4 
commands/completion/helper_completion.go   |  24 +-
commands/execenv/env_testing.go            |   9 +
commands/label.go                          |   2 
commands/user/user.go                      |   6 
commands/user/user_adopt.go                |   2 
commands/user/user_new.go                  |   2 
commands/user/user_show.go                 |   2 
commands/webui.go                          |   2 
entities/bug/bug.go                        |  13 -
entities/bug/resolver.go                   |   2 
entities/identity/common.go                |   6 
entities/identity/identity.go              |  31 +--
entities/identity/identity_actions_test.go |   5 
entities/identity/resolver.go              |  15 --
entity/dag/entity.go                       |  34 +---
entity/dag/entity_actions_test.go          |   2 
entity/err.go                              |  16 +
entity/interface.go                        |  12 -
entity/resolver.go                         |  29 ++-
entity/streamed.go                         |   6 
termui/bug_table.go                        |  12 
termui/label_select.go                     |   2 
termui/termui.go                           |   2 
65 files changed, 615 insertions(+), 556 deletions(-)

Detailed changes

api/auth/context.go πŸ”—

@@ -24,5 +24,5 @@ func UserFromCtx(ctx context.Context, r *cache.RepoCache) (*cache.IdentityCache,
 	if !ok {
 		return nil, ErrNotAuthenticated
 	}
-	return r.ResolveIdentity(id)
+	return r.Identities().Resolve(id)
 }

api/graphql/graphql_test.go πŸ”—

@@ -19,7 +19,7 @@ func TestQueries(t *testing.T) {
 	random_bugs.FillRepoWithSeed(repo, 10, 42)
 
 	mrc := cache.NewMultiRepoCache()
-	_, err := mrc.RegisterDefaultRepository(repo)
+	_, _, err := mrc.RegisterDefaultRepository(repo)
 	require.NoError(t, err)
 
 	handler := NewHandler(mrc, nil)

api/graphql/models/lazy_bug.go πŸ”—

@@ -58,7 +58,7 @@ func (lb *lazyBug) load() error {
 		return nil
 	}
 
-	b, err := lb.cache.ResolveBug(lb.excerpt.Id)
+	b, err := lb.cache.Bugs().Resolve(lb.excerpt.Id())
 	if err != nil {
 		return err
 	}
@@ -68,7 +68,7 @@ func (lb *lazyBug) load() error {
 }
 
 func (lb *lazyBug) identity(id entity.Id) (IdentityWrapper, error) {
-	i, err := lb.cache.ResolveIdentityExcerpt(id)
+	i, err := lb.cache.Identities().ResolveExcerpt(id)
 	if err != nil {
 		return nil, err
 	}
@@ -79,7 +79,7 @@ func (lb *lazyBug) identity(id entity.Id) (IdentityWrapper, error) {
 func (lb *lazyBug) IsAuthored() {}
 
 func (lb *lazyBug) Id() entity.Id {
-	return lb.excerpt.Id
+	return lb.excerpt.Id()
 }
 
 func (lb *lazyBug) LastEdit() time.Time {

api/graphql/models/lazy_identity.go πŸ”—

@@ -48,16 +48,16 @@ func (li *lazyIdentity) load() (*cache.IdentityCache, error) {
 		return li.id, nil
 	}
 
-	id, err := li.cache.ResolveIdentity(li.excerpt.Id)
+	id, err := li.cache.Identities().Resolve(li.excerpt.Id())
 	if err != nil {
-		return nil, fmt.Errorf("cache: missing identity %v", li.excerpt.Id)
+		return nil, fmt.Errorf("cache: missing identity %v", li.excerpt.Id())
 	}
 	li.id = id
 	return id, nil
 }
 
 func (li *lazyIdentity) Id() entity.Id {
-	return li.excerpt.Id
+	return li.excerpt.Id()
 }
 
 func (li *lazyIdentity) Name() string {

api/graphql/resolvers/mutation.go πŸ”—

@@ -32,7 +32,7 @@ func (r mutationResolver) getBug(repoRef *string, bugPrefix string) (*cache.Repo
 		return nil, nil, err
 	}
 
-	b, err := repo.ResolveBugPrefix(bugPrefix)
+	b, err := repo.Bugs().ResolvePrefix(bugPrefix)
 	if err != nil {
 		return nil, nil, err
 	}
@@ -50,7 +50,7 @@ func (r mutationResolver) NewBug(ctx context.Context, input models.NewBugInput)
 		return nil, err
 	}
 
-	b, op, err := repo.NewBugRaw(author,
+	b, op, err := repo.Bugs().NewRaw(author,
 		time.Now().Unix(),
 		text.CleanupOneLine(input.Title),
 		text.Cleanup(input.Message),
@@ -181,7 +181,7 @@ func (r mutationResolver) EditComment(ctx context.Context, input models.EditComm
 		return nil, err
 	}
 
-	b, target, err := repo.ResolveComment(input.TargetPrefix)
+	b, target, err := repo.Bugs().ResolveComment(input.TargetPrefix)
 	if err != nil {
 		return nil, err
 	}

api/graphql/resolvers/repo.go πŸ”—

@@ -41,7 +41,7 @@ func (repoResolver) AllBugs(_ context.Context, obj *models.Repository, after *st
 	}
 
 	// Simply pass a []string with the ids to the pagination algorithm
-	source, err := obj.Repo.QueryBugs(q)
+	source, err := obj.Repo.Bugs().Query(q)
 	if err != nil {
 		return nil, err
 	}
@@ -60,7 +60,7 @@ func (repoResolver) AllBugs(_ context.Context, obj *models.Repository, after *st
 		nodes := make([]models.BugWrapper, len(lazyBugEdges))
 
 		for i, lazyBugEdge := range lazyBugEdges {
-			excerpt, err := obj.Repo.ResolveBugExcerpt(lazyBugEdge.Id)
+			excerpt, err := obj.Repo.Bugs().ResolveExcerpt(lazyBugEdge.Id)
 			if err != nil {
 				return nil, err
 			}
@@ -86,7 +86,7 @@ func (repoResolver) AllBugs(_ context.Context, obj *models.Repository, after *st
 }
 
 func (repoResolver) Bug(_ context.Context, obj *models.Repository, prefix string) (models.BugWrapper, error) {
-	excerpt, err := obj.Repo.ResolveBugExcerptPrefix(prefix)
+	excerpt, err := obj.Repo.Bugs().ResolveExcerptPrefix(prefix)
 	if err != nil {
 		return nil, err
 	}
@@ -103,7 +103,7 @@ func (repoResolver) AllIdentities(_ context.Context, obj *models.Repository, aft
 	}
 
 	// Simply pass a []string with the ids to the pagination algorithm
-	source := obj.Repo.AllIdentityIds()
+	source := obj.Repo.Identities().AllIds()
 
 	// The edger create a custom edge holding just the id
 	edger := func(id entity.Id, offset int) connections.Edge {
@@ -119,7 +119,7 @@ func (repoResolver) AllIdentities(_ context.Context, obj *models.Repository, aft
 		nodes := make([]models.IdentityWrapper, len(lazyIdentityEdges))
 
 		for k, lazyIdentityEdge := range lazyIdentityEdges {
-			excerpt, err := obj.Repo.ResolveIdentityExcerpt(lazyIdentityEdge.Id)
+			excerpt, err := obj.Repo.Identities().ResolveExcerpt(lazyIdentityEdge.Id)
 			if err != nil {
 				return nil, err
 			}
@@ -145,7 +145,7 @@ func (repoResolver) AllIdentities(_ context.Context, obj *models.Repository, aft
 }
 
 func (repoResolver) Identity(_ context.Context, obj *models.Repository, prefix string) (models.IdentityWrapper, error) {
-	excerpt, err := obj.Repo.ResolveIdentityExcerptPrefix(prefix)
+	excerpt, err := obj.Repo.Identities().ResolveExcerptPrefix(prefix)
 	if err != nil {
 		return nil, err
 	}
@@ -187,5 +187,5 @@ func (repoResolver) ValidLabels(_ context.Context, obj *models.Repository, after
 		}, nil
 	}
 
-	return connections.LabelCon(obj.Repo.ValidLabels(), edger, conMaker, input)
+	return connections.LabelCon(obj.Repo.Bugs().ValidLabels(), edger, conMaker, input)
 }

api/http/git_file_handlers_test.go πŸ”—

@@ -22,10 +22,10 @@ func TestGitFileHandlers(t *testing.T) {
 	repo := repository.CreateGoGitTestRepo(t, false)
 
 	mrc := cache.NewMultiRepoCache()
-	repoCache, err := mrc.RegisterDefaultRepository(repo)
+	repoCache, _, err := mrc.RegisterDefaultRepository(repo)
 	require.NoError(t, err)
 
-	author, err := repoCache.NewIdentity("test identity", "test@test.org")
+	author, err := repoCache.Identities().New("test identity", "test@test.org")
 	require.NoError(t, err)
 
 	err = repoCache.SetUserIdentity(author)

bridge/core/config.go πŸ”—

@@ -10,7 +10,7 @@ import (
 
 func FinishConfig(repo *cache.RepoCache, metaKey string, login string) error {
 	// if no user exist with the given login metadata
-	_, err := repo.ResolveIdentityImmutableMetadata(metaKey, login)
+	_, err := repo.Identities().ResolveIdentityImmutableMetadata(metaKey, login)
 	if err != nil && !entity.IsErrNotFound(err) {
 		// real error
 		return err
@@ -34,7 +34,7 @@ func FinishConfig(repo *cache.RepoCache, metaKey string, login string) error {
 	}
 
 	// otherwise create a user with that metadata
-	i, err := repo.NewIdentityFromGitUserRaw(map[string]string{
+	i, err := repo.Identities().NewFromGitUserRaw(map[string]string{
 		metaKey: login,
 	})
 	if err != nil {

bridge/github/config.go πŸ”—

@@ -5,7 +5,6 @@ import (
 	"encoding/json"
 	"fmt"
 	"io/ioutil"
-	"math/rand"
 	"net/http"
 	"net/url"
 	"regexp"
@@ -319,17 +318,6 @@ func pollGithubForAuthorization(deviceCode string, intervalSec int64) (string, e
 	}
 }
 
-func randomFingerprint() string {
-	// Doesn't have to be crypto secure, it's just to avoid token collision
-	rand.Seed(time.Now().UnixNano())
-	var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
-	b := make([]rune, 32)
-	for i := range b {
-		b[i] = letterRunes[rand.Intn(len(letterRunes))]
-	}
-	return string(b)
-}
-
 func promptTokenOptions(repo repository.RepoKeyring, login, owner, project string) (auth.Credential, error) {
 	creds, err := auth.List(repo,
 		auth.WithTarget(target),

bridge/github/export.go πŸ”—

@@ -20,7 +20,6 @@ import (
 	"github.com/MichaelMure/git-bug/cache"
 	"github.com/MichaelMure/git-bug/entities/bug"
 	"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"
 )
@@ -89,7 +88,7 @@ func (ge *githubExporter) cacheAllClient(repo *cache.RepoCache) error {
 			continue
 		}
 
-		user, err := repo.ResolveIdentityImmutableMetadata(metaKeyGithubLogin, login)
+		user, err := repo.Identities().ResolveIdentityImmutableMetadata(metaKeyGithubLogin, login)
 		if entity.IsErrNotFound(err) {
 			continue
 		}
@@ -160,10 +159,10 @@ func (ge *githubExporter) ExportAll(ctx context.Context, repo *cache.RepoCache,
 			allIdentitiesIds = append(allIdentitiesIds, id)
 		}
 
-		allBugsIds := repo.AllBugsIds()
+		allBugsIds := repo.Bugs().AllIds()
 
 		for _, id := range allBugsIds {
-			b, err := repo.ResolveBug(id)
+			b, err := repo.Bugs().Resolve(id)
 			if err != nil {
 				out <- core.NewExportError(errors.Wrap(err, "can't load bug"), id)
 				return

bridge/github/export_test.go πŸ”—

@@ -34,18 +34,18 @@ type testCase struct {
 
 func testCases(t *testing.T, repo *cache.RepoCache) []*testCase {
 	// simple bug
-	simpleBug, _, err := repo.NewBug("simple bug", "new bug")
+	simpleBug, _, err := repo.Bugs().New("simple bug", "new bug")
 	require.NoError(t, err)
 
 	// bug with comments
-	bugWithComments, _, err := repo.NewBug("bug with comments", "new bug")
+	bugWithComments, _, err := repo.Bugs().New("bug with comments", "new bug")
 	require.NoError(t, err)
 
 	_, _, err = bugWithComments.AddComment("new comment")
 	require.NoError(t, err)
 
 	// bug with label changes
-	bugLabelChange, _, err := repo.NewBug("bug label change", "new bug")
+	bugLabelChange, _, err := repo.Bugs().New("bug label change", "new bug")
 	require.NoError(t, err)
 
 	_, _, err = bugLabelChange.ChangeLabels([]string{"bug"}, nil)
@@ -64,7 +64,7 @@ func testCases(t *testing.T, repo *cache.RepoCache) []*testCase {
 	require.NoError(t, err)
 
 	// bug with comments editions
-	bugWithCommentEditions, createOp, err := repo.NewBug("bug with comments editions", "new bug")
+	bugWithCommentEditions, createOp, err := repo.Bugs().New("bug with comments editions", "new bug")
 	require.NoError(t, err)
 
 	_, err = bugWithCommentEditions.EditComment(
@@ -78,7 +78,7 @@ func testCases(t *testing.T, repo *cache.RepoCache) []*testCase {
 	require.NoError(t, err)
 
 	// bug status changed
-	bugStatusChanged, _, err := repo.NewBug("bug status changed", "new bug")
+	bugStatusChanged, _, err := repo.Bugs().New("bug status changed", "new bug")
 	require.NoError(t, err)
 
 	_, err = bugStatusChanged.Close()
@@ -88,7 +88,7 @@ func testCases(t *testing.T, repo *cache.RepoCache) []*testCase {
 	require.NoError(t, err)
 
 	// bug title changed
-	bugTitleEdited, _, err := repo.NewBug("bug title edited", "new bug")
+	bugTitleEdited, _, err := repo.Bugs().New("bug title edited", "new bug")
 	require.NoError(t, err)
 
 	_, err = bugTitleEdited.SetTitle("bug title edited again")
@@ -141,12 +141,15 @@ func TestGithubPushPull(t *testing.T) {
 	// create repo backend
 	repo := repository.CreateGoGitTestRepo(t, false)
 
-	backend, err := cache.NewRepoCache(repo)
+	backend, events, err := cache.NewRepoCache(repo)
 	require.NoError(t, err)
+	for event := range events {
+		require.NoError(t, event.Err)
+	}
 
 	// set author identity
 	login := "identity-test"
-	author, err := backend.NewIdentity("test identity", "test@test.org")
+	author, err := backend.Identities().New("test identity", "test@test.org")
 	require.NoError(t, err)
 	author.SetMetadata(metaKeyGithubLogin, login)
 	err = author.Commit()
@@ -217,8 +220,11 @@ func TestGithubPushPull(t *testing.T) {
 	repoTwo := repository.CreateGoGitTestRepo(t, false)
 
 	// create a second backend
-	backendTwo, err := cache.NewRepoCache(repoTwo)
+	backendTwo, events, err := cache.NewRepoCache(repoTwo)
 	require.NoError(t, err)
+	for event := range events {
+		require.NoError(t, event.Err)
+	}
 
 	importer := &githubImporter{}
 	err = importer.Init(ctx, backend, core.Configuration{
@@ -236,7 +242,7 @@ func TestGithubPushPull(t *testing.T) {
 		require.NoError(t, result.Err)
 	}
 
-	require.Len(t, backendTwo.AllBugsIds(), len(tests))
+	require.Len(t, backendTwo.Bugs().AllIds(), len(tests))
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
@@ -261,7 +267,7 @@ func TestGithubPushPull(t *testing.T) {
 			require.True(t, ok)
 
 			// retrieve bug from backendTwo
-			importedBug, err := backendTwo.ResolveBugCreateMetadata(metaKeyGithubId, bugGithubID)
+			importedBug, err := backendTwo.Bugs().ResolveBugCreateMetadata(metaKeyGithubId, bugGithubID)
 			require.NoError(t, err)
 
 			// verify bug have same number of original operations

bridge/github/import.go πŸ”—

@@ -10,7 +10,6 @@ import (
 	"github.com/MichaelMure/git-bug/bridge/core"
 	"github.com/MichaelMure/git-bug/bridge/core/auth"
 	"github.com/MichaelMure/git-bug/cache"
-	"github.com/MichaelMure/git-bug/entities/bug"
 	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/util/text"
 )
@@ -183,7 +182,7 @@ func (gi *githubImporter) ensureIssue(ctx context.Context, repo *cache.RepoCache
 	}
 
 	// resolve bug
-	b, err := repo.ResolveBugMatcher(func(excerpt *cache.BugExcerpt) bool {
+	b, err := repo.Bugs().ResolveMatcher(func(excerpt *cache.BugExcerpt) bool {
 		return excerpt.CreateMetadata[metaKeyGithubUrl] == issue.Url.String() &&
 			excerpt.CreateMetadata[metaKeyGithubId] == parseId(issue.Id)
 	})
@@ -213,7 +212,7 @@ func (gi *githubImporter) ensureIssue(ctx context.Context, repo *cache.RepoCache
 	}
 
 	// create bug
-	b, _, err = repo.NewBugRaw(
+	b, _, err = repo.Bugs().NewRaw(
 		author,
 		issue.CreatedAt.Unix(),
 		text.CleanupOneLine(title), // TODO: this is the *current* title, not the original one
@@ -498,7 +497,7 @@ func (gi *githubImporter) ensurePerson(ctx context.Context, repo *cache.RepoCach
 	}
 
 	// Look first in the cache
-	i, err := repo.ResolveIdentityImmutableMetadata(metaKeyGithubLogin, string(actor.Login))
+	i, err := repo.Identities().ResolveIdentityImmutableMetadata(metaKeyGithubLogin, string(actor.Login))
 	if err == nil {
 		return i, nil
 	}
@@ -531,7 +530,7 @@ func (gi *githubImporter) ensurePerson(ctx context.Context, repo *cache.RepoCach
 		name = string(actor.Login)
 	}
 
-	i, err = repo.NewIdentityRaw(
+	i, err = repo.Identities().NewRaw(
 		name,
 		email,
 		string(actor.Login),
@@ -553,7 +552,7 @@ func (gi *githubImporter) ensurePerson(ctx context.Context, repo *cache.RepoCach
 func (gi *githubImporter) getGhost(ctx context.Context, repo *cache.RepoCache) (*cache.IdentityCache, error) {
 	loginName := "ghost"
 	// Look first in the cache
-	i, err := repo.ResolveIdentityImmutableMetadata(metaKeyGithubLogin, loginName)
+	i, err := repo.Identities().ResolveIdentityImmutableMetadata(metaKeyGithubLogin, loginName)
 	if err == nil {
 		return i, nil
 	}
@@ -568,7 +567,7 @@ func (gi *githubImporter) getGhost(ctx context.Context, repo *cache.RepoCache) (
 	if user.Name != nil {
 		userName = string(*user.Name)
 	}
-	return repo.NewIdentityRaw(
+	return repo.Identities().NewRaw(
 		userName,
 		"",
 		string(user.Login),

bridge/github/import_integration_test.go πŸ”—

@@ -34,8 +34,11 @@ func TestGithubImporterIntegration(t *testing.T) {
 
 	// arrange
 	repo := repository.CreateGoGitTestRepo(t, false)
-	backend, err := cache.NewRepoCache(repo)
+	backend, buildEvents, err := cache.NewRepoCache(repo)
 	require.NoError(t, err)
+	for event := range buildEvents {
+		require.NoError(t, event.Err)
+	}
 	defer backend.Close()
 	interrupt.RegisterCleaner(backend.Close)
 	require.NoError(t, err)
@@ -48,17 +51,17 @@ func TestGithubImporterIntegration(t *testing.T) {
 	for e := range events {
 		require.NoError(t, e.Err)
 	}
-	require.Len(t, backend.AllBugsIds(), 5)
-	require.Len(t, backend.AllIdentityIds(), 2)
+	require.Len(t, backend.Bugs().AllIds(), 5)
+	require.Len(t, backend.Identities().AllIds(), 2)
 
-	b1, err := backend.ResolveBugCreateMetadata(metaKeyGithubUrl, "https://github.com/marcus/to-himself/issues/1")
+	b1, err := backend.Bugs().ResolveBugCreateMetadata(metaKeyGithubUrl, "https://github.com/marcus/to-himself/issues/1")
 	require.NoError(t, err)
 	ops1 := b1.Snapshot().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.ResolveBugCreateMetadata(metaKeyGithubUrl, "https://github.com/marcus/to-himself/issues/3")
+	b3, err := backend.Bugs().ResolveBugCreateMetadata(metaKeyGithubUrl, "https://github.com/marcus/to-himself/issues/3")
 	require.NoError(t, err)
 	ops3 := b3.Snapshot().Operations
 	require.Equal(t, "issue 3 comment 1", ops3[1].(*bug.AddCommentOperation).Message)
@@ -66,7 +69,7 @@ func TestGithubImporterIntegration(t *testing.T) {
 	require.Equal(t, []bug.Label{"bug"}, ops3[3].(*bug.LabelChangeOperation).Added)
 	require.Equal(t, "title 3, edit 1", ops3[4].(*bug.SetTitleOperation).Title)
 
-	b4, err := backend.ResolveBugCreateMetadata(metaKeyGithubUrl, "https://github.com/marcus/to-himself/issues/4")
+	b4, err := backend.Bugs().ResolveBugCreateMetadata(metaKeyGithubUrl, "https://github.com/marcus/to-himself/issues/4")
 	require.NoError(t, err)
 	ops4 := b4.Snapshot().Operations
 	require.Equal(t, "edited", ops4[1].(*bug.EditCommentOperation).Message)

bridge/github/import_test.go πŸ”—

@@ -28,8 +28,11 @@ func TestGithubImporter(t *testing.T) {
 
 	repo := repository.CreateGoGitTestRepo(t, false)
 
-	backend, err := cache.NewRepoCache(repo)
+	backend, buildEvents, err := cache.NewRepoCache(repo)
 	require.NoError(t, err)
+	for event := range buildEvents {
+		require.NoError(t, event.Err)
+	}
 
 	defer backend.Close()
 	interrupt.RegisterCleaner(backend.Close)
@@ -171,11 +174,11 @@ func TestGithubImporter(t *testing.T) {
 
 	fmt.Printf("test repository imported in %f seconds\n", time.Since(start).Seconds())
 
-	require.Len(t, backend.AllBugsIds(), len(tests))
+	require.Len(t, backend.Bugs().AllIds(), len(tests))
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			b, err := backend.ResolveBugCreateMetadata(metaKeyGithubUrl, tt.url)
+			b, err := backend.Bugs().ResolveBugCreateMetadata(metaKeyGithubUrl, tt.url)
 			require.NoError(t, err)
 
 			ops := b.Snapshot().Operations

bridge/gitlab/export.go πŸ”—

@@ -15,7 +15,6 @@ import (
 	"github.com/MichaelMure/git-bug/cache"
 	"github.com/MichaelMure/git-bug/entities/bug"
 	"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"
 )
@@ -74,7 +73,7 @@ func (ge *gitlabExporter) cacheAllClient(repo *cache.RepoCache, baseURL string)
 			continue
 		}
 
-		user, err := repo.ResolveIdentityImmutableMetadata(metaKeyGitlabLogin, login)
+		user, err := repo.Identities().ResolveIdentityImmutableMetadata(metaKeyGitlabLogin, login)
 		if entity.IsErrNotFound(err) {
 			continue
 		}
@@ -116,14 +115,14 @@ func (ge *gitlabExporter) ExportAll(ctx context.Context, repo *cache.RepoCache,
 			allIdentitiesIds = append(allIdentitiesIds, id)
 		}
 
-		allBugsIds := repo.AllBugsIds()
+		allBugsIds := repo.Bugs().AllIds()
 
 		for _, id := range allBugsIds {
 			select {
 			case <-ctx.Done():
 				return
 			default:
-				b, err := repo.ResolveBug(id)
+				b, err := repo.Bugs().Resolve(id)
 				if err != nil {
 					out <- core.NewExportError(err, id)
 					return

bridge/gitlab/export_test.go πŸ”—

@@ -37,18 +37,18 @@ type testCase struct {
 
 func testCases(t *testing.T, repo *cache.RepoCache) []*testCase {
 	// simple bug
-	simpleBug, _, err := repo.NewBug("simple bug", "new bug")
+	simpleBug, _, err := repo.Bugs().New("simple bug", "new bug")
 	require.NoError(t, err)
 
 	// bug with comments
-	bugWithComments, _, err := repo.NewBug("bug with comments", "new bug")
+	bugWithComments, _, err := repo.Bugs().New("bug with comments", "new bug")
 	require.NoError(t, err)
 
 	_, _, err = bugWithComments.AddComment("new comment")
 	require.NoError(t, err)
 
 	// bug with label changes
-	bugLabelChange, _, err := repo.NewBug("bug label change", "new bug")
+	bugLabelChange, _, err := repo.Bugs().New("bug label change", "new bug")
 	require.NoError(t, err)
 
 	_, _, err = bugLabelChange.ChangeLabels([]string{"bug"}, nil)
@@ -61,7 +61,7 @@ func testCases(t *testing.T, repo *cache.RepoCache) []*testCase {
 	require.NoError(t, err)
 
 	// bug with comments editions
-	bugWithCommentEditions, createOp, err := repo.NewBug("bug with comments editions", "new bug")
+	bugWithCommentEditions, createOp, err := repo.Bugs().New("bug with comments editions", "new bug")
 	require.NoError(t, err)
 
 	_, err = bugWithCommentEditions.EditComment(
@@ -75,7 +75,7 @@ func testCases(t *testing.T, repo *cache.RepoCache) []*testCase {
 	require.NoError(t, err)
 
 	// bug status changed
-	bugStatusChanged, _, err := repo.NewBug("bug status changed", "new bug")
+	bugStatusChanged, _, err := repo.Bugs().New("bug status changed", "new bug")
 	require.NoError(t, err)
 
 	_, err = bugStatusChanged.Close()
@@ -85,7 +85,7 @@ func testCases(t *testing.T, repo *cache.RepoCache) []*testCase {
 	require.NoError(t, err)
 
 	// bug title changed
-	bugTitleEdited, _, err := repo.NewBug("bug title edited", "new bug")
+	bugTitleEdited, _, err := repo.Bugs().New("bug title edited", "new bug")
 	require.NoError(t, err)
 
 	_, err = bugTitleEdited.SetTitle("bug title edited again")
@@ -147,12 +147,15 @@ func TestGitlabPushPull(t *testing.T) {
 	// create repo backend
 	repo := repository.CreateGoGitTestRepo(t, false)
 
-	backend, err := cache.NewRepoCache(repo)
+	backend, events, err := cache.NewRepoCache(repo)
 	require.NoError(t, err)
+	for event := range events {
+		require.NoError(t, event.Err)
+	}
 
 	// set author identity
 	login := "test-identity"
-	author, err := backend.NewIdentity("test identity", "test@test.org")
+	author, err := backend.Identities().New("test identity", "test@test.org")
 	require.NoError(t, err)
 	author.SetMetadata(metaKeyGitlabLogin, login)
 	err = author.Commit()
@@ -220,8 +223,11 @@ func TestGitlabPushPull(t *testing.T) {
 	repoTwo := repository.CreateGoGitTestRepo(t, false)
 
 	// create a second backend
-	backendTwo, err := cache.NewRepoCache(repoTwo)
+	backendTwo, events, err := cache.NewRepoCache(repoTwo)
 	require.NoError(t, err)
+	for event := range events {
+		require.NoError(t, event.Err)
+	}
 
 	importer := &gitlabImporter{}
 	err = importer.Init(ctx, backend, core.Configuration{
@@ -239,7 +245,7 @@ func TestGitlabPushPull(t *testing.T) {
 		require.NoError(t, result.Err)
 	}
 
-	require.Len(t, backendTwo.AllBugsIds(), len(tests))
+	require.Len(t, backendTwo.Bugs().AllIds(), len(tests))
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
@@ -264,7 +270,7 @@ func TestGitlabPushPull(t *testing.T) {
 			require.True(t, ok)
 
 			// retrieve bug from backendTwo
-			importedBug, err := backendTwo.ResolveBugCreateMetadata(metaKeyGitlabId, bugGitlabID)
+			importedBug, err := backendTwo.Bugs().ResolveBugCreateMetadata(metaKeyGitlabId, bugGitlabID)
 			require.NoError(t, err)
 
 			// verify bug have same number of original operations

bridge/gitlab/import.go πŸ”—

@@ -11,7 +11,6 @@ import (
 	"github.com/MichaelMure/git-bug/bridge/core"
 	"github.com/MichaelMure/git-bug/bridge/core/auth"
 	"github.com/MichaelMure/git-bug/cache"
-	"github.com/MichaelMure/git-bug/entities/bug"
 	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/util/text"
 )
@@ -109,7 +108,7 @@ func (gi *gitlabImporter) ensureIssue(repo *cache.RepoCache, issue *gitlab.Issue
 	}
 
 	// resolve bug
-	b, err := repo.ResolveBugMatcher(func(excerpt *cache.BugExcerpt) bool {
+	b, err := repo.Bugs().ResolveMatcher(func(excerpt *cache.BugExcerpt) bool {
 		return excerpt.CreateMetadata[core.MetaKeyOrigin] == target &&
 			excerpt.CreateMetadata[metaKeyGitlabId] == fmt.Sprintf("%d", issue.IID) &&
 			excerpt.CreateMetadata[metaKeyGitlabBaseUrl] == gi.conf[confKeyGitlabBaseUrl] &&
@@ -123,7 +122,7 @@ func (gi *gitlabImporter) ensureIssue(repo *cache.RepoCache, issue *gitlab.Issue
 	}
 
 	// if bug was never imported, create bug
-	b, _, err = repo.NewBugRaw(
+	b, _, err = repo.Bugs().NewRaw(
 		author,
 		issue.CreatedAt.Unix(),
 		text.CleanupOneLine(issue.Title),
@@ -338,7 +337,7 @@ func (gi *gitlabImporter) ensureIssueEvent(repo *cache.RepoCache, b *cache.BugCa
 
 func (gi *gitlabImporter) ensurePerson(repo *cache.RepoCache, id int) (*cache.IdentityCache, error) {
 	// Look first in the cache
-	i, err := repo.ResolveIdentityImmutableMetadata(metaKeyGitlabId, strconv.Itoa(id))
+	i, err := repo.Identities().ResolveIdentityImmutableMetadata(metaKeyGitlabId, strconv.Itoa(id))
 	if err == nil {
 		return i, nil
 	}
@@ -351,7 +350,7 @@ func (gi *gitlabImporter) ensurePerson(repo *cache.RepoCache, id int) (*cache.Id
 		return nil, err
 	}
 
-	i, err = repo.NewIdentityRaw(
+	i, err = repo.Identities().NewRaw(
 		user.Name,
 		user.PublicEmail,
 		user.Username,

bridge/gitlab/import_test.go πŸ”—

@@ -33,8 +33,11 @@ func TestGitlabImport(t *testing.T) {
 
 	repo := repository.CreateGoGitTestRepo(t, false)
 
-	backend, err := cache.NewRepoCache(repo)
+	backend, buildEvents, err := cache.NewRepoCache(repo)
 	require.NoError(t, err)
+	for event := range buildEvents {
+		require.NoError(t, event.Err)
+	}
 
 	defer backend.Close()
 	interrupt.RegisterCleaner(backend.Close)
@@ -126,11 +129,11 @@ func TestGitlabImport(t *testing.T) {
 
 	fmt.Printf("test repository imported in %f seconds\n", time.Since(start).Seconds())
 
-	require.Len(t, backend.AllBugsIds(), len(tests))
+	require.Len(t, backend.Bugs().AllIds(), len(tests))
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			b, err := backend.ResolveBugCreateMetadata(metaKeyGitlabUrl, tt.url)
+			b, err := backend.Bugs().ResolveBugCreateMetadata(metaKeyGitlabUrl, tt.url)
 			require.NoError(t, err)
 
 			ops := b.Snapshot().Operations

bridge/jira/export.go πŸ”—

@@ -14,7 +14,6 @@ import (
 	"github.com/MichaelMure/git-bug/bridge/core/auth"
 	"github.com/MichaelMure/git-bug/cache"
 	"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"
 )
@@ -102,7 +101,7 @@ func (je *jiraExporter) cacheAllClient(ctx context.Context, repo *cache.RepoCach
 			continue
 		}
 
-		user, err := repo.ResolveIdentityImmutableMetadata(metaKeyJiraLogin, login)
+		user, err := repo.Identities().ResolveIdentityImmutableMetadata(metaKeyJiraLogin, login)
 		if entity.IsErrNotFound(err) {
 			continue
 		}
@@ -146,10 +145,10 @@ func (je *jiraExporter) ExportAll(ctx context.Context, repo *cache.RepoCache, si
 			allIdentitiesIds = append(allIdentitiesIds, id)
 		}
 
-		allBugsIds := repo.AllBugsIds()
+		allBugsIds := repo.Bugs().AllIds()
 
 		for _, id := range allBugsIds {
-			b, err := repo.ResolveBug(id)
+			b, err := repo.Bugs().Resolve(id)
 			if err != nil {
 				out <- core.NewExportError(errors.Wrap(err, "can't load bug"), id)
 				return

bridge/jira/import.go πŸ”—

@@ -184,7 +184,7 @@ func (ji *jiraImporter) ImportAll(ctx context.Context, repo *cache.RepoCache, si
 // Create a bug.Person from a JIRA user
 func (ji *jiraImporter) ensurePerson(repo *cache.RepoCache, user User) (*cache.IdentityCache, error) {
 	// Look first in the cache
-	i, err := repo.ResolveIdentityImmutableMetadata(
+	i, err := repo.Identities().ResolveIdentityImmutableMetadata(
 		metaKeyJiraUser, string(user.Key))
 	if err == nil {
 		return i, nil
@@ -193,7 +193,7 @@ func (ji *jiraImporter) ensurePerson(repo *cache.RepoCache, user User) (*cache.I
 		return nil, err
 	}
 
-	i, err = repo.NewIdentityRaw(
+	i, err = repo.Identities().NewRaw(
 		user.DisplayName,
 		user.EmailAddress,
 		user.Key,
@@ -219,7 +219,7 @@ func (ji *jiraImporter) ensureIssue(repo *cache.RepoCache, issue Issue) (*cache.
 		return nil, err
 	}
 
-	b, err := repo.ResolveBugMatcher(func(excerpt *cache.BugExcerpt) bool {
+	b, err := repo.Bugs().ResolveMatcher(func(excerpt *cache.BugExcerpt) bool {
 		if _, ok := excerpt.CreateMetadata[metaKeyJiraBaseUrl]; ok &&
 			excerpt.CreateMetadata[metaKeyJiraBaseUrl] != ji.conf[confKeyBaseUrl] {
 			return false
@@ -234,7 +234,7 @@ func (ji *jiraImporter) ensureIssue(repo *cache.RepoCache, issue Issue) (*cache.
 	}
 
 	if entity.IsErrNotFound(err) {
-		b, _, err = repo.NewBugRaw(
+		b, _, err = repo.Bugs().NewRaw(
 			author,
 			issue.Fields.Created.Unix(),
 			text.CleanupOneLine(issue.Fields.Summary),

bridge/launchpad/import.go πŸ”—

@@ -7,7 +7,6 @@ import (
 
 	"github.com/MichaelMure/git-bug/bridge/core"
 	"github.com/MichaelMure/git-bug/cache"
-	"github.com/MichaelMure/git-bug/entities/bug"
 	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/util/text"
 )
@@ -23,7 +22,7 @@ func (li *launchpadImporter) Init(_ context.Context, repo *cache.RepoCache, conf
 
 func (li *launchpadImporter) ensurePerson(repo *cache.RepoCache, owner LPPerson) (*cache.IdentityCache, error) {
 	// Look first in the cache
-	i, err := repo.ResolveIdentityImmutableMetadata(metaKeyLaunchpadLogin, owner.Login)
+	i, err := repo.Identities().ResolveIdentityImmutableMetadata(metaKeyLaunchpadLogin, owner.Login)
 	if err == nil {
 		return i, nil
 	}
@@ -31,7 +30,7 @@ func (li *launchpadImporter) ensurePerson(repo *cache.RepoCache, owner LPPerson)
 		return nil, err
 	}
 
-	return repo.NewIdentityRaw(
+	return repo.Identities().NewRaw(
 		owner.Name,
 		"",
 		owner.Login,
@@ -64,7 +63,7 @@ func (li *launchpadImporter) ImportAll(ctx context.Context, repo *cache.RepoCach
 				return
 			default:
 				lpBugID := fmt.Sprintf("%d", lpBug.ID)
-				b, err := repo.ResolveBugMatcher(func(excerpt *cache.BugExcerpt) bool {
+				b, err := repo.Bugs().ResolveMatcher(func(excerpt *cache.BugExcerpt) bool {
 					return excerpt.CreateMetadata[core.MetaKeyOrigin] == target &&
 						excerpt.CreateMetadata[metaKeyLaunchpadID] == lpBugID
 				})
@@ -81,7 +80,7 @@ func (li *launchpadImporter) ImportAll(ctx context.Context, repo *cache.RepoCach
 
 				if entity.IsErrNotFound(err) {
 					createdAt, _ := time.Parse(time.RFC3339, lpBug.CreatedAt)
-					b, _, err = repo.NewBugRaw(
+					b, _, err = repo.Bugs().NewRaw(
 						owner,
 						createdAt.Unix(),
 						text.CleanupOneLine(lpBug.Title),

cache/bug_excerpt.go πŸ”—

@@ -15,6 +15,8 @@ func init() {
 	gob.Register(BugExcerpt{})
 }
 
+var _ Excerpt = &BugExcerpt{}
+
 // BugExcerpt hold a subset of the bug values to be able to sort and filter bugs
 // efficiently without having to read and compile each raw bugs.
 type BugExcerpt struct {
@@ -36,7 +38,8 @@ type BugExcerpt struct {
 	CreateMetadata map[string]string
 }
 
-func NewBugExcerpt(b bug.Interface, snap *bug.Snapshot) *BugExcerpt {
+func NewBugExcerpt(b *BugCache) *BugExcerpt {
+	snap := b.Snapshot()
 	participantsIds := make([]entity.Id, 0, len(snap.Participants))
 	for _, participant := range snap.Participants {
 		participantsIds = append(participantsIds, participant.Id())
@@ -66,6 +69,10 @@ func NewBugExcerpt(b bug.Interface, snap *bug.Snapshot) *BugExcerpt {
 	return e
 }
 
+func (b *BugExcerpt) setId(id entity.Id) {
+	b.id = id
+}
+
 func (b *BugExcerpt) Id() entity.Id {
 	return b.id
 }

cache/bug_subcache.go πŸ”—

@@ -24,11 +24,7 @@ func NewRepoCacheBug(repo repository.ClockedRepo,
 		return NewBugCache(b, repo, getUserIdentity, entityUpdated)
 	}
 
-	makeExcerpt := func(b *bug.Bug) *BugExcerpt {
-		return NewBugExcerpt(b, b.Compile())
-	}
-
-	makeIndex := func(b *BugCache) []string {
+	makeIndexData := func(b *BugCache) []string {
 		snap := b.Snapshot()
 		var res []string
 		for _, comment := range snap.Comments {
@@ -38,9 +34,16 @@ func NewRepoCacheBug(repo repository.ClockedRepo,
 		return res
 	}
 
+	actions := Actions[*bug.Bug]{
+		ReadWithResolver:    bug.ReadWithResolver,
+		ReadAllWithResolver: bug.ReadAllWithResolver,
+		Remove:              bug.Remove,
+		MergeAll:            bug.MergeAll,
+	}
+
 	sc := NewSubCache[*bug.Bug, *BugExcerpt, *BugCache](
 		repo, resolvers, getUserIdentity,
-		makeCached, makeExcerpt, makeIndex,
+		makeCached, NewBugExcerpt, makeIndexData, actions,
 		"bug", "bugs",
 		formatVersion, defaultMaxLoadedBugs,
 	)
@@ -104,8 +107,8 @@ func (c *RepoCacheBug) ResolveComment(prefix string) (*BugCache, entity.Combined
 	return matchingBug, matchingCommentId, nil
 }
 
-// QueryBugs return the id of all Bug matching the given Query
-func (c *RepoCacheBug) QueryBugs(q *query.Query) ([]entity.Id, error) {
+// Query return the id of all Bug matching the given Query
+func (c *RepoCacheBug) Query(q *query.Query) ([]entity.Id, error) {
 	c.mu.RLock()
 	defer c.mu.RUnlock()
 
@@ -140,7 +143,7 @@ func (c *RepoCacheBug) QueryBugs(q *query.Query) ([]entity.Id, error) {
 	}
 
 	for _, excerpt := range foundBySearch {
-		if matcher.Match(excerpt, c) {
+		if matcher.Match(excerpt, c.resolvers()) {
 			filtered = append(filtered, excerpt)
 		}
 	}

cache/cached.go πŸ”—

@@ -3,10 +3,10 @@ package cache
 import (
 	"sync"
 
-	"github.com/MichaelMure/git-bug/entities/bug"
 	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/entity/dag"
 	"github.com/MichaelMure/git-bug/repository"
+	"github.com/MichaelMure/git-bug/util/lamport"
 )
 
 // type withSnapshot[SnapT dag.Snapshot, OpT dag.OperationWithApply[SnapT]] struct {
@@ -97,7 +97,7 @@ func (e *CachedEntityBase[SnapT, OpT]) ResolveOperationWithMetadata(key string,
 	}
 
 	if len(matching) > 1 {
-		return "", bug.NewErrMultipleMatchOp(matching)
+		return "", entity.NewErrMultipleMatch("operation", matching)
 	}
 
 	return matching[0], nil
@@ -136,3 +136,15 @@ func (e *CachedEntityBase[SnapT, OpT]) NeedCommit() bool {
 	defer e.mu.RUnlock()
 	return e.entity.NeedCommit()
 }
+
+func (e *CachedEntityBase[SnapT, OpT]) CreateLamportTime() lamport.Time {
+	return e.entity.CreateLamportTime()
+}
+
+func (e *CachedEntityBase[SnapT, OpT]) EditLamportTime() lamport.Time {
+	return e.entity.EditLamportTime()
+}
+
+func (e *CachedEntityBase[SnapT, OpT]) FirstOp() OpT {
+	return e.entity.FirstOp()
+}

cache/filter.go πŸ”—

@@ -8,28 +8,22 @@ import (
 	"github.com/MichaelMure/git-bug/query"
 )
 
-// resolver has the resolving functions needed by filters.
-// This exists mainly to go through the functions of the cache with proper locking.
-type resolver interface {
-	ResolveIdentityExcerpt(id entity.Id) (*IdentityExcerpt, error)
-}
-
 // Filter is a predicate that match a subset of bugs
-type Filter func(excerpt *BugExcerpt, resolver resolver) bool
+type Filter func(excerpt *BugExcerpt, resolvers entity.Resolvers) bool
 
 // StatusFilter return a Filter that match a bug status
 func StatusFilter(status common.Status) Filter {
-	return func(excerpt *BugExcerpt, resolver resolver) bool {
+	return func(excerpt *BugExcerpt, resolvers entity.Resolvers) bool {
 		return excerpt.Status == status
 	}
 }
 
 // AuthorFilter return a Filter that match a bug author
 func AuthorFilter(query string) Filter {
-	return func(excerpt *BugExcerpt, resolver resolver) bool {
+	return func(excerpt *BugExcerpt, resolvers entity.Resolvers) bool {
 		query = strings.ToLower(query)
 
-		author, err := resolver.ResolveIdentityExcerpt(excerpt.AuthorId)
+		author, err := entity.Resolve[*IdentityExcerpt](resolvers, excerpt.AuthorId)
 		if err != nil {
 			panic(err)
 		}
@@ -40,7 +34,7 @@ func AuthorFilter(query string) Filter {
 
 // MetadataFilter return a Filter that match a bug metadata at creation time
 func MetadataFilter(pair query.StringPair) Filter {
-	return func(excerpt *BugExcerpt, resolver resolver) bool {
+	return func(excerpt *BugExcerpt, resolvers entity.Resolvers) bool {
 		if value, ok := excerpt.CreateMetadata[pair.Key]; ok {
 			return value == pair.Value
 		}
@@ -50,7 +44,7 @@ func MetadataFilter(pair query.StringPair) Filter {
 
 // LabelFilter return a Filter that match a label
 func LabelFilter(label string) Filter {
-	return func(excerpt *BugExcerpt, resolver resolver) bool {
+	return func(excerpt *BugExcerpt, resolvers entity.Resolvers) bool {
 		for _, l := range excerpt.Labels {
 			if string(l) == label {
 				return true
@@ -62,11 +56,11 @@ func LabelFilter(label string) Filter {
 
 // ActorFilter return a Filter that match a bug actor
 func ActorFilter(query string) Filter {
-	return func(excerpt *BugExcerpt, resolver resolver) bool {
+	return func(excerpt *BugExcerpt, resolvers entity.Resolvers) bool {
 		query = strings.ToLower(query)
 
 		for _, id := range excerpt.Actors {
-			identityExcerpt, err := resolver.ResolveIdentityExcerpt(id)
+			identityExcerpt, err := entity.Resolve[*IdentityExcerpt](resolvers, id)
 			if err != nil {
 				panic(err)
 			}
@@ -81,11 +75,11 @@ func ActorFilter(query string) Filter {
 
 // ParticipantFilter return a Filter that match a bug participant
 func ParticipantFilter(query string) Filter {
-	return func(excerpt *BugExcerpt, resolver resolver) bool {
+	return func(excerpt *BugExcerpt, resolvers entity.Resolvers) bool {
 		query = strings.ToLower(query)
 
 		for _, id := range excerpt.Participants {
-			identityExcerpt, err := resolver.ResolveIdentityExcerpt(id)
+			identityExcerpt, err := entity.Resolve[*IdentityExcerpt](resolvers, id)
 			if err != nil {
 				panic(err)
 			}
@@ -100,7 +94,7 @@ func ParticipantFilter(query string) Filter {
 
 // TitleFilter return a Filter that match if the title contains the given query
 func TitleFilter(query string) Filter {
-	return func(excerpt *BugExcerpt, resolver resolver) bool {
+	return func(excerpt *BugExcerpt, resolvers entity.Resolvers) bool {
 		return strings.Contains(
 			strings.ToLower(excerpt.Title),
 			strings.ToLower(query),
@@ -110,7 +104,7 @@ func TitleFilter(query string) Filter {
 
 // NoLabelFilter return a Filter that match the absence of labels
 func NoLabelFilter() Filter {
-	return func(excerpt *BugExcerpt, resolver resolver) bool {
+	return func(excerpt *BugExcerpt, resolvers entity.Resolvers) bool {
 		return len(excerpt.Labels) == 0
 	}
 }
@@ -161,36 +155,36 @@ func compileMatcher(filters query.Filters) *Matcher {
 }
 
 // Match check if a bug match the set of filters
-func (f *Matcher) Match(excerpt *BugExcerpt, resolver resolver) bool {
-	if match := f.orMatch(f.Status, excerpt, resolver); !match {
+func (f *Matcher) Match(excerpt *BugExcerpt, resolvers entity.Resolvers) bool {
+	if match := f.orMatch(f.Status, excerpt, resolvers); !match {
 		return false
 	}
 
-	if match := f.orMatch(f.Author, excerpt, resolver); !match {
+	if match := f.orMatch(f.Author, excerpt, resolvers); !match {
 		return false
 	}
 
-	if match := f.orMatch(f.Metadata, excerpt, resolver); !match {
+	if match := f.orMatch(f.Metadata, excerpt, resolvers); !match {
 		return false
 	}
 
-	if match := f.orMatch(f.Participant, excerpt, resolver); !match {
+	if match := f.orMatch(f.Participant, excerpt, resolvers); !match {
 		return false
 	}
 
-	if match := f.orMatch(f.Actor, excerpt, resolver); !match {
+	if match := f.orMatch(f.Actor, excerpt, resolvers); !match {
 		return false
 	}
 
-	if match := f.andMatch(f.Label, excerpt, resolver); !match {
+	if match := f.andMatch(f.Label, excerpt, resolvers); !match {
 		return false
 	}
 
-	if match := f.andMatch(f.NoFilters, excerpt, resolver); !match {
+	if match := f.andMatch(f.NoFilters, excerpt, resolvers); !match {
 		return false
 	}
 
-	if match := f.andMatch(f.Title, excerpt, resolver); !match {
+	if match := f.andMatch(f.Title, excerpt, resolvers); !match {
 		return false
 	}
 
@@ -198,28 +192,28 @@ func (f *Matcher) Match(excerpt *BugExcerpt, resolver resolver) bool {
 }
 
 // Check if any of the filters provided match the bug
-func (*Matcher) orMatch(filters []Filter, excerpt *BugExcerpt, resolver resolver) bool {
+func (*Matcher) orMatch(filters []Filter, excerpt *BugExcerpt, resolvers entity.Resolvers) bool {
 	if len(filters) == 0 {
 		return true
 	}
 
 	match := false
 	for _, f := range filters {
-		match = match || f(excerpt, resolver)
+		match = match || f(excerpt, resolvers)
 	}
 
 	return match
 }
 
 // Check if all the filters provided match the bug
-func (*Matcher) andMatch(filters []Filter, excerpt *BugExcerpt, resolver resolver) bool {
+func (*Matcher) andMatch(filters []Filter, excerpt *BugExcerpt, resolvers entity.Resolvers) bool {
 	if len(filters) == 0 {
 		return true
 	}
 
 	match := true
 	for _, f := range filters {
-		match = match && f(excerpt, resolver)
+		match = match && f(excerpt, resolvers)
 	}
 
 	return match

cache/identity_excerpt.go πŸ”—

@@ -5,7 +5,6 @@ import (
 	"fmt"
 	"strings"
 
-	"github.com/MichaelMure/git-bug/entities/identity"
 	"github.com/MichaelMure/git-bug/entity"
 )
 
@@ -14,6 +13,8 @@ func init() {
 	gob.Register(IdentityExcerpt{})
 }
 
+var _ Excerpt = &IdentityExcerpt{}
+
 // IdentityExcerpt hold a subset of the identity values to be able to sort and
 // filter identities efficiently without having to read and compile each raw
 // identity.
@@ -25,7 +26,7 @@ type IdentityExcerpt struct {
 	ImmutableMetadata map[string]string
 }
 
-func NewIdentityExcerpt(i *identity.Identity) *IdentityExcerpt {
+func NewIdentityExcerpt(i *IdentityCache) *IdentityExcerpt {
 	return &IdentityExcerpt{
 		id:                i.Id(),
 		Name:              i.Name(),
@@ -34,6 +35,10 @@ func NewIdentityExcerpt(i *identity.Identity) *IdentityExcerpt {
 	}
 }
 
+func (i *IdentityExcerpt) setId(id entity.Id) {
+	i.id = id
+}
+
 func (i *IdentityExcerpt) Id() entity.Id {
 	return i.id
 }

cache/identity_subcache.go πŸ”—

@@ -20,18 +20,34 @@ func NewRepoCacheIdentity(repo repository.ClockedRepo,
 		return NewIdentityCache(i, repo, entityUpdated)
 	}
 
-	makeExcerpt := func(i *identity.Identity) *IdentityExcerpt {
-		return NewIdentityExcerpt(i)
-	}
-
 	makeIndex := func(i *IdentityCache) []string {
 		// no indexing
 		return nil
 	}
 
+	// TODO: this is terribly ugly, but we are currently stuck with the fact that identities are NOT using the fancy dag framework.
+	//   This lead to various complication here and there to handle entities generically, and avoid large code duplication.
+	//   TL;DR: something has to give, and this is the less ugly solution I found. This "normalize" identities as just another "dag framework"
+	//   entity. Ideally identities would be converted to the dag framework, but right now that could lead to potential attack: if an old
+	//   private key is leaked, it would be possible to craft a legal identity update that take over the most recent version. While this is
+	//   meaningless in the case of a normal entity, it's really an issues for identities.
+
+	actions := Actions[*identity.Identity]{
+		ReadWithResolver: func(repo repository.ClockedRepo, resolvers entity.Resolvers, id entity.Id) (*identity.Identity, error) {
+			return identity.ReadLocal(repo, id)
+		},
+		ReadAllWithResolver: func(repo repository.ClockedRepo, resolvers entity.Resolvers) <-chan entity.StreamedEntity[*identity.Identity] {
+			return identity.ReadAllLocal(repo)
+		},
+		Remove: identity.RemoveIdentity,
+		MergeAll: func(repo repository.ClockedRepo, resolvers entity.Resolvers, remote string, mergeAuthor identity.Interface) <-chan entity.MergeResult {
+			return identity.MergeAll(repo, remote)
+		},
+	}
+
 	sc := NewSubCache[*identity.Identity, *IdentityExcerpt, *IdentityCache](
 		repo, resolvers, getUserIdentity,
-		makeCached, makeExcerpt, makeIndex,
+		makeCached, NewIdentityExcerpt, makeIndex, actions,
 		"identity", "identities",
 		formatVersion, defaultMaxLoadedBugs,
 	)
@@ -47,32 +63,32 @@ func (c *RepoCacheIdentity) ResolveIdentityImmutableMetadata(key string, value s
 	})
 }
 
-func (c *RepoCacheIdentity) NewIdentityFromGitUser() (*IdentityCache, error) {
-	return c.NewIdentityFromGitUserRaw(nil)
+// New create a new identity
+// The new identity is written in the repository (commit)
+func (c *RepoCacheIdentity) New(name string, email string) (*IdentityCache, error) {
+	return c.NewRaw(name, email, "", "", nil, nil)
 }
 
-func (c *RepoCacheIdentity) NewIdentityFromGitUserRaw(metadata map[string]string) (*IdentityCache, error) {
-	i, err := identity.NewFromGitUser(c.repo)
+// NewFull create a new identity
+// The new identity is written in the repository (commit)
+func (c *RepoCacheIdentity) NewFull(name string, email string, login string, avatarUrl string, keys []*identity.Key) (*IdentityCache, error) {
+	return c.NewRaw(name, email, login, avatarUrl, keys, nil)
+}
+
+func (c *RepoCacheIdentity) NewRaw(name string, email string, login string, avatarUrl string, keys []*identity.Key, metadata map[string]string) (*IdentityCache, error) {
+	i, err := identity.NewIdentityFull(c.repo, name, email, login, avatarUrl, keys)
 	if err != nil {
 		return nil, err
 	}
 	return c.finishIdentity(i, metadata)
 }
 
-// NewIdentity create a new identity
-// The new identity is written in the repository (commit)
-func (c *RepoCacheIdentity) NewIdentity(name string, email string) (*IdentityCache, error) {
-	return c.NewIdentityRaw(name, email, "", "", nil, nil)
-}
-
-// NewIdentityFull create a new identity
-// The new identity is written in the repository (commit)
-func (c *RepoCacheIdentity) NewIdentityFull(name string, email string, login string, avatarUrl string, keys []*identity.Key) (*IdentityCache, error) {
-	return c.NewIdentityRaw(name, email, login, avatarUrl, keys, nil)
+func (c *RepoCacheIdentity) NewFromGitUser() (*IdentityCache, error) {
+	return c.NewFromGitUserRaw(nil)
 }
 
-func (c *RepoCacheIdentity) NewIdentityRaw(name string, email string, login string, avatarUrl string, keys []*identity.Key, metadata map[string]string) (*IdentityCache, error) {
-	i, err := identity.NewIdentityFull(c.repo, name, email, login, avatarUrl, keys)
+func (c *RepoCacheIdentity) NewFromGitUserRaw(metadata map[string]string) (*IdentityCache, error) {
+	i, err := identity.NewFromGitUser(c.repo)
 	if err != nil {
 		return nil, err
 	}

cache/multi_repo_cache.go πŸ”—

@@ -21,25 +21,25 @@ func NewMultiRepoCache() *MultiRepoCache {
 }
 
 // RegisterRepository register a named repository. Use this for multi-repo setup
-func (c *MultiRepoCache) RegisterRepository(ref string, repo repository.ClockedRepo) (*RepoCache, error) {
-	r, err := NewRepoCache(repo)
+func (c *MultiRepoCache) RegisterRepository(ref string, repo repository.ClockedRepo) (*RepoCache, chan BuildEvent, error) {
+	r, events, err := NewRepoCache(repo)
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 
 	c.repos[ref] = r
-	return r, nil
+	return r, events, nil
 }
 
 // RegisterDefaultRepository register an unnamed repository. Use this for mono-repo setup
-func (c *MultiRepoCache) RegisterDefaultRepository(repo repository.ClockedRepo) (*RepoCache, error) {
-	r, err := NewRepoCache(repo)
+func (c *MultiRepoCache) RegisterDefaultRepository(repo repository.ClockedRepo) (*RepoCache, chan BuildEvent, error) {
+	r, events, err := NewRepoCache(repo)
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 
 	c.repos[defaultRepoName] = r
-	return r, nil
+	return r, events, nil
 }
 
 // DefaultRepo retrieve the default repository

cache/repo_cache.go πŸ”—

@@ -30,8 +30,10 @@ var _ repository.RepoKeyring = &RepoCache{}
 type cacheMgmt interface {
 	Typename() string
 	Load() error
-	Write() error
 	Build() error
+	SetCacheSize(size int)
+	MergeAll(remote string) <-chan entity.MergeResult
+	GetNamespace() string
 	Close() error
 }
 
@@ -86,18 +88,24 @@ func NewNamedRepoCache(r repository.ClockedRepo, name string) (*RepoCache, chan
 	c.subcaches = append(c.subcaches, c.bugs)
 
 	c.resolvers = entity.Resolvers{
-		&IdentityCache{}: entity.ResolverFunc[*IdentityCache](c.identities.Resolve),
-		&BugCache{}:      entity.ResolverFunc[*BugCache](c.bugs.Resolve),
+		&IdentityCache{}:   entity.ResolverFunc[*IdentityCache](c.identities.Resolve),
+		&IdentityExcerpt{}: entity.ResolverFunc[*IdentityExcerpt](c.identities.ResolveExcerpt),
+		&BugCache{}:        entity.ResolverFunc[*BugCache](c.bugs.Resolve),
+		&BugExcerpt{}:      entity.ResolverFunc[*BugExcerpt](c.bugs.ResolveExcerpt),
 	}
 
 	err := c.lock()
 	if err != nil {
-		return &RepoCache{}, nil, err
+		closed := make(chan BuildEvent)
+		close(closed)
+		return &RepoCache{}, closed, err
 	}
 
 	err = c.load()
 	if err == nil {
-		return c, nil, nil
+		closed := make(chan BuildEvent)
+		close(closed)
+		return c, closed, nil
 	}
 
 	// Cache is either missing, broken or outdated. Rebuilding.
@@ -122,8 +130,9 @@ func (c *RepoCache) getResolvers() entity.Resolvers {
 
 // setCacheSize change the maximum number of loaded bugs
 func (c *RepoCache) setCacheSize(size int) {
-	c.maxLoadedBugs = size
-	c.evictIfNeeded()
+	for _, subcache := range c.subcaches {
+		subcache.SetCacheSize(size)
+	}
 }
 
 // load will try to read from the disk all the cache files
@@ -135,15 +144,6 @@ func (c *RepoCache) load() error {
 	return errWait.Wait()
 }
 
-// write will serialize on disk all the cache files
-func (c *RepoCache) write() error {
-	var errWait multierr.ErrWaitGroup
-	for _, mgmt := range c.subcaches {
-		errWait.Go(mgmt.Write)
-	}
-	return errWait.Wait()
-}
-
 func (c *RepoCache) lock() error {
 	err := repoIsAvailable(c.repo)
 	if err != nil {
@@ -190,10 +190,14 @@ const (
 	BuildEventFinished
 )
 
+// BuildEvent carry an event happening during the cache build process.
 type BuildEvent struct {
+	// Err carry an error if the build process failed. If set, no other field matter.
+	Err error
+	// Typename is the name of the entity of which the event relate to.
 	Typename string
-	Event    BuildEventType
-	Err      error
+	// Event is the type of the event.
+	Event BuildEventType
 }
 
 func (c *RepoCache) buildCache() chan BuildEvent {
@@ -221,15 +225,6 @@ func (c *RepoCache) buildCache() chan BuildEvent {
 					return
 				}
 
-				err = subcache.Write()
-				if err != nil {
-					out <- BuildEvent{
-						Typename: subcache.Typename(),
-						Err:      err,
-					}
-					return
-				}
-
 				out <- BuildEvent{
 					Typename: subcache.Typename(),
 					Event:    BuildEventFinished,

cache/repo_cache_common.go πŸ”—

@@ -1,10 +1,11 @@
 package cache
 
 import (
+	"sync"
+
 	"github.com/go-git/go-billy/v5"
 	"github.com/pkg/errors"
 
-	"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/repository"
@@ -72,76 +73,40 @@ func (c *RepoCache) StoreData(data []byte) (repository.Hash, error) {
 // Fetch retrieve updates from a remote
 // This does not change the local bugs or identities state
 func (c *RepoCache) Fetch(remote string) (string, error) {
-	stdout1, err := identity.Fetch(c.repo, remote)
-	if err != nil {
-		return stdout1, err
-	}
-
-	stdout2, err := bug.Fetch(c.repo, remote)
-	if err != nil {
-		return stdout2, err
+	prefixes := make([]string, len(c.subcaches))
+	for i, subcache := range c.subcaches {
+		prefixes[i] = subcache.GetNamespace()
 	}
 
-	return stdout1 + stdout2, nil
+	// fetch everything at once, to have a single auth step if required.
+	return c.repo.FetchRefs(remote, prefixes...)
 }
 
 // MergeAll will merge all the available remote bug and identities
 func (c *RepoCache) MergeAll(remote string) <-chan entity.MergeResult {
 	out := make(chan entity.MergeResult)
 
-	// Intercept merge results to update the cache properly
+	dependency := [][]cacheMgmt{
+		{c.identities},
+		{c.bugs},
+	}
+
+	// run MergeAll according to entities dependencies and merge the results
 	go func() {
 		defer close(out)
 
-		author, err := c.GetUserIdentity()
-		if err != nil {
-			out <- entity.NewMergeError(err, "")
-			return
-		}
-
-		results := identity.MergeAll(c.repo, remote)
-		for result := range results {
-			out <- result
-
-			if result.Err != nil {
-				continue
-			}
-
-			switch result.Status {
-			case entity.MergeStatusNew, entity.MergeStatusUpdated:
-				i := result.Entity.(*identity.Identity)
-				c.muIdentity.Lock()
-				c.identitiesExcerpts[result.Id] = NewIdentityExcerpt(i)
-				c.muIdentity.Unlock()
+		for _, subcaches := range dependency {
+			var wg sync.WaitGroup
+			for _, subcache := range subcaches {
+				wg.Add(1)
+				go func(subcache cacheMgmt) {
+					for res := range subcache.MergeAll(remote) {
+						out <- res
+					}
+					wg.Done()
+				}(subcache)
 			}
-		}
-
-		results = bug.MergeAll(c.repo, c.resolvers, remote, author)
-		for result := range results {
-			out <- result
-
-			if result.Err != nil {
-				continue
-			}
-
-			// TODO: have subcache do the merging?
-			switch result.Status {
-			case entity.MergeStatusNew:
-				b := result.Entity.(*bug.Bug)
-				_, err := c.bugs.add(b)
-			case entity.MergeStatusUpdated:
-				_, err := c.bugs.entityUpdated(b)
-				snap := b.Compile()
-				c.muBug.Lock()
-				c.bugExcerpts[result.Id] = NewBugExcerpt(b, snap)
-				c.muBug.Unlock()
-			}
-		}
-
-		err = c.write()
-		if err != nil {
-			out <- entity.NewMergeError(err, "")
-			return
+			wg.Wait()
 		}
 	}()
 
@@ -150,17 +115,13 @@ func (c *RepoCache) MergeAll(remote string) <-chan entity.MergeResult {
 
 // Push update a remote with the local changes
 func (c *RepoCache) Push(remote string) (string, error) {
-	stdout1, err := identity.Push(c.repo, remote)
-	if err != nil {
-		return stdout1, err
-	}
-
-	stdout2, err := bug.Push(c.repo, remote)
-	if err != nil {
-		return stdout2, err
+	prefixes := make([]string, len(c.subcaches))
+	for i, subcache := range c.subcaches {
+		prefixes[i] = subcache.GetNamespace()
 	}
 
-	return stdout1 + stdout2, nil
+	// push everything at once, to have a single auth step if required
+	return c.repo.PushRefs(remote, prefixes...)
 }
 
 // Pull will do a Fetch + MergeAll

cache/repo_cache_test.go πŸ”—

@@ -8,20 +8,27 @@ import (
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 
-	"github.com/MichaelMure/git-bug/entities/bug"
 	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/query"
 	"github.com/MichaelMure/git-bug/repository"
 )
 
+func noBuildEventErrors(t *testing.T, c chan BuildEvent) {
+	t.Helper()
+	for event := range c {
+		require.NoError(t, event.Err)
+	}
+}
+
 func TestCache(t *testing.T) {
 	repo := repository.CreateGoGitTestRepo(t, false)
 
-	cache, err := NewRepoCache(repo)
+	cache, events, err := NewRepoCache(repo)
+	noBuildEventErrors(t, events)
 	require.NoError(t, err)
 
 	// Create, set and get user identity
-	iden1, err := cache.NewIdentity("RenΓ© Descartes", "rene@descartes.fr")
+	iden1, err := cache.Identities().New("RenΓ© Descartes", "rene@descartes.fr")
 	require.NoError(t, err)
 	err = cache.SetUserIdentity(iden1)
 	require.NoError(t, err)
@@ -30,102 +37,105 @@ func TestCache(t *testing.T) {
 	require.Equal(t, iden1.Id(), userIden.Id())
 
 	// it's possible to create two identical identities
-	iden2, err := cache.NewIdentity("RenΓ© Descartes", "rene@descartes.fr")
+	iden2, err := cache.Identities().New("RenΓ© Descartes", "rene@descartes.fr")
 	require.NoError(t, err)
 
 	// Two identical identities yield a different id
 	require.NotEqual(t, iden1.Id(), iden2.Id())
 
 	// There is now two identities in the cache
-	require.Len(t, cache.AllIdentityIds(), 2)
-	require.Len(t, cache.identitiesExcerpts, 2)
-	require.Len(t, cache.identities, 2)
+	require.Len(t, cache.Identities().AllIds(), 2)
+	require.Len(t, cache.identities.excerpts, 2)
+	require.Len(t, cache.identities.cached, 2)
 
 	// Create a bug
-	bug1, _, err := cache.NewBug("title", "message")
+	bug1, _, err := cache.Bugs().New("title", "message")
 	require.NoError(t, err)
 
 	// It's possible to create two identical bugs
-	bug2, _, err := cache.NewBug("title", "message")
+	bug2, _, err := cache.Bugs().New("title", "message")
 	require.NoError(t, err)
 
 	// two identical bugs yield a different id
 	require.NotEqual(t, bug1.Id(), bug2.Id())
 
 	// There is now two bugs in the cache
-	require.Len(t, cache.AllBugsIds(), 2)
-	require.Len(t, cache.bugExcerpts, 2)
-	require.Len(t, cache.bugs, 2)
+	require.Len(t, cache.Bugs().AllIds(), 2)
+	require.Len(t, cache.bugs.excerpts, 2)
+	require.Len(t, cache.bugs.cached, 2)
 
 	// Resolving
-	_, err = cache.ResolveIdentity(iden1.Id())
+	_, err = cache.Identities().Resolve(iden1.Id())
 	require.NoError(t, err)
-	_, err = cache.ResolveIdentityExcerpt(iden1.Id())
+	_, err = cache.Identities().ResolveExcerpt(iden1.Id())
 	require.NoError(t, err)
-	_, err = cache.ResolveIdentityPrefix(iden1.Id().String()[:10])
+	_, err = cache.Identities().ResolvePrefix(iden1.Id().String()[:10])
 	require.NoError(t, err)
 
-	_, err = cache.ResolveBug(bug1.Id())
+	_, err = cache.Bugs().Resolve(bug1.Id())
 	require.NoError(t, err)
-	_, err = cache.ResolveBugExcerpt(bug1.Id())
+	_, err = cache.Bugs().ResolveExcerpt(bug1.Id())
 	require.NoError(t, err)
-	_, err = cache.ResolveBugPrefix(bug1.Id().String()[:10])
+	_, err = cache.Bugs().ResolvePrefix(bug1.Id().String()[:10])
 	require.NoError(t, err)
 
 	// Querying
 	q, err := query.Parse("status:open author:descartes sort:edit-asc")
 	require.NoError(t, err)
-	res, err := cache.QueryBugs(q)
+	res, err := cache.Bugs().Query(q)
 	require.NoError(t, err)
 	require.Len(t, res, 2)
 
 	// Close
 	require.NoError(t, cache.Close())
-	require.Empty(t, cache.bugs)
-	require.Empty(t, cache.bugExcerpts)
-	require.Empty(t, cache.identities)
-	require.Empty(t, cache.identitiesExcerpts)
+	require.Empty(t, cache.bugs.cached)
+	require.Empty(t, cache.bugs.excerpts)
+	require.Empty(t, cache.identities.cached)
+	require.Empty(t, cache.identities.excerpts)
 
 	// Reload, only excerpt are loaded, but as we need to load the identities used in the bugs
 	// to check the signatures, we also load the identity used above
-	cache, err = NewRepoCache(repo)
+	cache, events, err = NewRepoCache(repo)
+	noBuildEventErrors(t, events)
 	require.NoError(t, err)
-	require.Empty(t, cache.bugs)
-	require.Len(t, cache.identities, 1)
-	require.Len(t, cache.bugExcerpts, 2)
-	require.Len(t, cache.identitiesExcerpts, 2)
+	require.Empty(t, cache.bugs.cached)
+	require.Len(t, cache.bugs.excerpts, 2)
+	require.Len(t, cache.identities.cached, 0)
+	require.Len(t, cache.identities.excerpts, 2)
 
 	// Resolving load from the disk
-	_, err = cache.ResolveIdentity(iden1.Id())
+	_, err = cache.Identities().Resolve(iden1.Id())
 	require.NoError(t, err)
-	_, err = cache.ResolveIdentityExcerpt(iden1.Id())
+	_, err = cache.Identities().ResolveExcerpt(iden1.Id())
 	require.NoError(t, err)
-	_, err = cache.ResolveIdentityPrefix(iden1.Id().String()[:10])
+	_, err = cache.Identities().ResolvePrefix(iden1.Id().String()[:10])
 	require.NoError(t, err)
 
-	_, err = cache.ResolveBug(bug1.Id())
+	_, err = cache.Bugs().Resolve(bug1.Id())
 	require.NoError(t, err)
-	_, err = cache.ResolveBugExcerpt(bug1.Id())
+	_, err = cache.Bugs().ResolveExcerpt(bug1.Id())
 	require.NoError(t, err)
-	_, err = cache.ResolveBugPrefix(bug1.Id().String()[:10])
+	_, err = cache.Bugs().ResolvePrefix(bug1.Id().String()[:10])
 	require.NoError(t, err)
 }
 
 func TestCachePushPull(t *testing.T) {
 	repoA, repoB, _ := repository.SetupGoGitReposAndRemote(t)
 
-	cacheA, err := NewRepoCache(repoA)
+	cacheA, events, err := NewRepoCache(repoA)
+	noBuildEventErrors(t, events)
 	require.NoError(t, err)
 
-	cacheB, err := NewRepoCache(repoB)
+	cacheB, events, err := NewRepoCache(repoB)
+	noBuildEventErrors(t, events)
 	require.NoError(t, err)
 
 	// Create, set and get user identity
-	reneA, err := cacheA.NewIdentity("RenΓ© Descartes", "rene@descartes.fr")
+	reneA, err := cacheA.Identities().New("RenΓ© Descartes", "rene@descartes.fr")
 	require.NoError(t, err)
 	err = cacheA.SetUserIdentity(reneA)
 	require.NoError(t, err)
-	isaacB, err := cacheB.NewIdentity("Isaac Newton", "isaac@newton.uk")
+	isaacB, err := cacheB.Identities().New("Isaac Newton", "isaac@newton.uk")
 	require.NoError(t, err)
 	err = cacheB.SetUserIdentity(isaacB)
 	require.NoError(t, err)
@@ -137,7 +147,7 @@ func TestCachePushPull(t *testing.T) {
 	require.NoError(t, err)
 
 	// Create a bug in A
-	_, _, err = cacheA.NewBug("bug1", "message")
+	_, _, err = cacheA.Bugs().New("bug1", "message")
 	require.NoError(t, err)
 
 	// A --> remote --> B
@@ -147,17 +157,17 @@ func TestCachePushPull(t *testing.T) {
 	err = cacheB.Pull("origin")
 	require.NoError(t, err)
 
-	require.Len(t, cacheB.AllBugsIds(), 1)
+	require.Len(t, cacheB.Bugs().AllIds(), 1)
 
 	// retrieve and set identity
-	reneB, err := cacheB.ResolveIdentity(reneA.Id())
+	reneB, err := cacheB.Identities().Resolve(reneA.Id())
 	require.NoError(t, err)
 
 	err = cacheB.SetUserIdentity(reneB)
 	require.NoError(t, err)
 
 	// B --> remote --> A
-	_, _, err = cacheB.NewBug("bug2", "message")
+	_, _, err = cacheB.Bugs().New("bug2", "message")
 	require.NoError(t, err)
 
 	_, err = cacheB.Push("origin")
@@ -166,7 +176,7 @@ func TestCachePushPull(t *testing.T) {
 	err = cacheA.Pull("origin")
 	require.NoError(t, err)
 
-	require.Len(t, cacheA.AllBugsIds(), 2)
+	require.Len(t, cacheA.Bugs().AllIds(), 2)
 }
 
 func TestRemove(t *testing.T) {
@@ -180,20 +190,21 @@ func TestRemove(t *testing.T) {
 	err = repo.AddRemote("remoteB", remoteB.GetLocalRemote())
 	require.NoError(t, err)
 
-	repoCache, err := NewRepoCache(repo)
+	repoCache, events, err := NewRepoCache(repo)
+	noBuildEventErrors(t, events)
 	require.NoError(t, err)
 
-	rene, err := repoCache.NewIdentity("RenΓ© Descartes", "rene@descartes.fr")
+	rene, err := repoCache.Identities().New("RenΓ© Descartes", "rene@descartes.fr")
 	require.NoError(t, err)
 
 	err = repoCache.SetUserIdentity(rene)
 	require.NoError(t, err)
 
-	_, _, err = repoCache.NewBug("title", "message")
+	_, _, err = repoCache.Bugs().New("title", "message")
 	require.NoError(t, err)
 
 	// and one more for testing
-	b1, _, err := repoCache.NewBug("title", "message")
+	b1, _, err := repoCache.Bugs().New("title", "message")
 	require.NoError(t, err)
 
 	_, err = repoCache.Push("remoteA")
@@ -208,72 +219,73 @@ func TestRemove(t *testing.T) {
 	_, err = repoCache.Fetch("remoteB")
 	require.NoError(t, err)
 
-	err = repoCache.RemoveBug(b1.Id().String())
+	err = repoCache.Bugs().Remove(b1.Id().String())
 	require.NoError(t, err)
-	assert.Equal(t, 1, len(repoCache.bugs))
-	assert.Equal(t, 1, len(repoCache.bugExcerpts))
+	assert.Len(t, repoCache.bugs.cached, 1)
+	assert.Len(t, repoCache.bugs.excerpts, 1)
 
-	_, err = repoCache.ResolveBug(b1.Id())
-	assert.ErrorIs(t, entity.ErrNotFound{}, err)
+	_, err = repoCache.Bugs().Resolve(b1.Id())
+	assert.ErrorAs(t, entity.ErrNotFound{}, err)
 }
 
 func TestCacheEviction(t *testing.T) {
 	repo := repository.CreateGoGitTestRepo(t, false)
-	repoCache, err := NewRepoCache(repo)
+	repoCache, events, err := NewRepoCache(repo)
+	noBuildEventErrors(t, events)
 	require.NoError(t, err)
 	repoCache.setCacheSize(2)
 
-	require.Equal(t, 2, repoCache.maxLoadedBugs)
-	require.Equal(t, 0, repoCache.loadedBugs.Len())
-	require.Equal(t, 0, len(repoCache.bugs))
+	require.Equal(t, 2, repoCache.bugs.maxLoaded)
+	require.Len(t, repoCache.bugs.cached, 0)
+	require.Equal(t, repoCache.bugs.lru.Len(), 0)
 
 	// Generating some bugs
-	rene, err := repoCache.NewIdentity("RenΓ© Descartes", "rene@descartes.fr")
+	rene, err := repoCache.Identities().New("RenΓ© Descartes", "rene@descartes.fr")
 	require.NoError(t, err)
 	err = repoCache.SetUserIdentity(rene)
 	require.NoError(t, err)
 
-	bug1, _, err := repoCache.NewBug("title", "message")
+	bug1, _, err := repoCache.Bugs().New("title", "message")
 	require.NoError(t, err)
 
 	checkBugPresence(t, repoCache, bug1, true)
-	require.Equal(t, 1, repoCache.loadedBugs.Len())
-	require.Equal(t, 1, len(repoCache.bugs))
+	require.Len(t, repoCache.bugs.cached, 1)
+	require.Equal(t, 1, repoCache.bugs.lru.Len())
 
-	bug2, _, err := repoCache.NewBug("title", "message")
+	bug2, _, err := repoCache.Bugs().New("title", "message")
 	require.NoError(t, err)
 
 	checkBugPresence(t, repoCache, bug1, true)
 	checkBugPresence(t, repoCache, bug2, true)
-	require.Equal(t, 2, repoCache.loadedBugs.Len())
-	require.Equal(t, 2, len(repoCache.bugs))
+	require.Len(t, repoCache.bugs.cached, 2)
+	require.Equal(t, 2, repoCache.bugs.lru.Len())
 
 	// Number of bugs should not exceed max size of lruCache, oldest one should be evicted
-	bug3, _, err := repoCache.NewBug("title", "message")
+	bug3, _, err := repoCache.Bugs().New("title", "message")
 	require.NoError(t, err)
 
-	require.Equal(t, 2, repoCache.loadedBugs.Len())
-	require.Equal(t, 2, len(repoCache.bugs))
+	require.Len(t, repoCache.bugs.cached, 2)
+	require.Equal(t, 2, repoCache.bugs.lru.Len())
 	checkBugPresence(t, repoCache, bug1, false)
 	checkBugPresence(t, repoCache, bug2, true)
 	checkBugPresence(t, repoCache, bug3, true)
 
 	// Accessing bug should update position in lruCache and therefore it should not be evicted
-	repoCache.loadedBugs.Get(bug2.Id())
-	oldestId, _ := repoCache.loadedBugs.GetOldest()
+	repoCache.bugs.lru.Get(bug2.Id())
+	oldestId, _ := repoCache.bugs.lru.GetOldest()
 	require.Equal(t, bug3.Id(), oldestId)
 
 	checkBugPresence(t, repoCache, bug1, false)
 	checkBugPresence(t, repoCache, bug2, true)
 	checkBugPresence(t, repoCache, bug3, true)
-	require.Equal(t, 2, repoCache.loadedBugs.Len())
-	require.Equal(t, 2, len(repoCache.bugs))
+	require.Len(t, repoCache.bugs.cached, 2)
+	require.Equal(t, 2, repoCache.bugs.lru.Len())
 }
 
 func checkBugPresence(t *testing.T, cache *RepoCache, bug *BugCache, presence bool) {
 	id := bug.Id()
-	require.Equal(t, presence, cache.loadedBugs.Contains(id))
-	b, ok := cache.bugs[id]
+	require.Equal(t, presence, cache.bugs.lru.Contains(id))
+	b, ok := cache.bugs.cached[id]
 	require.Equal(t, presence, ok)
 	if ok {
 		require.Equal(t, bug, b)
@@ -287,12 +299,13 @@ func TestLongDescription(t *testing.T) {
 
 	repo := repository.CreateGoGitTestRepo(t, false)
 
-	backend, err := NewRepoCache(repo)
+	backend, events, err := NewRepoCache(repo)
+	noBuildEventErrors(t, events)
 	require.NoError(t, err)
 
-	i, err := backend.NewIdentity("RenΓ© Descartes", "rene@descartes.fr")
+	i, err := backend.Identities().New("RenΓ© Descartes", "rene@descartes.fr")
 	require.NoError(t, err)
 
-	_, _, err = backend.NewBugRaw(i, time.Now().Unix(), text, text, nil, nil)
+	_, _, err = backend.Bugs().NewRaw(i, time.Now().Unix(), text, text, nil, nil)
 	require.NoError(t, err)
 }

cache/subcache.go πŸ”—

@@ -4,18 +4,18 @@ import (
 	"bytes"
 	"encoding/gob"
 	"fmt"
-	"os"
 	"sync"
 
 	"github.com/pkg/errors"
 
-	"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/repository"
 )
 
 type Excerpt interface {
 	Id() entity.Id
+	setId(id entity.Id)
 }
 
 type CacheEntity interface {
@@ -25,15 +25,27 @@ type CacheEntity interface {
 
 type getUserIdentityFunc func() (*IdentityCache, error)
 
+// Actions expose a number of action functions on Entities, to give upper layers (cache) a way to normalize interactions.
+// Note: ideally this wouldn't exist, the cache layer would assume that everything is an entity/dag, and directly use the
+// functions from this package, but right now identities are not using that framework.
+type Actions[EntityT entity.Interface] struct {
+	ReadWithResolver    func(repo repository.ClockedRepo, resolvers entity.Resolvers, id entity.Id) (EntityT, error)
+	ReadAllWithResolver func(repo repository.ClockedRepo, resolvers entity.Resolvers) <-chan entity.StreamedEntity[EntityT]
+	Remove              func(repo repository.ClockedRepo, id entity.Id) error
+	MergeAll            func(repo repository.ClockedRepo, resolvers entity.Resolvers, remote string, mergeAuthor identity.Interface) <-chan entity.MergeResult
+}
+
+var _ cacheMgmt = &SubCache[entity.Interface, Excerpt, CacheEntity]{}
+
 type SubCache[EntityT entity.Interface, ExcerptT Excerpt, CacheT CacheEntity] struct {
 	repo      repository.ClockedRepo
 	resolvers func() entity.Resolvers
 
-	getUserIdentity  getUserIdentityFunc
-	readWithResolver func(repository.ClockedRepo, entity.Resolvers, entity.Id) (EntityT, error)
-	makeCached       func(entity EntityT, entityUpdated func(id entity.Id) error) CacheT
-	makeExcerpt      func(EntityT) ExcerptT
-	makeIndex        func(CacheT) []string
+	getUserIdentity getUserIdentityFunc
+	makeCached      func(entity EntityT, entityUpdated func(id entity.Id) error) CacheT
+	makeExcerpt     func(CacheT) ExcerptT
+	makeIndexData   func(CacheT) []string
+	actions         Actions[EntityT]
 
 	typename  string
 	namespace string
@@ -50,14 +62,19 @@ func NewSubCache[EntityT entity.Interface, ExcerptT Excerpt, CacheT CacheEntity]
 	repo repository.ClockedRepo,
 	resolvers func() entity.Resolvers, getUserIdentity getUserIdentityFunc,
 	makeCached func(entity EntityT, entityUpdated func(id entity.Id) error) CacheT,
-	makeExcerpt func(EntityT) ExcerptT,
-	makeIndex func(CacheT) []string,
+	makeExcerpt func(CacheT) ExcerptT,
+	makeIndexData func(CacheT) []string,
+	actions Actions[EntityT],
 	typename, namespace string,
 	version uint, maxLoaded int) *SubCache[EntityT, ExcerptT, CacheT] {
 	return &SubCache[EntityT, ExcerptT, CacheT]{
 		repo:            repo,
 		resolvers:       resolvers,
 		getUserIdentity: getUserIdentity,
+		makeCached:      makeCached,
+		makeExcerpt:     makeExcerpt,
+		makeIndexData:   makeIndexData,
+		actions:         actions,
 		typename:        typename,
 		namespace:       namespace,
 		version:         version,
@@ -98,6 +115,12 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) Load() error {
 		return fmt.Errorf("unknown %s cache format version %v", sc.namespace, aux.Version)
 	}
 
+	// the id is not serialized in the excerpt itself (non-exported field in go, long story ...),
+	// so we fix it here, which doubles as enforcing coherency.
+	for id, excerpt := range aux.Excerpts {
+		excerpt.setId(id)
+	}
+
 	sc.excerpts = aux.Excerpts
 
 	index, err := sc.repo.GetIndex(sc.typename)
@@ -118,7 +141,7 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) Load() error {
 }
 
 // Write will serialize on disk the entity cache file
-func (sc *SubCache[EntityT, ExcerptT, CacheT]) Write() error {
+func (sc *SubCache[EntityT, ExcerptT, CacheT]) write() error {
 	sc.mu.RLock()
 	defer sc.mu.RUnlock()
 
@@ -155,9 +178,7 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) Write() error {
 func (sc *SubCache[EntityT, ExcerptT, CacheT]) Build() error {
 	sc.excerpts = make(map[entity.Id]ExcerptT)
 
-	sc.readWithResolver
-
-	allBugs := bug.ReadAllWithResolver(c.repo, c.resolvers)
+	allEntities := sc.actions.ReadAllWithResolver(sc.repo, sc.resolvers())
 
 	index, err := sc.repo.GetIndex(sc.typename)
 	if err != nil {
@@ -172,15 +193,17 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) Build() error {
 
 	indexer, indexEnd := index.IndexBatch()
 
-	for b := range allBugs {
-		if b.Err != nil {
-			return b.Err
+	for e := range allEntities {
+		if e.Err != nil {
+			return e.Err
 		}
 
-		snap := b.Bug.Compile()
-		c.bugExcerpts[b.Bug.Id()] = NewBugExcerpt(b.Bug, snap)
+		// TODO: doesn't actually record in cache, should we?
+		cached := sc.makeCached(e.Entity, sc.entityUpdated)
+		sc.excerpts[e.Entity.Id()] = sc.makeExcerpt(cached)
 
-		if err := indexer(snap); err != nil {
+		indexData := sc.makeIndexData(cached)
+		if err := indexer(e.Entity.Id().String(), indexData); err != nil {
 			return err
 		}
 	}
@@ -190,10 +213,19 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) Build() error {
 		return err
 	}
 
-	_, _ = fmt.Fprintln(os.Stderr, "Done.")
+	err = sc.write()
+	if err != nil {
+		return err
+	}
+
 	return nil
 }
 
+func (sc *SubCache[EntityT, ExcerptT, CacheT]) SetCacheSize(size int) {
+	sc.maxLoaded = size
+	sc.evictIfNeeded()
+}
+
 func (sc *SubCache[EntityT, ExcerptT, CacheT]) Close() error {
 	sc.mu.Lock()
 	defer sc.mu.Unlock()
@@ -229,7 +261,7 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) Resolve(id entity.Id) (CacheT, er
 	}
 	sc.mu.RUnlock()
 
-	e, err := sc.readWithResolver(sc.repo, sc.resolvers(), id)
+	e, err := sc.actions.ReadWithResolver(sc.repo, sc.resolvers(), id)
 	if err != nil {
 		return *new(CacheT), err
 	}
@@ -315,8 +347,6 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) resolveMatcher(f func(ExcerptT) b
 	return matching[0], nil
 }
 
-var errNotInCache = errors.New("entity missing from cache")
-
 func (sc *SubCache[EntityT, ExcerptT, CacheT]) add(e EntityT) (CacheT, error) {
 	sc.mu.Lock()
 	if _, has := sc.cached[e.Id()]; has {
@@ -348,26 +378,74 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) Remove(prefix string) error {
 
 	sc.mu.Lock()
 
-	err = bug.Remove(c.repo, b.Id())
+	err = sc.actions.Remove(sc.repo, e.Id())
 	if err != nil {
-		c.muBug.Unlock()
-
+		sc.mu.Unlock()
 		return err
 	}
 
-	delete(c.bugs, b.Id())
-	delete(c.bugExcerpts, b.Id())
-	c.loadedBugs.Remove(b.Id())
+	delete(sc.cached, e.Id())
+	delete(sc.excerpts, e.Id())
+	sc.lru.Remove(e.Id())
+
+	sc.mu.Unlock()
+
+	return sc.write()
+}
+
+func (sc *SubCache[EntityT, ExcerptT, CacheT]) MergeAll(remote string) <-chan entity.MergeResult {
+	out := make(chan entity.MergeResult)
 
-	c.muBug.Unlock()
+	// Intercept merge results to update the cache properly
+	go func() {
+		defer close(out)
+
+		author, err := sc.getUserIdentity()
+		if err != nil {
+			out <- entity.NewMergeError(err, "")
+			return
+		}
+
+		results := sc.actions.MergeAll(sc.repo, sc.resolvers(), remote, author)
+		for result := range results {
+			out <- result
+
+			if result.Err != nil {
+				continue
+			}
+
+			switch result.Status {
+			case entity.MergeStatusNew, entity.MergeStatusUpdated:
+				e := result.Entity.(EntityT)
+
+				// TODO: doesn't actually record in cache, should we?
+				cached := sc.makeCached(e, sc.entityUpdated)
+
+				sc.mu.Lock()
+				sc.excerpts[result.Id] = sc.makeExcerpt(cached)
+				sc.mu.Unlock()
+			}
+		}
+
+		err = sc.write()
+		if err != nil {
+			out <- entity.NewMergeError(err, "")
+			return
+		}
+	}()
+
+	return out
 
-	return c.writeBugCache()
+}
+
+func (sc *SubCache[EntityT, ExcerptT, CacheT]) GetNamespace() string {
+	return sc.namespace
 }
 
 // entityUpdated is a callback to trigger when the excerpt of an entity changed
 func (sc *SubCache[EntityT, ExcerptT, CacheT]) entityUpdated(id entity.Id) error {
 	sc.mu.Lock()
-	b, ok := sc.cached[id]
+	e, ok := sc.cached[id]
 	if !ok {
 		sc.mu.Unlock()
 
@@ -376,19 +454,24 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) entityUpdated(id entity.Id) error
 		// memory and thus concurrent write.
 		// Failing immediately here is the simple and safe solution to avoid
 		// complicated data loss.
-		return errNotInCache
+		return errors.New("entity missing from cache")
 	}
 	sc.lru.Get(id)
 	// sc.excerpts[id] = bug2.NewBugExcerpt(b.bug, b.Snapshot())
-	sc.excerpts[id] = bug2.NewBugExcerpt(b.bug, b.Snapshot())
+	sc.excerpts[id] = sc.makeExcerpt(e)
 	sc.mu.Unlock()
 
-	if err := sc.addBugToSearchIndex(b.Snapshot()); err != nil {
+	index, err := sc.repo.GetIndex(sc.typename)
+	if err != nil {
+		return err
+	}
+
+	err = index.IndexOne(e.Id().String(), sc.makeIndexData(e))
+	if err != nil {
 		return err
 	}
 
-	// we only need to write the bug cache
-	return sc.Write()
+	return sc.write()
 }
 
 // evictIfNeeded will evict an entity from the cache if needed
@@ -405,7 +488,8 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) evictIfNeeded() {
 			continue
 		}
 
-		b.Lock()
+		// TODO
+		// b.Lock()
 		sc.lru.Remove(id)
 		delete(sc.cached, id)
 

commands/bridge/bridge_auth_addtoken.go πŸ”—

@@ -94,7 +94,7 @@ func runBridgeAuthAddToken(env *execenv.Env, opts bridgeAuthAddTokenOptions, arg
 	if opts.user == "" {
 		user, err = env.Backend.GetUserIdentity()
 	} else {
-		user, err = env.Backend.ResolveIdentityPrefix(opts.user)
+		user, err = env.Backend.Identities().ResolvePrefix(opts.user)
 	}
 	if err != nil {
 		return err

commands/bug/bug.go πŸ”—

@@ -142,14 +142,14 @@ func runBug(env *execenv.Env, opts bugOptions, args []string) error {
 		return err
 	}
 
-	allIds, err := env.Backend.QueryBugs(q)
+	allIds, err := env.Backend.Bugs().Query(q)
 	if err != nil {
 		return err
 	}
 
 	bugExcerpt := make([]*cache.BugExcerpt, len(allIds))
 	for i, id := range allIds {
-		b, err := env.Backend.ResolveBugExcerpt(id)
+		b, err := env.Backend.Bugs().ResolveExcerpt(id)
 		if err != nil {
 			return err
 		}
@@ -208,8 +208,8 @@ func bugsJsonFormatter(env *execenv.Env, bugExcerpts []*cache.BugExcerpt) error
 	jsonBugs := make([]JSONBugExcerpt, len(bugExcerpts))
 	for i, b := range bugExcerpts {
 		jsonBug := JSONBugExcerpt{
-			Id:         b.Id.String(),
-			HumanId:    b.Id.Human(),
+			Id:         b.Id().String(),
+			HumanId:    b.Id().Human(),
 			CreateTime: cmdjson.NewTime(b.CreateTime(), b.CreateLamportTime),
 			EditTime:   cmdjson.NewTime(b.EditTime(), b.EditLamportTime),
 			Status:     b.Status.String(),
@@ -219,7 +219,7 @@ func bugsJsonFormatter(env *execenv.Env, bugExcerpts []*cache.BugExcerpt) error
 			Metadata:   b.CreateMetadata,
 		}
 
-		author, err := env.Backend.ResolveIdentityExcerpt(b.AuthorId)
+		author, err := env.Backend.Identities().ResolveExcerpt(b.AuthorId)
 		if err != nil {
 			return err
 		}
@@ -227,7 +227,7 @@ func bugsJsonFormatter(env *execenv.Env, bugExcerpts []*cache.BugExcerpt) error
 
 		jsonBug.Actors = make([]cmdjson.Identity, len(b.Actors))
 		for i, element := range b.Actors {
-			actor, err := env.Backend.ResolveIdentityExcerpt(element)
+			actor, err := env.Backend.Identities().ResolveExcerpt(element)
 			if err != nil {
 				return err
 			}
@@ -236,7 +236,7 @@ func bugsJsonFormatter(env *execenv.Env, bugExcerpts []*cache.BugExcerpt) error
 
 		jsonBug.Participants = make([]cmdjson.Identity, len(b.Participants))
 		for i, element := range b.Participants {
-			participant, err := env.Backend.ResolveIdentityExcerpt(element)
+			participant, err := env.Backend.Identities().ResolveExcerpt(element)
 			if err != nil {
 				return err
 			}
@@ -252,7 +252,7 @@ func bugsJsonFormatter(env *execenv.Env, bugExcerpts []*cache.BugExcerpt) error
 
 func bugsCompactFormatter(env *execenv.Env, bugExcerpts []*cache.BugExcerpt) error {
 	for _, b := range bugExcerpts {
-		author, err := env.Backend.ResolveIdentityExcerpt(b.AuthorId)
+		author, err := env.Backend.Identities().ResolveExcerpt(b.AuthorId)
 		if err != nil {
 			return err
 		}
@@ -266,7 +266,7 @@ func bugsCompactFormatter(env *execenv.Env, bugExcerpts []*cache.BugExcerpt) err
 		}
 
 		env.Out.Printf("%s %s %s %s %s\n",
-			colors.Cyan(b.Id.Human()),
+			colors.Cyan(b.Id().Human()),
 			colors.Yellow(b.Status),
 			text.LeftPadMaxLine(strings.TrimSpace(b.Title), 40, 0),
 			text.LeftPadMaxLine(labelsTxt.String(), 5, 0),
@@ -278,7 +278,7 @@ func bugsCompactFormatter(env *execenv.Env, bugExcerpts []*cache.BugExcerpt) err
 
 func bugsIDFormatter(env *execenv.Env, bugExcerpts []*cache.BugExcerpt) error {
 	for _, b := range bugExcerpts {
-		env.Out.Println(b.Id.String())
+		env.Out.Println(b.Id().String())
 	}
 
 	return nil
@@ -286,7 +286,7 @@ func bugsIDFormatter(env *execenv.Env, bugExcerpts []*cache.BugExcerpt) error {
 
 func bugsDefaultFormatter(env *execenv.Env, bugExcerpts []*cache.BugExcerpt) error {
 	for _, b := range bugExcerpts {
-		author, err := env.Backend.ResolveIdentityExcerpt(b.AuthorId)
+		author, err := env.Backend.Identities().ResolveExcerpt(b.AuthorId)
 		if err != nil {
 			return err
 		}
@@ -313,7 +313,7 @@ func bugsDefaultFormatter(env *execenv.Env, bugExcerpts []*cache.BugExcerpt) err
 		}
 
 		env.Out.Printf("%s\t%s\t%s\t%s\t%s\n",
-			colors.Cyan(b.Id.Human()),
+			colors.Cyan(b.Id().Human()),
 			colors.Yellow(b.Status),
 			titleFmt+labelsFmt,
 			colors.Magenta(authorFmt),
@@ -325,7 +325,7 @@ func bugsDefaultFormatter(env *execenv.Env, bugExcerpts []*cache.BugExcerpt) err
 
 func bugsPlainFormatter(env *execenv.Env, bugExcerpts []*cache.BugExcerpt) error {
 	for _, b := range bugExcerpts {
-		env.Out.Printf("%s [%s] %s\n", b.Id.Human(), b.Status, strings.TrimSpace(b.Title))
+		env.Out.Printf("%s [%s] %s\n", b.Id().Human(), b.Status, strings.TrimSpace(b.Title))
 	}
 	return nil
 }
@@ -353,7 +353,7 @@ func bugsOrgmodeFormatter(env *execenv.Env, bugExcerpts []*cache.BugExcerpt) err
 			title = b.Title
 		}
 
-		author, err := env.Backend.ResolveIdentityExcerpt(b.AuthorId)
+		author, err := env.Backend.Identities().ResolveExcerpt(b.AuthorId)
 		if err != nil {
 			return err
 		}
@@ -370,7 +370,7 @@ func bugsOrgmodeFormatter(env *execenv.Env, bugExcerpts []*cache.BugExcerpt) err
 
 		env.Out.Printf("* %-6s %s %s %s: %s %s\n",
 			status,
-			b.Id.Human(),
+			b.Id().Human(),
 			formatTime(b.CreateTime()),
 			author.DisplayName(),
 			title,
@@ -381,26 +381,26 @@ func bugsOrgmodeFormatter(env *execenv.Env, bugExcerpts []*cache.BugExcerpt) err
 
 		env.Out.Printf("** Actors:\n")
 		for _, element := range b.Actors {
-			actor, err := env.Backend.ResolveIdentityExcerpt(element)
+			actor, err := env.Backend.Identities().ResolveExcerpt(element)
 			if err != nil {
 				return err
 			}
 
 			env.Out.Printf(": %s %s\n",
-				actor.Id.Human(),
+				actor.Id().Human(),
 				actor.DisplayName(),
 			)
 		}
 
 		env.Out.Printf("** Participants:\n")
 		for _, element := range b.Participants {
-			participant, err := env.Backend.ResolveIdentityExcerpt(element)
+			participant, err := env.Backend.Identities().ResolveExcerpt(element)
 			if err != nil {
 				return err
 			}
 
 			env.Out.Printf(": %s %s\n",
-				participant.Id.Human(),
+				participant.Id().Human(),
 				participant.DisplayName(),
 			)
 		}

commands/bug/bug_comment_edit.go πŸ”—

@@ -41,7 +41,7 @@ func newBugCommentEditCommand() *cobra.Command {
 }
 
 func runBugCommentEdit(env *execenv.Env, opts bugCommentEditOptions, args []string) error {
-	b, commentId, err := env.Backend.ResolveComment(args[0])
+	b, commentId, err := env.Backend.Bugs().ResolveComment(args[0])
 	if err != nil {
 		return err
 	}

commands/bug/bug_new.go πŸ”—

@@ -63,7 +63,7 @@ func runBugNew(env *execenv.Env, opts bugNewOptions) error {
 		}
 	}
 
-	b, _, err := env.Backend.NewBug(
+	b, _, err := env.Backend.Bugs().New(
 		text.CleanupOneLine(opts.title),
 		text.Cleanup(opts.message),
 	)

commands/bug/bug_rm.go πŸ”—

@@ -34,7 +34,7 @@ func runBugRm(env *execenv.Env, args []string) (err error) {
 		return errors.New("you must provide a bug prefix to remove")
 	}
 
-	err = env.Backend.RemoveBug(args[0])
+	err = env.Backend.Bugs().Remove(args[0])
 
 	if err != nil {
 		return

commands/bug/bug_select.go πŸ”—

@@ -46,7 +46,7 @@ func runBugSelect(env *execenv.Env, args []string) error {
 
 	prefix := args[0]
 
-	b, err := env.Backend.ResolveBugPrefix(prefix)
+	b, err := env.Backend.Bugs().ResolvePrefix(prefix)
 	if err != nil {
 		return err
 	}

commands/bug/select/select.go πŸ”—

@@ -9,7 +9,6 @@ import (
 	"github.com/pkg/errors"
 
 	"github.com/MichaelMure/git-bug/cache"
-	"github.com/MichaelMure/git-bug/entities/bug"
 	"github.com/MichaelMure/git-bug/entity"
 )
 
@@ -28,7 +27,7 @@ var ErrNoValidId = errors.New("you must provide a bug id or use the \"select\" c
 func ResolveBug(repo *cache.RepoCache, args []string) (*cache.BugCache, []string, error) {
 	// At first, try to use the first argument as a bug prefix
 	if len(args) > 0 {
-		b, err := repo.ResolveBugPrefix(args[0])
+		b, err := repo.Bugs().ResolvePrefix(args[0])
 
 		if err == nil {
 			return b, args[1:], nil
@@ -115,7 +114,7 @@ func selected(repo *cache.RepoCache) (*cache.BugCache, error) {
 		return nil, fmt.Errorf("select file in invalid, removing it")
 	}
 
-	b, err := repo.ResolveBug(id)
+	b, err := repo.Bugs().Resolve(id)
 	if err != nil {
 		return nil, err
 	}

commands/bug/select/select_test.go πŸ”—

@@ -13,8 +13,11 @@ import (
 func TestSelect(t *testing.T) {
 	repo := repository.CreateGoGitTestRepo(t, false)
 
-	repoCache, err := cache.NewRepoCache(repo)
+	repoCache, events, err := cache.NewRepoCache(repo)
 	require.NoError(t, err)
+	for event := range events {
+		require.NoError(t, event.Err)
+	}
 
 	_, _, err = ResolveBug(repoCache, []string{})
 	require.Equal(t, ErrNoValidId, err)
@@ -28,18 +31,18 @@ func TestSelect(t *testing.T) {
 
 	// generate a bunch of bugs
 
-	rene, err := repoCache.NewIdentity("RenΓ© Descartes", "rene@descartes.fr")
+	rene, err := repoCache.Identities().New("RenΓ© Descartes", "rene@descartes.fr")
 	require.NoError(t, err)
 
 	for i := 0; i < 10; i++ {
-		_, _, err := repoCache.NewBugRaw(rene, time.Now().Unix(), "title", "message", nil, nil)
+		_, _, err := repoCache.Bugs().NewRaw(rene, time.Now().Unix(), "title", "message", nil, nil)
 		require.NoError(t, err)
 	}
 
 	// and two more for testing
-	b1, _, err := repoCache.NewBugRaw(rene, time.Now().Unix(), "title", "message", nil, nil)
+	b1, _, err := repoCache.Bugs().NewRaw(rene, time.Now().Unix(), "title", "message", nil, nil)
 	require.NoError(t, err)
-	b2, _, err := repoCache.NewBugRaw(rene, time.Now().Unix(), "title", "message", nil, nil)
+	b2, _, err := repoCache.Bugs().NewRaw(rene, time.Now().Unix(), "title", "message", nil, nil)
 	require.NoError(t, err)
 
 	err = Select(repoCache, b1.Id())

commands/bug/testenv/testenv.go πŸ”—

@@ -19,7 +19,7 @@ func NewTestEnvAndUser(t *testing.T) (*execenv.Env, entity.Id) {
 
 	testEnv := execenv.NewTestEnv(t)
 
-	i, err := testEnv.Backend.NewIdentity(testUserName, testUserEmail)
+	i, err := testEnv.Backend.Identities().New(testUserName, testUserEmail)
 	require.NoError(t, err)
 
 	err = testEnv.Backend.SetUserIdentity(i)
@@ -38,7 +38,7 @@ func NewTestEnvAndBug(t *testing.T) (*execenv.Env, entity.Id) {
 
 	testEnv, _ := NewTestEnvAndUser(t)
 
-	b, _, err := testEnv.Backend.NewBug(testBugTitle, testBugMessage)
+	b, _, err := testEnv.Backend.Bugs().New(testBugTitle, testBugMessage)
 	require.NoError(t, err)
 
 	return testEnv, b.Id()
@@ -53,7 +53,7 @@ func NewTestEnvAndBugWithComment(t *testing.T) (*execenv.Env, entity.Id, entity.
 
 	env, bugID := NewTestEnvAndBug(t)
 
-	b, err := env.Backend.ResolveBug(bugID)
+	b, err := env.Backend.Bugs().Resolve(bugID)
 	require.NoError(t, err)
 
 	commentId, _, err := b.AddComment(testCommentMessage)

commands/cmdjson/json_common.go πŸ”—

@@ -26,8 +26,8 @@ func NewIdentity(i identity.Interface) Identity {
 
 func NewIdentityFromExcerpt(excerpt *cache.IdentityExcerpt) Identity {
 	return Identity{
-		Id:      excerpt.Id.String(),
-		HumanId: excerpt.Id.Human(),
+		Id:      excerpt.Id().String(),
+		HumanId: excerpt.Id().Human(),
 		Name:    excerpt.Name,
 		Login:   excerpt.Login,
 	}

commands/completion/helper_completion.go πŸ”—

@@ -88,11 +88,11 @@ func Bug(env *execenv.Env) ValidArgsFunction {
 }
 
 func bugWithBackend(backend *cache.RepoCache, toComplete string) (completions []string, directives cobra.ShellCompDirective) {
-	allIds := backend.AllBugsIds()
+	allIds := backend.Bugs().AllIds()
 	bugExcerpt := make([]*cache.BugExcerpt, len(allIds))
 	for i, id := range allIds {
 		var err error
-		bugExcerpt[i], err = backend.ResolveBugExcerpt(id)
+		bugExcerpt[i], err = backend.Bugs().ResolveExcerpt(id)
 		if err != nil {
 			return handleError(err)
 		}
@@ -138,7 +138,7 @@ func BugAndLabels(env *execenv.Env, addOrRemove bool) ValidArgsFunction {
 				seenLabels[label] = true
 			}
 
-			allLabels := env.Backend.ValidLabels()
+			allLabels := env.Backend.Bugs().ValidLabels()
 			labels = make([]bug.Label, 0, len(allLabels))
 			for _, label := range allLabels {
 				if !seenLabels[label] {
@@ -200,7 +200,7 @@ func Label(env *execenv.Env) ValidArgsFunction {
 			_ = env.Backend.Close()
 		}()
 
-		labels := env.Backend.ValidLabels()
+		labels := env.Backend.Bugs().ValidLabels()
 		completions = make([]string, len(labels))
 		for i, label := range labels {
 			if strings.Contains(label.String(), " ") {
@@ -243,10 +243,10 @@ func Ls(env *execenv.Env) ValidArgsFunction {
 			if !strings.HasPrefix(toComplete, key) {
 				continue
 			}
-			ids := env.Backend.AllIdentityIds()
+			ids := env.Backend.Identities().AllIds()
 			completions = make([]string, len(ids))
 			for i, id := range ids {
-				user, err := env.Backend.ResolveIdentityExcerpt(id)
+				user, err := env.Backend.Identities().ResolveExcerpt(id)
 				if err != nil {
 					return handleError(err)
 				}
@@ -266,7 +266,7 @@ func Ls(env *execenv.Env) ValidArgsFunction {
 			if !strings.HasPrefix(toComplete, key) {
 				continue
 			}
-			labels := env.Backend.ValidLabels()
+			labels := env.Backend.Bugs().ValidLabels()
 			completions = make([]string, len(labels))
 			for i, label := range labels {
 				if strings.Contains(label.String(), " ") {
@@ -300,14 +300,14 @@ func User(env *execenv.Env) ValidArgsFunction {
 			_ = env.Backend.Close()
 		}()
 
-		ids := env.Backend.AllIdentityIds()
+		ids := env.Backend.Identities().AllIds()
 		completions = make([]string, len(ids))
 		for i, id := range ids {
-			user, err := env.Backend.ResolveIdentityExcerpt(id)
+			user, err := env.Backend.Identities().ResolveExcerpt(id)
 			if err != nil {
 				return handleError(err)
 			}
-			completions[i] = user.Id.Human() + "\t" + user.DisplayName()
+			completions[i] = user.Id().Human() + "\t" + user.DisplayName()
 		}
 		return completions, cobra.ShellCompDirectiveNoFileComp
 	}
@@ -322,10 +322,10 @@ func UserForQuery(env *execenv.Env) ValidArgsFunction {
 			_ = env.Backend.Close()
 		}()
 
-		ids := env.Backend.AllIdentityIds()
+		ids := env.Backend.Identities().AllIds()
 		completions = make([]string, len(ids))
 		for i, id := range ids {
-			user, err := env.Backend.ResolveIdentityExcerpt(id)
+			user, err := env.Backend.Identities().ResolveExcerpt(id)
 			if err != nil {
 				return handleError(err)
 			}

commands/execenv/env_testing.go πŸ”—

@@ -5,9 +5,10 @@ import (
 	"fmt"
 	"testing"
 
+	"github.com/stretchr/testify/require"
+
 	"github.com/MichaelMure/git-bug/cache"
 	"github.com/MichaelMure/git-bug/repository"
-	"github.com/stretchr/testify/require"
 )
 
 type TestOut struct {
@@ -33,8 +34,12 @@ func NewTestEnv(t *testing.T) *Env {
 
 	buf := new(bytes.Buffer)
 
-	backend, err := cache.NewRepoCache(repo)
+	backend, events, err := cache.NewRepoCache(repo)
 	require.NoError(t, err)
+	for event := range events {
+		require.NoError(t, event.Err)
+	}
+
 	t.Cleanup(func() {
 		backend.Close()
 	})

commands/label.go πŸ”—

@@ -25,7 +25,7 @@ Note: in the future, a proper label policy could be implemented where valid labe
 }
 
 func runLabel(env *execenv.Env) error {
-	labels := env.Backend.ValidLabels()
+	labels := env.Backend.Bugs().ValidLabels()
 
 	for _, l := range labels {
 		env.Out.Println(l)

commands/user/user.go πŸ”—

@@ -46,10 +46,10 @@ func NewUserCommand() *cobra.Command {
 }
 
 func runUser(env *execenv.Env, opts userOptions) error {
-	ids := env.Backend.AllIdentityIds()
+	ids := env.Backend.Identities().AllIds()
 	var users []*cache.IdentityExcerpt
 	for _, id := range ids {
-		user, err := env.Backend.ResolveIdentityExcerpt(id)
+		user, err := env.Backend.Identities().ResolveExcerpt(id)
 		if err != nil {
 			return err
 		}
@@ -69,7 +69,7 @@ func runUser(env *execenv.Env, opts userOptions) error {
 func userDefaultFormatter(env *execenv.Env, users []*cache.IdentityExcerpt) error {
 	for _, user := range users {
 		env.Out.Printf("%s %s\n",
-			colors.Cyan(user.Id.Human()),
+			colors.Cyan(user.Id().Human()),
 			user.DisplayName(),
 		)
 	}

commands/user/user_adopt.go πŸ”—

@@ -27,7 +27,7 @@ func newUserAdoptCommand() *cobra.Command {
 func runUserAdopt(env *execenv.Env, args []string) error {
 	prefix := args[0]
 
-	i, err := env.Backend.ResolveIdentityPrefix(prefix)
+	i, err := env.Backend.Identities().ResolvePrefix(prefix)
 	if err != nil {
 		return err
 	}

commands/user/user_new.go πŸ”—

@@ -69,7 +69,7 @@ func runUserNew(env *execenv.Env, opts userNewOptions) error {
 		}
 	}
 
-	id, err := env.Backend.NewIdentityRaw(opts.name, opts.email, "", opts.avatarURL, nil, nil)
+	id, err := env.Backend.Identities().NewRaw(opts.name, opts.email, "", opts.avatarURL, nil, nil)
 	if err != nil {
 		return err
 	}

commands/user/user_show.go πŸ”—

@@ -49,7 +49,7 @@ func runUserShow(env *execenv.Env, opts userShowOptions, args []string) error {
 	var id *cache.IdentityCache
 	var err error
 	if len(args) == 1 {
-		id, err = env.Backend.ResolveIdentityPrefix(args[0])
+		id, err = env.Backend.Identities().ResolvePrefix(args[0])
 	} else {
 		id, err = env.Backend.GetUserIdentity()
 	}

commands/webui.go πŸ”—

@@ -105,7 +105,7 @@ func runWebUI(env *execenv.Env, opts webUIOptions) error {
 	}
 
 	mrc := cache.NewMultiRepoCache()
-	_, err := mrc.RegisterDefaultRepository(env.Repo)
+	_, _, err := mrc.RegisterDefaultRepository(env.Repo)
 	if err != nil {
 		return err
 	}

entities/bug/bug.go πŸ”—

@@ -27,15 +27,6 @@ var def = dag.Definition{
 	FormatVersion:        formatVersion,
 }
 
-var Actions = dag.Actions[*Bug]{
-	Wrap:             wrapper,
-	New:              NewBug,
-	Read:             Read,
-	ReadWithResolver: ReadWithResolver,
-	ReadAll:          ReadAll,
-	ListLocalIds:     ListLocalIds,
-}
-
 var ClockLoader = dag.ClockLoader(def)
 
 type Interface interface {
@@ -75,12 +66,12 @@ func ReadWithResolver(repo repository.ClockedRepo, resolvers entity.Resolvers, i
 }
 
 // ReadAll read and parse all local bugs
-func ReadAll(repo repository.ClockedRepo) <-chan dag.StreamedEntity[*Bug] {
+func ReadAll(repo repository.ClockedRepo) <-chan entity.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 dag.StreamedEntity[*Bug] {
+func ReadAllWithResolver(repo repository.ClockedRepo, resolvers entity.Resolvers) <-chan entity.StreamedEntity[*Bug] {
 	return dag.ReadAll(def, wrapper, repo, resolvers)
 }
 

entities/bug/resolver.go πŸ”—

@@ -16,6 +16,6 @@ func NewSimpleResolver(repo repository.ClockedRepo) *SimpleResolver {
 	return &SimpleResolver{repo: repo}
 }
 
-func (r *SimpleResolver) Resolve(id entity.Id) (entity.Interface, error) {
+func (r *SimpleResolver) Resolve(id entity.Id) (entity.Resolved, error) {
 	return Read(r.repo, id)
 }

entities/identity/common.go πŸ”—

@@ -3,14 +3,8 @@ package identity
 import (
 	"encoding/json"
 	"fmt"
-
-	"github.com/MichaelMure/git-bug/entity"
 )
 
-func NewErrMultipleMatch(matching []entity.Id) *entity.ErrMultipleMatch {
-	return entity.NewErrMultipleMatch("identity", matching)
-}
-
 // Custom unmarshaling function to allow package user to delegate
 // the decoding of an Identity and distinguish between an Identity
 // and a Bare.

entities/identity/identity.go πŸ”—

@@ -25,10 +25,6 @@ var ErrNoIdentitySet = errors.New("No identity is set.\n" +
 	"\"git bug user new\" or adopted with \"git bug user adopt\"")
 var ErrMultipleIdentitiesSet = errors.New("multiple user identities set")
 
-func NewErrMultipleMatchIdentity(matching []entity.Id) *entity.ErrMultipleMatch {
-	return entity.NewErrMultipleMatch("identity", matching)
-}
-
 var _ Interface = &Identity{}
 var _ entity.Interface = &Identity{}
 
@@ -174,7 +170,7 @@ func RemoveIdentity(repo repository.ClockedRepo, id entity.Id) error {
 		return err
 	}
 	if len(refs) > 1 {
-		return NewErrMultipleMatchIdentity(entity.RefsToIds(refs))
+		return entity.NewErrMultipleMatch("identity", entity.RefsToIds(refs))
 	}
 	if len(refs) == 1 {
 		// we have the identity locally
@@ -193,7 +189,7 @@ func RemoveIdentity(repo repository.ClockedRepo, id entity.Id) error {
 			return err
 		}
 		if len(remoteRefs) > 1 {
-			return NewErrMultipleMatchIdentity(entity.RefsToIds(refs))
+			return entity.NewErrMultipleMatch("identity", entity.RefsToIds(refs))
 		}
 		if len(remoteRefs) == 1 {
 			// found the identity in a remote
@@ -215,44 +211,39 @@ func RemoveIdentity(repo repository.ClockedRepo, id entity.Id) error {
 	return nil
 }
 
-type StreamedIdentity struct {
-	Identity *Identity
-	Err      error
-}
-
 // ReadAllLocal read and parse all local Identity
-func ReadAllLocal(repo repository.ClockedRepo) <-chan StreamedIdentity {
+func ReadAllLocal(repo repository.ClockedRepo) <-chan entity.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 StreamedIdentity {
+func ReadAllRemote(repo repository.ClockedRepo, remote string) <-chan entity.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 StreamedIdentity {
-	out := make(chan StreamedIdentity)
+func readAll(repo repository.ClockedRepo, refPrefix string) <-chan entity.StreamedEntity[*Identity] {
+	out := make(chan entity.StreamedEntity[*Identity])
 
 	go func() {
 		defer close(out)
 
 		refs, err := repo.ListRefs(refPrefix)
 		if err != nil {
-			out <- StreamedIdentity{Err: err}
+			out <- entity.StreamedEntity[*Identity]{Err: err}
 			return
 		}
 
 		for _, ref := range refs {
-			b, err := read(repo, ref)
+			i, err := read(repo, ref)
 
 			if err != nil {
-				out <- StreamedIdentity{Err: err}
+				out <- entity.StreamedEntity[*Identity]{Err: err}
 				return
 			}
 
-			out <- StreamedIdentity{Identity: b}
+			out <- entity.StreamedEntity[*Identity]{Entity: i}
 		}
 	}()
 
@@ -308,7 +299,7 @@ func (i *Identity) Mutate(repo repository.RepoClock, f func(orig *Mutator)) erro
 	return nil
 }
 
-// Write the identity into the Repository. In particular, this ensure that
+// Commit write the identity into the Repository. In particular, this ensures that
 // the Id is properly set.
 func (i *Identity) Commit(repo repository.ClockedRepo) error {
 	if !i.NeedCommit() {

entities/identity/identity_actions_test.go πŸ”—

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

entities/identity/resolver.go πŸ”—

@@ -16,19 +16,6 @@ func NewSimpleResolver(repo repository.Repo) *SimpleResolver {
 	return &SimpleResolver{repo: repo}
 }
 
-func (r *SimpleResolver) Resolve(id entity.Id) (entity.Interface, error) {
+func (r *SimpleResolver) Resolve(id entity.Id) (entity.Resolved, error) {
 	return ReadLocal(r.repo, id)
 }
-
-var _ entity.Resolver = &StubResolver{}
-
-// StubResolver is a Resolver that doesn't load anything, only returning IdentityStub instances
-type StubResolver struct{}
-
-func NewStubResolver() *StubResolver {
-	return &StubResolver{}
-}
-
-func (s *StubResolver) Resolve(id entity.Id) (entity.Interface, error) {
-	return &IdentityStub{id: id}, nil
-}

entity/dag/entity.go πŸ”—

@@ -33,19 +33,6 @@ type Definition struct {
 	FormatVersion uint
 }
 
-type Actions[EntityT entity.Interface] struct {
-	Wrap             func(e *Entity) EntityT
-	New              func() EntityT
-	Read             func(repo repository.ClockedRepo, id entity.Id) (EntityT, error)
-	ReadWithResolver func(repo repository.ClockedRepo, resolvers entity.Resolvers, id entity.Id) (EntityT, error)
-	ReadAll          func(repo repository.ClockedRepo) <-chan StreamedEntity[EntityT]
-	ListLocalIds     func(repo repository.Repo) ([]entity.Id, error)
-	Fetch            func(repo repository.Repo, remote string) (string, error)
-	Push             func(repo repository.Repo, remote string) (string, error)
-	Pull             func(repo repository.ClockedRepo, resolvers entity.Resolvers, remote string, mergeAuthor identity.Interface) error
-	MergeAll         func(repo repository.ClockedRepo, resolvers entity.Resolvers, remote string, mergeAuthor identity.Interface) <-chan entity.MergeResult
-}
-
 // Entity is a data structure stored in a chain of git objects, supporting actions like Push, Pull and Merge.
 type Entity struct {
 	// A Lamport clock is a logical clock that allow to order event
@@ -96,6 +83,9 @@ 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) {
 	rootHash, err := repo.ResolveRef(ref)
+	if err == repository.ErrNotFound {
+		return *new(EntityT), entity.NewErrNotFound(def.Typename)
+	}
 	if err != nil {
 		return *new(EntityT), err
 	}
@@ -260,6 +250,9 @@ func read[EntityT entity.Interface](def Definition, wrapper func(e *Entity) Enti
 // operation blobs can be implemented instead.
 func readClockNoCheck(def Definition, repo repository.ClockedRepo, ref string) error {
 	rootHash, err := repo.ResolveRef(ref)
+	if err == repository.ErrNotFound {
+		return entity.NewErrNotFound(def.Typename)
+	}
 	if err != nil {
 		return err
 	}
@@ -306,14 +299,9 @@ func readClockNoCheck(def Definition, repo repository.ClockedRepo, ref string) e
 	return nil
 }
 
-type StreamedEntity[EntityT entity.Interface] struct {
-	Entity EntityT
-	Err    error
-}
-
 // 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 StreamedEntity[EntityT] {
-	out := make(chan StreamedEntity[EntityT])
+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])
 
 	go func() {
 		defer close(out)
@@ -322,7 +310,7 @@ func ReadAll[EntityT entity.Interface](def Definition, wrapper func(e *Entity) E
 
 		refs, err := repo.ListRefs(refPrefix)
 		if err != nil {
-			out <- StreamedEntity[EntityT]{Err: err}
+			out <- entity.StreamedEntity[EntityT]{Err: err}
 			return
 		}
 
@@ -330,11 +318,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 <- StreamedEntity[EntityT]{Err: err}
+				out <- entity.StreamedEntity[EntityT]{Err: err}
 				return
 			}
 
-			out <- StreamedEntity[EntityT]{Entity: e}
+			out <- entity.StreamedEntity[EntityT]{Entity: e}
 		}
 	}()
 

entity/dag/entity_actions_test.go πŸ”—

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

entity/err.go πŸ”—

@@ -5,6 +5,8 @@ import (
 	"strings"
 )
 
+// ErrNotFound is to be returned when an entity, item, element is
+// not found.
 type ErrNotFound struct {
 	typename string
 }
@@ -22,13 +24,15 @@ func IsErrNotFound(err error) bool {
 	return ok
 }
 
+// ErrMultipleMatch is to be returned when more than one entity, item, element
+// is found, where only one was expected.
 type ErrMultipleMatch struct {
-	entityType string
-	Matching   []Id
+	typename string
+	Matching []Id
 }
 
-func NewErrMultipleMatch(entityType string, matching []Id) *ErrMultipleMatch {
-	return &ErrMultipleMatch{entityType: entityType, Matching: matching}
+func NewErrMultipleMatch(typename string, matching []Id) *ErrMultipleMatch {
+	return &ErrMultipleMatch{typename: typename, Matching: matching}
 }
 
 func (e ErrMultipleMatch) Error() string {
@@ -39,7 +43,7 @@ func (e ErrMultipleMatch) Error() string {
 	}
 
 	return fmt.Sprintf("Multiple matching %s found:\n%s",
-		e.entityType,
+		e.typename,
 		strings.Join(matching, "\n"))
 }
 
@@ -48,6 +52,8 @@ func IsErrMultipleMatch(err error) bool {
 	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

entity/interface.go πŸ”—

@@ -12,15 +12,3 @@ type Interface interface {
 	// Validate check if the Entity data is valid
 	Validate() error
 }
-
-// type Commitable interface {
-// 	Interface
-// 	NeedCommit() bool
-// 	CommitAsNeeded(repo repository.ClockedRepo) error
-// 	Commit(repo repository.ClockedRepo) error
-// }
-
-//
-// type Operation interface {
-//
-// }

entity/resolver.go πŸ”—

@@ -5,16 +5,23 @@ import (
 	"sync"
 )
 
+// 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) (Interface, error)
+	Resolve(id Id) (Resolved, error)
 }
 
 // Resolvers is a collection of Resolver, for different type of Entity
-type Resolvers map[Interface]Resolver
+type Resolvers map[Resolved]Resolver
 
 // Resolve use the appropriate sub-resolver for the given type and find the Entity matching the Id.
-func Resolve[T Interface](rs Resolvers, id Id) (T, error) {
+func Resolve[T Resolved](rs Resolvers, id Id) (T, error) {
 	var zero T
 	for t, resolver := range rs {
 		switch t.(type) {
@@ -35,17 +42,17 @@ var _ Resolver = &CachedResolver{}
 type CachedResolver struct {
 	resolver Resolver
 	mu       sync.RWMutex
-	entities map[Id]Interface
+	entities map[Id]Resolved
 }
 
 func NewCachedResolver(resolver Resolver) *CachedResolver {
 	return &CachedResolver{
 		resolver: resolver,
-		entities: make(map[Id]Interface),
+		entities: make(map[Id]Resolved),
 	}
 }
 
-func (c *CachedResolver) Resolve(id Id) (Interface, error) {
+func (c *CachedResolver) Resolve(id Id) (Resolved, error) {
 	c.mu.RLock()
 	if i, ok := c.entities[id]; ok {
 		c.mu.RUnlock()
@@ -64,18 +71,18 @@ func (c *CachedResolver) Resolve(id Id) (Interface, error) {
 	return i, nil
 }
 
-var _ Resolver = ResolverFunc[Interface](nil)
+var _ Resolver = ResolverFunc[Resolved](nil)
 
 // ResolverFunc is a helper to morph a function resolver into a Resolver
-type ResolverFunc[T Interface] func(id Id) (T, error)
+type ResolverFunc[EntityT Resolved] func(id Id) (EntityT, error)
 
-func (fn ResolverFunc[T]) Resolve(id Id) (Interface, error) {
+func (fn ResolverFunc[EntityT]) Resolve(id Id) (Resolved, error) {
 	return fn(id)
 }
 
 // MakeResolver create a resolver able to return the given entities.
-func MakeResolver(entities ...Interface) Resolver {
-	return ResolverFunc[Interface](func(id Id) (Interface, error) {
+func MakeResolver(entities ...Resolved) Resolver {
+	return ResolverFunc[Resolved](func(id Id) (Resolved, error) {
 		for _, entity := range entities {
 			if entity.Id() == id {
 				return entity, nil

entity/streamed.go πŸ”—

@@ -0,0 +1,6 @@
+package entity
+
+type StreamedEntity[EntityT Interface] struct {
+	Entity EntityT
+	Err    error
+}

termui/bug_table.go πŸ”—

@@ -239,7 +239,7 @@ func (bt *bugTable) disable(g *gocui.Gui) error {
 
 func (bt *bugTable) paginate(max int) error {
 	var err error
-	bt.allIds, err = bt.repo.QueryBugs(bt.query)
+	bt.allIds, err = bt.repo.Bugs().Query(bt.query)
 	if err != nil {
 		return err
 	}
@@ -265,7 +265,7 @@ func (bt *bugTable) doPaginate(max int) error {
 	bt.excerpts = make([]*cache.BugExcerpt, len(ids))
 
 	for i, id := range ids {
-		excerpt, err := bt.repo.ResolveBugExcerpt(id)
+		excerpt, err := bt.repo.Bugs().ResolveExcerpt(id)
 		if err != nil {
 			return err
 		}
@@ -319,12 +319,12 @@ func (bt *bugTable) render(v *gocui.View, maxX int) {
 			labelsTxt.WriteString(lc256.Unescape())
 		}
 
-		author, err := bt.repo.ResolveIdentityExcerpt(excerpt.AuthorId)
+		author, err := bt.repo.Identities().ResolveExcerpt(excerpt.AuthorId)
 		if err != nil {
 			panic(err)
 		}
 
-		id := text.LeftPadMaxLine(excerpt.Id.Human(), columnWidths["id"], 0)
+		id := text.LeftPadMaxLine(excerpt.Id().Human(), columnWidths["id"], 0)
 		status := text.LeftPadMaxLine(excerpt.Status.String(), columnWidths["status"], 0)
 		labels := text.TruncateMax(labelsTxt.String(), minInt(columnWidths["title"]-2, 10))
 		title := text.LeftPadMaxLine(strings.TrimSpace(excerpt.Title), columnWidths["title"]-text.Len(labels), 0)
@@ -451,8 +451,8 @@ func (bt *bugTable) openBug(g *gocui.Gui, v *gocui.View) error {
 		// There are no open bugs, just do nothing
 		return nil
 	}
-	id := bt.excerpts[bt.selectCursor].Id
-	b, err := bt.repo.ResolveBug(id)
+	id := bt.excerpts[bt.selectCursor].Id()
+	b, err := bt.repo.Bugs().Resolve(id)
 	if err != nil {
 		return err
 	}

termui/label_select.go πŸ”—

@@ -37,7 +37,7 @@ func newLabelSelect() *labelSelect {
 func (ls *labelSelect) SetBug(cache *cache.RepoCache, bug *cache.BugCache) {
 	ls.cache = cache
 	ls.bug = bug
-	ls.labels = cache.ValidLabels()
+	ls.labels = cache.Bugs().ValidLabels()
 
 	// Find which labels are currently applied to the bug
 	bugLabels := bug.Snapshot().Labels

termui/termui.go πŸ”—

@@ -200,7 +200,7 @@ func newBugWithEditor(repo *cache.RepoCache) error {
 
 		return errTerminateMainloop
 	} else {
-		b, _, err = repo.NewBug(
+		b, _, err = repo.Bugs().New(
 			text.CleanupOneLine(title),
 			text.Cleanup(message),
 		)