cache: maintain, write and load from disk bug excerpts

Michael Muré created

Change summary

cache/bug_cache.go            |  60 +++++++---------
cache/bug_excerpt.go          |   9 ++
cache/cache.go                |  45 ++++++------
cache/repo_cache.go           | 133 ++++++++++++++++++++++++++++--------
graphql/models/models.go      |   8 +-
graphql/resolvers/mutation.go |   4 
graphql/resolvers/query.go    |   2 
termui/bug_table.go           |  10 +-
termui/show_bug.go            |   8 +-
termui/termui.go              |  18 +++-
10 files changed, 188 insertions(+), 109 deletions(-)

Detailed changes

cache/bug_cache.go 🔗

@@ -3,34 +3,18 @@ package cache
 import (
 	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/bug/operations"
-	"github.com/MichaelMure/git-bug/repository"
 	"github.com/MichaelMure/git-bug/util"
 )
 
-type BugCacher interface {
-	Snapshot() *bug.Snapshot
-
-	// Mutations
-	AddComment(message string) error
-	AddCommentWithFiles(message string, files []util.Hash) error
-	ChangeLabels(added []string, removed []string) error
-	Open() error
-	Close() error
-	SetTitle(title string) error
-
-	Commit() error
-	CommitAsNeeded() error
-}
-
 type BugCache struct {
-	repo repository.Repo
-	bug  *bug.WithSnapshot
+	repoCache *RepoCache
+	bug       *bug.WithSnapshot
 }
 
