Skip to content
Open
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
2 changes: 2 additions & 0 deletions cache/multi_repo_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
)

const lockfile = "lock"

const cacheDir = "cache"
const defaultRepoName = "__default"

// MultiRepoCache is the root cache, holding multiple RepoCache.
Expand Down
34 changes: 31 additions & 3 deletions cache/repo_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"

"github.com/git-bug/git-bug/entities/bug"
"github.com/git-bug/git-bug/entities/identity"
Expand Down Expand Up @@ -88,6 +89,18 @@ func NewRepoCache(r repository.ClockedRepo) (*RepoCache, chan BuildEvent) {
// The caller is expected to read all returned events before the cache is considered
// ready to use.
func NewNamedRepoCache(r repository.ClockedRepo, name string) (*RepoCache, chan BuildEvent) {
return newRepoCache(r, name, false)
}

// NewRepoCacheRebuild open a cache on top of a raw repository, ignoring any
// cache already on disk and always rebuilding it from the git data.
// The caller is expected to read all returned events before the cache is considered
// ready to use.
func NewRepoCacheRebuild(r repository.ClockedRepo) (*RepoCache, chan BuildEvent) {
return newRepoCache(r, defaultRepoName, true)
}

func newRepoCache(r repository.ClockedRepo, name string, rebuild bool) (*RepoCache, chan BuildEvent) {
c := &RepoCache{
repo: r,
name: name,
Expand Down Expand Up @@ -118,9 +131,17 @@ func NewNamedRepoCache(r repository.ClockedRepo, name string) (*RepoCache, chan
return
}

err = c.load()
if err == nil {
return
if rebuild {
err = c.repo.LocalStorage().RemoveAll(cacheDir)
if err != nil {
events <- BuildEvent{Err: err}
return
}
} else {
err = c.load()
if err == nil {
return
}
}

// Cache is either missing, broken or outdated. Rebuilding.
Expand Down Expand Up @@ -215,6 +236,7 @@ func (c *RepoCache) buildCache(events chan BuildEvent) {
events <- BuildEvent{Event: BuildEventCacheIsBuilt}

var wg sync.WaitGroup
var failed atomic.Bool
for _, subcache := range c.subcaches {
wg.Add(1)
go func(subcache cacheMgmt) {
Expand All @@ -224,12 +246,18 @@ func (c *RepoCache) buildCache(events chan BuildEvent) {
for buildEvent := range buildEvents {
events <- buildEvent
if buildEvent.Err != nil {
failed.Store(true)
return
}
}
}(subcache)
}
wg.Wait()

if failed.Load() {
// don't leave the excerpts of the sub-caches that succeeded
_ = c.repo.LocalStorage().RemoveAll(cacheDir)
}
}

func (c *RepoCache) registerObserver(repoName string, typename string, observer Observer) error {
Expand Down
169 changes: 169 additions & 0 deletions cache/repo_cache_rebuild_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
package cache

import (
"os"
"path/filepath"
"slices"
"testing"

"github.com/stretchr/testify/require"

"github.com/git-bug/git-bug/entities/bug"
"github.com/git-bug/git-bug/entities/identity"
"github.com/git-bug/git-bug/entity"
"github.com/git-bug/git-bug/repository"
)

func rebuildNoEvents(t *testing.T, repo repository.ClockedRepo) (*RepoCache, error) {
t.Helper()
c, events := NewRepoCacheRebuild(repo)
var firstErr error
for event := range events {
if event.Err != nil && firstErr == nil {
firstErr = event.Err
}
}
return c, firstErr
}

func gitBugRefs(t *testing.T, repo repository.ClockedRepo) map[string]repository.Hash {
t.Helper()
res := make(map[string]repository.Hash)
for _, prefix := range []string{"refs/bugs/", "refs/identities/"} {
refs, err := repo.ListRefs(prefix)
require.NoError(t, err)
for _, ref := range refs {
h, err := repo.ResolveRef(ref)
require.NoError(t, err)
res[ref] = h
}
}
return res
}

func TestCacheRebuild(t *testing.T) {
repo := repository.CreateGoGitTestRepo(t, false)

c, err := NewRepoCacheNoEvents(repo)
require.NoError(t, err)
iden, err := c.Identities().New("René Descartes", "[email protected]")
require.NoError(t, err)
require.NoError(t, c.SetUserIdentity(iden))

removed, _, err := c.Bugs().New("removed", "message")
require.NoError(t, err)
moved, _, err := c.Bugs().New("moved", "message")
require.NoError(t, err)
removedId, movedId := removed.Id(), moved.Id()

removedRef := "refs/" + bug.Namespace + "/" + removedId.String()
movedRef := "refs/" + bug.Namespace + "/" + movedId.String()

removedHash, err := repo.ResolveRef(removedRef)
require.NoError(t, err)
movedOldHash, err := repo.ResolveRef(movedRef)
require.NoError(t, err)

_, _, err = moved.AddComment("a comment")
require.NoError(t, err)
require.NoError(t, moved.Commit())
require.NoError(t, c.Close())

// changes made outside git-bug
require.NoError(t, repo.RemoveRef(removedRef))
movedNewHash, err := repo.ResolveRef(movedRef)
require.NoError(t, err)
require.NoError(t, repo.UpdateRef(movedRef, movedNewHash, movedOldHash))

// C1: a normal open doesn't notice
c, err = NewRepoCacheNoEvents(repo)
require.NoError(t, err)
require.Contains(t, c.Bugs().AllIds(), removedId)
require.NoError(t, c.Close())

refsBefore := gitBugRefs(t, repo)

// C2, C4: rebuild reflects the refs
c, err = rebuildNoEvents(t, repo)
require.NoError(t, err)
require.False(t, slices.Contains(c.Bugs().AllIds(), removedId))
excerpt, err := c.Bugs().ResolveExcerpt(movedId)
require.NoError(t, err)
require.Equal(t, 1, excerpt.LenComments) // only the creation message
require.NoError(t, c.Close())

// C7: git data is untouched
require.Equal(t, refsBefore, gitBugRefs(t, repo))

// C3: a restored ref shows up again
require.NoError(t, repo.UpdateRef(removedRef, "", removedHash))
c, err = rebuildNoEvents(t, repo)
require.NoError(t, err)
require.Contains(t, c.Bugs().AllIds(), removedId)
require.NoError(t, c.Close())
}

func TestCacheRebuildLocked(t *testing.T) {
repo := repository.CreateGoGitTestRepo(t, false)

c, err := NewRepoCacheNoEvents(repo)
require.NoError(t, err)
t.Cleanup(func() { _ = c.Close() })

before := make(map[string][]byte)
for _, namespace := range []string{bug.Namespace, identity.Namespace} {
data, err := os.ReadFile(filepath.Join(repo.LocalStorage().Root(), cacheDir, namespace))
require.NoError(t, err)
before[namespace] = data
}

_, err = rebuildNoEvents(t, repo)
require.ErrorContains(t, err, "already locked")

for namespace, data := range before {
after, err := os.ReadFile(filepath.Join(repo.LocalStorage().Root(), cacheDir, namespace))
require.NoError(t, err)
require.Equal(t, data, after)
}
}

func TestCacheRebuildFailureLeavesNoExcerpts(t *testing.T) {
repo := repository.CreateGoGitTestRepo(t, false)

c, err := NewRepoCacheNoEvents(repo)
require.NoError(t, err)
require.NoError(t, c.Close())

cachePath := filepath.Join(repo.LocalStorage().Root(), cacheDir)
_, err = os.Stat(filepath.Join(cachePath, bug.Namespace))
require.NoError(t, err)

// a bug ref pointing to something that isn't a bug
tree, err := repo.StoreTree(nil)
require.NoError(t, err)
commit, err := repo.StoreCommit(tree)
require.NoError(t, err)
badId := entity.DeriveId([]byte("not a bug"))
badRef := "refs/" + bug.Namespace + "/" + badId.String()
require.NoError(t, repo.UpdateRef(badRef, "", commit))

c, err = rebuildNoEvents(t, repo)
require.Error(t, err)
require.NoError(t, c.Close())

for _, namespace := range []string{bug.Namespace, identity.Namespace} {
_, err = os.Stat(filepath.Join(cachePath, namespace))
require.ErrorIs(t, err, os.ErrNotExist)
}

// the next normal open rebuilds
require.NoError(t, repo.RemoveRef(badRef))
c, events := NewRepoCache(repo)
var rebuilt bool
for event := range events {
require.NoError(t, event.Err)
rebuilt = rebuilt || event.Event == BuildEventCacheIsBuilt
}
require.True(t, rebuilt)
require.NoError(t, c.Close())
}
4 changes: 2 additions & 2 deletions cache/subcache.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) Load() error {
sc.mu.Lock()
defer sc.mu.Unlock()

f, err := sc.repo.LocalStorage().Open(filepath.Join("cache", sc.namespace))
f, err := sc.repo.LocalStorage().Open(filepath.Join(cacheDir, sc.namespace))
if err != nil {
return err
}
Expand Down Expand Up @@ -175,7 +175,7 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) write() error {
return err
}

f, err := sc.repo.LocalStorage().Create(filepath.Join("cache", sc.namespace))
f, err := sc.repo.LocalStorage().Create(filepath.Join(cacheDir, sc.namespace))
if err != nil {
return err
}
Expand Down
36 changes: 36 additions & 0 deletions commands/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package commands

import (
"github.com/spf13/cobra"

"github.com/git-bug/git-bug/commands/execenv"
)

func newCacheCommand(env *execenv.Env) *cobra.Command {
cmd := &cobra.Command{
Use: "cache",
Short: "Manage the local cache",
Args: cobra.NoArgs,
}

cmd.AddCommand(newCacheRebuildCommand(env))

return cmd
}

func newCacheRebuildCommand(env *execenv.Env) *cobra.Command {
cmd := &cobra.Command{
Use: "rebuild",
Short: "Rebuild the local cache from the git data",
Long: `Rebuild the local cache from the git data.

The cache is not aware of changes made to git-bug's references by other tools, for example when fetching or pushing them with git directly. Use this command to make git-bug reflect them.`,
PreRunE: execenv.LoadBackendRebuild(env),
RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error {
return nil
}),
Args: cobra.NoArgs,
}

return cmd
}
65 changes: 65 additions & 0 deletions commands/cache_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package commands

import (
"bytes"
"context"
"path/filepath"
"slices"
"testing"

"github.com/stretchr/testify/require"

"github.com/git-bug/git-bug/cache"
"github.com/git-bug/git-bug/commands/bug/testenv"
"github.com/git-bug/git-bug/entities/bug"
)

func TestCacheRebuildCommand(t *testing.T) {
env, bugId := testenv.NewTestEnvAndBug(t)

root := env.Repo.LocalStorage().Root() // $REPO/.git/git-bug
t.Chdir(filepath.Dir(filepath.Dir(root)))

require.NoError(t, env.Backend.Close())
env.Backend = nil

// removed outside of git-bug
require.NoError(t, env.Repo.RemoveRef("refs/"+bug.Namespace+"/"+bugId.String()))

cmd := newCacheCommand(env)
cmd.SetArgs([]string{"rebuild"})
require.NoError(t, cmd.Execute())
require.Empty(t, env.Out.String())
require.Nil(t, env.Backend)

backend, err := cache.NewRepoCacheNoEvents(env.Repo)
require.NoError(t, err)
t.Cleanup(func() { _ = backend.Close() })
require.False(t, slices.Contains(backend.Bugs().AllIds(), bugId))
}

func TestCacheRebuildCommandArgs(t *testing.T) {
env, _ := testenv.NewTestEnvAndUser(t)
backend := env.Backend

cmd := newCacheCommand(env)
cmd.SetArgs([]string{"rebuild", "extra"})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
err := cmd.Execute()
require.ErrorContains(t, err, `unknown command "extra"`)

// the pre-run never replaced the backend
require.Same(t, backend, env.Backend)
}

func TestRootHelpMaintenanceGroup(t *testing.T) {
root := NewRootCommand(context.Background(), "")

var out bytes.Buffer
root.SetOut(&out)
root.SetArgs([]string{"--help"})
require.NoError(t, root.Execute())

require.Regexp(t, `(?m)^Maintenance\n cache +Manage the local cache\n wipe +Wipe git-bug from the git repository\n`, out.String())
}
Loading