Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions cache/repo_cache_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cache

import (
"slices"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -290,6 +291,152 @@ func TestCachePushPull(t *testing.T) {
require.Len(t, cacheA.Bugs().AllIds(), 2)
}

// searchObserver records the events it receives, and whether the entity could
// already be found by a full-text search for term when the event was received.
type searchObserver struct {
cache *RepoCache
term string
events []searchEvent
}

type searchEvent struct {
event EntityEventType
id entity.Id
found bool
}

func (o *searchObserver) EntityEvent(event EntityEventType, _ string, _ string, id entity.Id) {
// no require here, as this doesn't run in the test goroutine
found := false
q, err := query.Parse(o.term)
if err == nil {
res, err := o.cache.Bugs().Query(q)
found = err == nil && slices.Contains(res, id)
}
o.events = append(o.events, searchEvent{event: event, id: id, found: found})
}

// watch resets the recorded events, and sets the term to search for.
func (o *searchObserver) watch(term string) {
o.term = term
o.events = nil
}

// Entities created or updated by a merge must be up to date in the excerpts and
// the full-text index by the time observers are notified, the same way as
// entities created or changed locally.
func TestCacheMerge(t *testing.T) {
repoA, repoB, _ := repository.SetupGoGitReposAndRemote(t)

cacheA := createTestRepoCacheNoEvents(t, repoA)
cacheB := createTestRepoCacheNoEvents(t, repoB)

reneA, err := cacheA.Identities().New("René Descartes", "rene@descartes.fr")
require.NoError(t, err)
err = cacheA.SetUserIdentity(reneA)
require.NoError(t, err)

search := func(t *testing.T, cache *RepoCache, term string) []entity.Id {
t.Helper()
q, err := query.Parse(term)
require.NoError(t, err)
res, err := cache.Bugs().Query(q)
require.NoError(t, err)
return res
}

obs := &searchObserver{cache: cacheB}
require.NoError(t, cacheB.registerObserver("repotest", bug.Typename, obs))

// A creates a bug holding a unique marker
bugA, _, err := cacheA.Bugs().New("title", "markercreate")
require.NoError(t, err)

_, err = cacheA.Push("origin")
require.NoError(t, err)

// a bug merged as new is searchable in B, already when observers are notified
obs.watch("markercreate")
err = cacheB.Pull("origin")
require.NoError(t, err)
require.Equal(t, []entity.Id{bugA.Id()}, search(t, cacheB, "markercreate"))
require.Equal(t, []searchEvent{{EntityEventCreated, bugA.Id(), true}}, obs.events)

// A adds a comment holding a second marker
_, _, err = bugA.AddComment("markerupdate")
require.NoError(t, err)
err = bugA.Commit()
require.NoError(t, err)

_, err = cacheA.Push("origin")
require.NoError(t, err)

// the new text of a bug merged as updated is searchable in B, already when
// observers are notified
obs.watch("markerupdate")
err = cacheB.Pull("origin")
require.NoError(t, err)
require.Equal(t, []entity.Id{bugA.Id()}, search(t, cacheB, "markerupdate"))
require.Equal(t, []searchEvent{{EntityEventUpdated, bugA.Id(), true}}, obs.events)

// a merge commit requires a user identity in B
reneB, err := cacheB.Identities().Resolve(reneA.Id())
require.NoError(t, err)
err = cacheB.SetUserIdentity(reneB)
require.NoError(t, err)

// A comments and closes the bug while B comments, so B needs a merge commit
_, _, err = bugA.AddComment("markerremote")
require.NoError(t, err)
_, err = bugA.Close()
require.NoError(t, err)
err = bugA.Commit()
require.NoError(t, err)

_, err = cacheA.Push("origin")
require.NoError(t, err)

bugB, err := cacheB.Bugs().Resolve(bugA.Id())
require.NoError(t, err)
_, _, err = bugB.AddComment("markerlocal")
require.NoError(t, err)
err = bugB.Commit()
require.NoError(t, err)

// the text of both sides of a merge commit is searchable in B, already when
// observers are notified
obs.watch("markerremote")
err = cacheB.Pull("origin")
require.NoError(t, err)
require.Equal(t, []entity.Id{bugA.Id()}, search(t, cacheB, "markerremote"))
require.Equal(t, []entity.Id{bugA.Id()}, search(t, cacheB, "markerlocal"))
require.Equal(t, []searchEvent{{EntityEventUpdated, bugA.Id(), true}}, obs.events)

// the excerpt holds the merged state as well
require.Equal(t, []entity.Id{bugA.Id()}, search(t, cacheB, "status:closed"))

// the merged bug can still be changed in B
bugB, err = cacheB.Bugs().Resolve(bugA.Id())
require.NoError(t, err)
_, _, err = bugB.AddComment("markeraftermerge")
require.NoError(t, err)
err = bugB.Commit()
require.NoError(t, err)

// the index count matches the excerpts, so the next load won't detect a
// mismatch and rebuild the cache
indexCount := func(t *testing.T, name string) uint64 {
t.Helper()
idx, err := repoB.GetIndex(name)
require.NoError(t, err)
count, err := idx.DocCount()
require.NoError(t, err)
return count
}
require.Equal(t, uint64(len(cacheB.Bugs().AllIds())), indexCount(t, bug.Namespace))
require.Equal(t, uint64(len(cacheB.Identities().AllIds())), indexCount(t, identity.Namespace))
}

// Pulling into a fresh repo must not require a user identity, otherwise it's
// impossible to adopt an identity that only exists on the remote.
// See https://github.com/git-bug/git-bug/issues/1003
Expand Down
46 changes: 30 additions & 16 deletions cache/subcache.go
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,28 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) MergeAll(remote string) <-chan en
author = user
}