-func NewBugCache(repo repository.Repo, b *bug.Bug) BugCacher {
+func NewBugCache(repoCache *RepoCache, b *bug.Bug) *BugCache {
 	return &BugCache{
-		repo: repo,
-		bug:  &bug.WithSnapshot{Bug: b},
+		repoCache: repoCache,
+		bug:       &bug.WithSnapshot{Bug: b},
 	}
 }
 
@@ -38,23 +22,31 @@ func (c *BugCache) Snapshot() *bug.Snapshot {
 	return c.bug.Snapshot()
 }
 
+func (c *BugCache) notifyUpdated() error {
+	return c.repoCache.bugUpdated(c.bug.Id())
+}
+
 func (c *BugCache) AddComment(message string) error {
-	return c.AddCommentWithFiles(message, nil)
+	if err := c.AddCommentWithFiles(message, nil); err != nil {
+		return err
+	}
+
+	return c.notifyUpdated()
 }
 
 func (c *BugCache) AddCommentWithFiles(message string, files []util.Hash) error {
-	author, err := bug.GetUser(c.repo)
+	author, err := bug.GetUser(c.repoCache.repo)
 	if err != nil {
 		return err
 	}
 
 	operations.CommentWithFiles(c.bug, author, message, files)
 
-	return nil
+	return c.notifyUpdated()
 }
 
 func (c *BugCache) ChangeLabels(added []string, removed []string) error {
-	author, err := bug.GetUser(c.repo)
+	author, err := bug.GetUser(c.repoCache.repo)
 	if err != nil {
 		return err
 	}
@@ -64,49 +56,49 @@ func (c *BugCache) ChangeLabels(added []string, removed []string) error {
 		return err
 	}
 
-	return nil
+	return c.notifyUpdated()
 }
 
 func (c *BugCache) Open() error {
-	author, err := bug.GetUser(c.repo)
+	author, err := bug.GetUser(c.repoCache.repo)
 	if err != nil {
 		return err
 	}
 
 	operations.Open(c.bug, author)
 
-	return nil
+	return c.notifyUpdated()
 }
 
 func (c *BugCache) Close() error {
-	author, err := bug.GetUser(c.repo)
+	author, err := bug.GetUser(c.repoCache.repo)
 	if err != nil {
 		return err
 	}
 
 	operations.Close(c.bug, author)
 
-	return nil
+	return c.notifyUpdated()
 }
 
 func (c *BugCache) SetTitle(title string) error {
-	author, err := bug.GetUser(c.repo)
+	author, err := bug.GetUser(c.repoCache.repo)
 	if err != nil {
 		return err
 	}
 
 	operations.SetTitle(c.bug, author, title)
 
-	return nil
+	return c.notifyUpdated()
 }
 
 func (c *BugCache) Commit() error {
-	return c.bug.Commit(c.repo)
+	return c.bug.Commit(c.repoCache.repo)
 }
 
 func (c *BugCache) CommitAsNeeded() error {
 	if c.bug.HasPendingOp() {
-		return c.bug.Commit(c.repo)
+		return c.bug.Commit(c.repoCache.repo)
 	}
 	return nil
 }

cache/bug_excerpt.go 🔗

@@ -1,6 +1,8 @@
 package cache
 
 import (
+	"encoding/gob"
+
 	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/util"
 )
@@ -19,7 +21,7 @@ type BugExcerpt struct {
 	Author bug.Person
 }
 
-func NewBugExcerpt(b *bug.Bug, snap bug.Snapshot) BugExcerpt {
+func NewBugExcerpt(b bug.Interface, snap *bug.Snapshot) BugExcerpt {
 	return BugExcerpt{
 		Id:                b.Id(),
 		CreateLamportTime: b.CreateLamportTime(),
@@ -31,6 +33,11 @@ func NewBugExcerpt(b *bug.Bug, snap bug.Snapshot) BugExcerpt {
 	}
 }
 
+// Package initialisation used to register the type for (de)serialization
+func init() {
+	gob.Register(BugExcerpt{})
+}
+
 /*
  * Sorting
  */

cache/cache.go 🔗

@@ -13,29 +13,15 @@ import (
 )
 
 const lockfile = "lock"
-
-type Cacher interface {
-	// RegisterRepository register a named repository. Use this for multi-repo setup
-	RegisterRepository(ref string, repo repository.Repo) error
-	// RegisterDefaultRepository register a unnamed repository. Use this for mono-repo setup
-	RegisterDefaultRepository(repo repository.Repo) error
-
-	// ResolveRepo retrieve a repository by name
-	ResolveRepo(ref string) (RepoCacher, error)
-	// DefaultRepo retrieve the default repository
-	DefaultRepo() (RepoCacher, error)
-
-	// Close will do anything that is needed to close the cache properly
-	Close() error
-}
+const excerptsFile = "excerpts"
 
 type RootCache struct {
-	repos map[string]RepoCacher
+	repos map[string]*RepoCache
 }
 
 func NewCache() RootCache {
 	return RootCache{
-		repos: make(map[string]RepoCacher),
+		repos: make(map[string]*RepoCache),
 	}
 }
 
@@ -46,7 +32,12 @@ func (c *RootCache) RegisterRepository(ref string, repo repository.Repo) error {
 		return err
 	}
 
-	c.repos[ref] = NewRepoCache(repo)
+	r, err := NewRepoCache(repo)
+	if err != nil {
+		return err
+	}
+
+	c.repos[ref] = r
 	return nil
 }
 
@@ -57,7 +48,12 @@ func (c *RootCache) RegisterDefaultRepository(repo repository.Repo) error {
 		return err
 	}
 
-	c.repos[""] = NewRepoCache(repo)
+	r, err := NewRepoCache(repo)
+	if err != nil {
+		return err
+	}
+
+	c.repos[""] = r
 	return nil
 }
 
@@ -84,7 +80,7 @@ func (c *RootCache) lockRepository(repo repository.Repo) error {
 }
 
 // ResolveRepo retrieve a repository by name
-func (c *RootCache) DefaultRepo() (RepoCacher, error) {
+func (c *RootCache) DefaultRepo() (*RepoCache, error) {
 	if len(c.repos) != 1 {
 		return nil, fmt.Errorf("repository is not unique")
 	}
@@ -97,7 +93,7 @@ func (c *RootCache) DefaultRepo() (RepoCacher, error) {
 }
 
 // DefaultRepo retrieve the default repository
-func (c *RootCache) ResolveRepo(ref string) (RepoCacher, error) {
+func (c *RootCache) ResolveRepo(ref string) (*RepoCache, error) {
 	r, ok := c.repos[ref]
 	if !ok {
 		return nil, fmt.Errorf("unknown repo")
@@ -105,9 +101,10 @@ func (c *RootCache) ResolveRepo(ref string) (RepoCacher, error) {
 	return r, nil
 }
 
+// Close will do anything that is needed to close the cache properly
 func (c *RootCache) Close() error {
 	for _, cachedRepo := range c.repos {
-		lockPath := repoLockFilePath(cachedRepo.Repository())
+		lockPath := repoLockFilePath(cachedRepo.repo)
 		err := os.Remove(lockPath)
 		if err != nil {
 			return err
@@ -116,6 +113,10 @@ func (c *RootCache) Close() error {
 	return nil
 }
 
+// RepoIsAvailable check is the given repository is locked by a Cache.
+// Note: this is a smart function that will cleanup the lock file if the
+// corresponding process is not there anymore.
+// If no error is returned, the repo is free to edit.
 func RepoIsAvailable(repo repository.Repo) error {
 	lockPath := repoLockFilePath(repo)
 

cache/repo_cache.go 🔗

@@ -1,8 +1,12 @@
 package cache
 
 import (
+	"bytes"
+	"encoding/gob"
 	"fmt"
 	"io"
+	"os"
+	"path"
 	"strings"
 
 	"github.com/MichaelMure/git-bug/bug"
@@ -11,39 +15,110 @@ import (
 	"github.com/MichaelMure/git-bug/util"
 )
 
-type RepoCacher interface {
-	Repository() repository.Repo
-	ResolveBug(id string) (BugCacher, error)
-	ResolveBugPrefix(prefix string) (BugCacher, error)
-	AllBugIds() ([]string, error)
-	ClearAllBugs()
-
-	// Mutations
-	NewBug(title string, message string) (BugCacher, error)
-	NewBugWithFiles(title string, message string, files []util.Hash) (BugCacher, error)
-	Fetch(remote string) (string, error)
-	MergeAll(remote string) <-chan bug.MergeResult
-	Pull(remote string, out io.Writer) error
-	Push(remote string) (string, error)
-}
-
 type RepoCache struct {
-	repo repository.Repo
-	bugs map[string]BugCacher
+	repo     repository.Repo
+	excerpts map[string]BugExcerpt
+	bugs     map[string]*BugCache
 }
 
-func NewRepoCache(r repository.Repo) RepoCacher {
-	return &RepoCache{
+func NewRepoCache(r repository.Repo) (*RepoCache, error) {
+	c := &RepoCache{
 		repo: r,
-		bugs: make(map[string]BugCacher),
+		bugs: make(map[string]*BugCache),
+	}
+
+	err := c.loadExcerpts()
+
+	if err == nil {
+		return c, nil
 	}
+
+	c.buildAllExcerpt()
+
+	return c, c.writeExcerpts()
 }
 
 func (c *RepoCache) Repository() repository.Repo {
 	return c.repo
 }
 
-func (c *RepoCache) ResolveBug(id string) (BugCacher, error) {
+// bugUpdated is a callback to trigger when the excerpt of a bug changed,
+// that is each time a bug is updated
+func (c *RepoCache) bugUpdated(id string) error {
+	b, ok := c.bugs[id]
+	if !ok {
+		panic("missing bug in the cache")
+	}
+
+	c.excerpts[id] = NewBugExcerpt(b.bug, b.Snapshot())
+
+	return c.writeExcerpts()
+}
+
+// loadExcerpts will try to read from the disk the bug excerpt file
+func (c *RepoCache) loadExcerpts() error {
+	excerptsPath := repoExcerptsFilePath(c.repo)
+
+	f, err := os.Open(excerptsPath)
+	if err != nil {
+		return err
+	}
+
+	decoder := gob.NewDecoder(f)
+
+	var excerpts map[string]BugExcerpt
+
+	err = decoder.Decode(&excerpts)
+	if err != nil {
+		return err
+	}
+
+	c.excerpts = excerpts
+	return nil
+}
+
+// writeExcerpts will serialize on disk the BugExcerpt array
+func (c *RepoCache) writeExcerpts() error {
+	var data bytes.Buffer
+
+	encoder := gob.NewEncoder(&data)
+
+	err := encoder.Encode(c.excerpts)
+	if err != nil {
+		return err
+	}
+
+	excerptsPath := repoExcerptsFilePath(c.repo)
+
+	f, err := os.Create(excerptsPath)
+	if err != nil {
+		return err
+	}
+
+	_, err = f.Write(data.Bytes())
+	if err != nil {
+		return err
+	}
+
+	return f.Close()
+}
+
+func repoExcerptsFilePath(repo repository.Repo) string {
+	return path.Join(repo.GetPath(), ".git", "git-bug", excerptsFile)
+}
+
+func (c *RepoCache) buildAllExcerpt() {
+	c.excerpts = make(map[string]BugExcerpt)
+
+	allBugs := bug.ReadAllLocalBugs(c.repo)
+
+	for b := range allBugs {
+		snap := b.Bug.Compile()
+		c.excerpts[b.Bug.Id()] = NewBugExcerpt(b.Bug, &snap)
+	}
+}
+
+func (c *RepoCache) ResolveBug(id string) (*BugCache, error) {
 	cached, ok := c.bugs[id]
 	if ok {
 		return cached, nil
@@ -54,13 +129,13 @@ func (c *RepoCache) ResolveBug(id string) (BugCacher, error) {
 		return nil, err
 	}
 
-	cached = NewBugCache(c.repo, b)
+	cached = NewBugCache(c, b)
 	c.bugs[id] = cached
 
 	return cached, nil
 }
 
-func (c *RepoCache) ResolveBugPrefix(prefix string) (BugCacher, error) {
+func (c *RepoCache) ResolveBugPrefix(prefix string) (*BugCache, error) {
 	// preallocate but empty
 	matching := make([]string, 0, 5)
 
@@ -87,7 +162,7 @@ func (c *RepoCache) ResolveBugPrefix(prefix string) (BugCacher, error) {
 		return nil, err
 	}
 
-	cached := NewBugCache(c.repo, b)
+	cached := NewBugCache(c, b)
 	c.bugs[b.Id()] = cached
 
 	return cached, nil
@@ -98,14 +173,14 @@ func (c *RepoCache) AllBugIds() ([]string, error) {
 }
 
 func (c *RepoCache) ClearAllBugs() {
-	c.bugs = make(map[string]BugCacher)
+	c.bugs = make(map[string]*BugCache)
 }
 
-func (c *RepoCache) NewBug(title string, message string) (BugCacher, error) {
+func (c *RepoCache) NewBug(title string, message string) (*BugCache, error) {
 	return c.NewBugWithFiles(title, message, nil)
 }
 
-func (c *RepoCache) NewBugWithFiles(title string, message string, files []util.Hash) (BugCacher, error) {
+func (c *RepoCache) NewBugWithFiles(title string, message string, files []util.Hash) (*BugCache, error) {
 	author, err := bug.GetUser(c.repo)
 	if err != nil {
 		return nil, err
@@ -121,7 +196,7 @@ func (c *RepoCache) NewBugWithFiles(title string, message string, files []util.H
 		return nil, err
 	}
 
-	cached := NewBugCache(c.repo, b)
+	cached := NewBugCache(c, b)
 	c.bugs[b.Id()] = cached
 
 	return cached, nil

graphql/models/models.go 🔗

@@ -12,11 +12,11 @@ type ConnectionInput struct {
 }
 
 type Repository struct {
-	Cache cache.Cacher
-	Repo  cache.RepoCacher
+	Cache *cache.RootCache
+	Repo  *cache.RepoCache
 }
 
 type RepositoryMutation struct {
-	Cache cache.Cacher
-	Repo  cache.RepoCacher
+	Cache *cache.RootCache
+	Repo  *cache.RepoCache
 }

graphql/resolvers/mutation.go 🔗

@@ -9,10 +9,10 @@ import (
 )
 
 type mutationResolver struct {
-	cache cache.Cacher
+	cache *cache.RootCache
 }
 
-func (r mutationResolver) getRepo(repoRef *string) (cache.RepoCacher, error) {
+func (r mutationResolver) getRepo(repoRef *string) (*cache.RepoCache, error) {
 	if repoRef != nil {
 		return r.cache.ResolveRepo(*repoRef)
 	}

graphql/resolvers/query.go 🔗

@@ -8,7 +8,7 @@ import (
 )
 
 type rootQueryResolver struct {
-	cache cache.Cacher
+	cache *cache.RootCache
 }
 
 func (r rootQueryResolver) DefaultRepository(ctx context.Context) (*models.Repository, error) {

termui/bug_table.go 🔗

@@ -19,14 +19,14 @@ const bugTableInstructionView = "bugTableInstructionView"
 const remote = "origin"
 
 type bugTable struct {
-	repo         cache.RepoCacher
+	repo         *cache.RepoCache
 	allIds       []string
-	bugs         []cache.BugCacher
+	bugs         []*cache.BugCache
 	pageCursor   int
 	selectCursor int
 }
 
-func newBugTable(cache cache.RepoCacher) *bugTable {
+func newBugTable(cache *cache.RepoCache) *bugTable {
 	return &bugTable{
 		repo:         cache,
 		pageCursor:   0,
@@ -230,14 +230,14 @@ func (bt *bugTable) doPaginate(allIds []string, max int) error {
 	nb := minInt(len(allIds)-bt.pageCursor, max)
 
 	if nb < 0 {
-		bt.bugs = []cache.BugCacher{}
+		bt.bugs = []*cache.BugCache{}
 		return nil
 	}
 
 	// slice the data
 	ids := allIds[bt.pageCursor : bt.pageCursor+nb]
 
-	bt.bugs = make([]cache.BugCacher, len(ids))
+	bt.bugs = make([]*cache.BugCache, len(ids))
 
 	for i, id := range ids {
 		b, err := bt.repo.ResolveBug(id)

termui/show_bug.go 🔗

@@ -19,8 +19,8 @@ const showBugHeaderView = "showBugHeaderView"
 const timeLayout = "Jan 2 2006"
 
 type showBug struct {
-	cache              cache.RepoCacher
-	bug                cache.BugCacher
+	cache              *cache.RepoCache
+	bug                *cache.BugCache
 	childViews         []string
 	mainSelectableView []string
 	sideSelectableView []string
@@ -29,13 +29,13 @@ type showBug struct {
 	scroll             int
 }
 
-func newShowBug(cache cache.RepoCacher) *showBug {
+func newShowBug(cache *cache.RepoCache) *showBug {
 	return &showBug{
 		cache: cache,
 	}
 }
 
-func (sb *showBug) SetBug(bug cache.BugCacher) {
+func (sb *showBug) SetBug(bug *cache.BugCache) {
 	sb.bug = bug
 	sb.scroll = 0
 	sb.selected = ""

termui/termui.go 🔗

@@ -13,7 +13,7 @@ var errTerminateMainloop = errors.New("terminate gocui mainloop")
 type termUI struct {
 	g      *gocui.Gui
 	gError chan error
-	cache  cache.RepoCacher
+	cache  *cache.RepoCache
 
 	activeWindow window
 
@@ -43,7 +43,11 @@ type window interface {
 
 // Run will launch the termUI in the terminal
 func Run(repo repository.Repo) error {
-	c := cache.NewRepoCache(repo)
+	c, err := cache.NewRepoCache(repo)
+
+	if err != nil {
+		return err
+	}
 
 	ui = &termUI{
 		gError:     make(chan error, 1),
@@ -58,7 +62,7 @@ func Run(repo repository.Repo) error {
 
 	initGui(nil)
 
-	err := <-ui.gError
+	err = <-ui.gError
 
 	if err != nil && err != gocui.ErrQuit {
 		return err
@@ -157,7 +161,7 @@ func quit(g *gocui.Gui, v *gocui.View) error {
 	return gocui.ErrQuit
 }
 
-func newBugWithEditor(repo cache.RepoCacher) error {
+func newBugWithEditor(repo *cache.RepoCache) error {
 	// This is somewhat hacky.
 	// As there is no way to pause gocui, run the editor and restart gocui,
 	// we have to stop it entirely and start a new one later.
@@ -176,7 +180,7 @@ func newBugWithEditor(repo cache.RepoCacher) error {
 		return err
 	}
 
-	var b cache.BugCacher
+	var b *cache.BugCache
 	if err == input.ErrEmptyTitle {
 		ui.msgPopup.Activate(msgPopupErrorTitle, "Empty title, aborting.")
 		initGui(nil)
@@ -197,7 +201,7 @@ func newBugWithEditor(repo cache.RepoCacher) error {
 	}
 }
 
-func addCommentWithEditor(bug cache.BugCacher) error {
+func addCommentWithEditor(bug *cache.BugCache) error {
 	// This is somewhat hacky.
 	// As there is no way to pause gocui, run the editor and restart gocui,
 	// we have to stop it entirely and start a new one later.
@@ -230,7 +234,7 @@ func addCommentWithEditor(bug cache.BugCacher) error {
 	return errTerminateMainloop
 }
 
-func setTitleWithEditor(bug cache.BugCacher) error {
+func setTitleWithEditor(bug *cache.BugCache) error {
 	// This is somewhat hacky.
 	// As there is no way to pause gocui, run the editor and restart gocui,
 	// we have to stop it entirely and start a new one later.