index, err := sc.repo.GetIndex(sc.namespace)
if err != nil {
out <- entity.NewMergeError(err, "")
return
}

// merge a single entity in the cache and its views. The excerpt file is
// not written here, it's written once for all the merged entities.
updateCache := func(result entity.MergeResult) error {
e := result.Entity.(EntityT)
cached := sc.makeCached(e, sc.entityUpdated)
Comment on lines +617 to +618

sc.mu.Lock()
sc.excerpts[result.Id] = sc.makeExcerpt(cached)
// might as well keep them in memory
sc.cached[result.Id] = cached
sc.mu.Unlock()

// index before notifying, so that an observer can already search it
return index.IndexOne(result.Id.String(), sc.makeIndexData(cached))
Comment on lines +620 to +627
}

results := sc.actions.MergeAll(sc.repo, sc.resolvers(), remote, author)
for result := range results {
out <- result
Expand All @@ -615,26 +637,18 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) MergeAll(remote string) <-chan en

switch result.Status {
case entity.MergeStatusNew:
e := result.Entity.(EntityT)
cached := sc.makeCached(e, sc.entityUpdated)

sc.mu.Lock()
sc.excerpts[result.Id] = sc.makeExcerpt(cached)
// might as well keep them in memory
sc.cached[result.Id] = cached
sc.mu.Unlock()
if err := updateCache(result); err != nil {
out <- entity.NewMergeError(err, result.Id)
continue
Comment on lines +640 to +642
}
sc.notifyObservers(EntityEventCreated, result.Id)

case entity.MergeStatusUpdated:
// TODO: can that result in multiple copy of the same entity?
e := result.Entity.(EntityT)
cached := sc.makeCached(e, sc.entityUpdated)

sc.mu.Lock()
sc.excerpts[result.Id] = sc.makeExcerpt(cached)
// might as well keep them in memory
sc.cached[result.Id] = cached
sc.mu.Unlock()
if err := updateCache(result); err != nil {
out <- entity.NewMergeError(err, result.Id)
continue
}
sc.notifyObservers(EntityEventUpdated, result.Id)
}
}
Expand Down
104 changes: 55 additions & 49 deletions entity/dag/entity_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,12 @@ func Pull[EntityT entity.Interface](def Definition, wrapper func(e *Entity) Enti
// MergeAll will merge all the available remote Entity:
//
// Multiple scenario exist:
// 1. if the remote Entity doesn't exist locally, it's created
// --> emit entity.MergeStatusNew
// 2. if the remote and local Entity have the same state, nothing is changed
// 1. if the remote and local Entity have the same state, nothing is changed
// --> emit entity.MergeStatusNothing
// 3. if the local Entity has new commits but the remote don't, nothing is changed
// 2. if the local Entity has new commits but the remote don't, nothing is changed
// --> emit entity.MergeStatusNothing
// 3. if the remote Entity doesn't exist locally, it's created
// --> emit entity.MergeStatusNew
// 4. if the remote has new commit, the local bug is updated to match the same history
// (fast-forward update)
// --> emit entity.MergeStatusUpdated
Expand Down Expand Up @@ -100,66 +100,73 @@ func merge[EntityT entity.Interface](def Definition, wrapper func(e *Entity) Ent
return entity.NewMergeInvalidStatus(id, errors.Wrap(err, "invalid ref").Error())
}

remoteEntity, err := read[EntityT](def, wrapper, repo, resolvers, remoteRef)
if err != nil {
return entity.NewMergeInvalidStatus(id,
errors.Wrapf(err, "remote %s is not readable", def.Typename).Error())
}

// Check for error in remote data
if err := remoteEntity.Validate(); err != nil {
return entity.NewMergeInvalidStatus(id,
errors.Wrapf(err, "remote %s data is invalid", def.Typename).Error())
}

localRef := fmt.Sprintf("refs/%s/%s", def.Namespace, id.String())

// SCENARIO 1
// if the remote Entity doesn't exist locally, it's created
remoteCommit, err := repo.ResolveRef(remoteRef)
if err != nil {
return entity.NewMergeError(err, id)
}

localExist, err := repo.RefExist(localRef)
if err != nil {
return entity.NewMergeError(err, id)
}

if !localExist {
// the bug is not local yet, simply create the reference
err := repo.CopyRef(remoteRef, localRef)
// Scenarios 1 and 2 don't change anything, so they are checked before reading
// the remote Entity as there is no need to pay for it. Scenario 1 is by far
// the most common case.

var localCommit repository.Hash
if localExist {
localCommit, err = repo.ResolveRef(localRef)
if err != nil {
return entity.NewMergeError(err, id)
}

return entity.NewMergeNewStatus(id, remoteEntity)
}
// SCENARIO 1
// if the remote and local Entity have the same state, nothing is changed

localCommit, err := repo.ResolveRef(localRef)
if err != nil {
return entity.NewMergeError(err, id)
if localCommit == remoteCommit {
// nothing to merge
return entity.NewMergeNothingStatus(id)
}

// SCENARIO 2
// if the local Entity has new commits but the remote don't, nothing is changed

localCommits, err := repo.ListCommits(localRef)
if err != nil {
return entity.NewMergeError(err, id)
}

if slices.Contains(localCommits, remoteCommit) {
return entity.NewMergeNothingStatus(id)
}
}

remoteCommit, err := repo.ResolveRef(remoteRef)
remoteEntity, err := read[EntityT](def, wrapper, repo, resolvers, remoteRef)
if err != nil {
return entity.NewMergeError(err, id)
return entity.NewMergeInvalidStatus(id,
errors.Wrapf(err, "remote %s is not readable", def.Typename).Error())
}

// SCENARIO 2
// if the remote and local Entity have the same state, nothing is changed

if localCommit == remoteCommit {
// nothing to merge
return entity.NewMergeNothingStatus(id)
// Check for error in remote data
if err := remoteEntity.Validate(); err != nil {
return entity.NewMergeInvalidStatus(id,
errors.Wrapf(err, "remote %s data is invalid", def.Typename).Error())
}

// SCENARIO 3
// if the local Entity has new commits but the remote don't, nothing is changed
// if the remote Entity doesn't exist locally, it's created

localCommits, err := repo.ListCommits(localRef)
if err != nil {
return entity.NewMergeError(err, id)
}
if !localExist {
// the bug is not local yet, simply create the reference
err := repo.CopyRef(remoteRef, localRef)
if err != nil {
return entity.NewMergeError(err, id)
}

if slices.Contains(localCommits, remoteCommit) {
return entity.NewMergeNothingStatus(id)
return entity.NewMergeNewStatus(id, remoteEntity)
}

// SCENARIO 4
Expand Down Expand Up @@ -198,11 +205,6 @@ func merge[EntityT entity.Interface](def Definition, wrapper func(e *Entity) Ent
"%s %s has diverged and requires a merge commit", def.Typename, id.Human()), id)
}

localEntity, err := read[EntityT](def, wrapper, repo, resolvers, localRef)
if err != nil {
return entity.NewMergeError(err, id)
}

editTime, err := repo.Increment(fmt.Sprintf(editClockPattern, def.Namespace))
if err != nil {
return entity.NewMergeError(err, id)
Expand All @@ -226,10 +228,14 @@ func merge[EntityT entity.Interface](def Definition, wrapper func(e *Entity) Ent
return entity.NewMergeError(err, id)
}

// Note: we don't need to update localEntity state (lastCommit, operations...) as we
// discard it entirely anyway.
// read the merged entity back, so that the returned entity holds the operations
// of both branches and can be committed on top of the merge commit.
mergedEntity, err := read[EntityT](def, wrapper, repo, resolvers, localRef)
if err != nil {
return entity.NewMergeError(err, id)
}

return entity.NewMergeUpdatedStatus(id, localEntity)
return entity.NewMergeUpdatedStatus(id, mergedEntity)
}

// Remove delete an Entity.
Expand Down
Loading
Loading