fix(tui): cache ref commits and track annotated tags

Ayman Bagabas created

* Keep track of the selected ref in each bubble
* Lazy cache commits of each ref on demand
* Find the target hash of annotated tags

Change summary

internal/git/git.go                      | 191 ++++++++++++++++++++-----
internal/tui/bubbles/git/about/bubble.go |   9 +
internal/tui/bubbles/git/bubble.go       |  16 +
internal/tui/bubbles/git/log/bubble.go   |  22 ++
internal/tui/bubbles/git/refs/bubble.go  |   6 
internal/tui/bubbles/git/tree/bubble.go  |  13 +
internal/tui/bubbles/git/types/git.go    |   8 
internal/tui/bubbles/git/types/reset.go  |   7 
internal/tui/commands.go                 |  11 -
internal/tui/git.go                      | 105 --------------
10 files changed, 215 insertions(+), 173 deletions(-)

Detailed changes

internal/git/git.go 🔗

@@ -9,8 +9,10 @@ import (
 	"sync"
 	"time"
 
+	gitypes "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
 	"github.com/go-git/go-billy/v5/memfs"
 	"github.com/go-git/go-git/v5"
+	"github.com/go-git/go-git/v5/plumbing"
 	"github.com/go-git/go-git/v5/plumbing/object"
 	"github.com/go-git/go-git/v5/plumbing/transport"
 	"github.com/go-git/go-git/v5/storage/memory"
@@ -25,22 +27,147 @@ type Repo struct {
 	Repository  *git.Repository
 	Readme      string
 	LastUpdated *time.Time
-	commits     CommitLog
+	refCommits  map[plumbing.Hash]gitypes.Commits
+	ref         *plumbing.Reference
 }
 
-// RepoCommit contains metadata for a Git commit.
-type RepoCommit struct {
-	Name   string
-	Commit *object.Commit
+// GetName returns the name of the repository.
+func (r *Repo) GetName() string {
+	return r.Name
 }
 
-// CommitLog is a series of Git commits.
-type CommitLog []RepoCommit
+// GetReference returns the reference for a repository.
+func (r *Repo) GetReference() *plumbing.Reference {
+	return r.ref
+}
+
+// SetReference sets the repository head reference.
+func (r *Repo) SetReference(ref *plumbing.Reference) error {
+	r.ref = ref
+	return nil
+}
+
+// GetRepository returns the underlying go-git repository object.
+func (r *Repo) GetRepository() *git.Repository {
+	return r.Repository
+}
+
+// Tree returns the git tree for a given path.
+func (r *Repo) Tree(ref *plumbing.Reference, path string) (*object.Tree, error) {
+	path = filepath.Clean(path)
+	hash, err := r.targetHash(ref)
+	if err != nil {
+		return nil, err
+	}
+	c, err := r.Repository.CommitObject(hash)
+	if err != nil {
+		return nil, err
+	}
+	t, err := c.Tree()
+	if err != nil {
+		return nil, err
+	}
+	if path == "." {
+		return t, nil
+	}
+	return t.Tree(path)
+}
 
-func (cl CommitLog) Len() int      { return len(cl) }
-func (cl CommitLog) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }
-func (cl CommitLog) Less(i, j int) bool {
-	return cl[i].Commit.Author.When.After(cl[j].Commit.Author.When)
+// GetCommits returns the commits for a repository.
+func (r *Repo) GetCommits(ref *plumbing.Reference) (gitypes.Commits, error) {
+	hash, err := r.targetHash(ref)
+	if err != nil {
+		return nil, err
+	}
+	// return cached commits if available
+	commits, ok := r.refCommits[hash]
+	if ok {
+		return commits, nil
+	}
+	log.Printf("caching commits for %s/%s: %s", r.Name, ref.Name(), ref.Hash())
+	commits = gitypes.Commits{}
+	co, err := r.Repository.CommitObject(hash)
+	if err != nil {
+		return nil, err
+	}
+	// traverse the commit tree to get all commits
+	commits = append(commits, &gitypes.Commit{Commit: co})
+	for {
+		co, err = co.Parent(0)
+		if err != nil {
+			if err == object.ErrParentNotFound {
+				err = nil
+			}
+			break
+		}
+		commits = append(commits, &gitypes.Commit{Commit: co})
+	}
+	if err != nil {
+		return nil, err
+	}
+	sort.Sort(commits)
+	// cache the commits in the repo
+	r.refCommits[hash] = commits
+	return commits, nil
+}
+
+// targetHash returns the target hash for a given reference. If reference is an
+// annotated tag, find the target hash for that tag.
+func (r *Repo) targetHash(ref *plumbing.Reference) (plumbing.Hash, error) {
+	hash := ref.Hash()
+	if ref.Type() != plumbing.HashReference {
+		return plumbing.ZeroHash, plumbing.ErrInvalidType
+	}
+	if ref.Name().IsTag() {
+		to, err := r.Repository.TagObject(hash)
+		switch err {
+		case nil:
+			// annotated tag (object has a target hash)
+			hash = to.Target
+		case plumbing.ErrObjectNotFound:
+			// lightweight tag (hash points to a commit)
+		default:
+			return plumbing.ZeroHash, err
+		}
+	}
+	return hash, nil
+}
+
+// loadCommits loads the commits for a repository.
+func (r *Repo) loadCommits(ref *plumbing.Reference) (gitypes.Commits, error) {
+	commits := gitypes.Commits{}
+	hash, err := r.targetHash(ref)
+	if err != nil {
+		return nil, err
+	}
+	l, err := r.Repository.Log(&git.LogOptions{
+		Order: git.LogOrderCommitterTime,
+		From:  hash,
+	})
+	if err != nil {
+		return nil, err
+	}
+	defer l.Close()
+	err = l.ForEach(func(c *object.Commit) error {
+		commits = append(commits, &gitypes.Commit{Commit: c})
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+	return commits, nil
+}
+
+// GetReadme returns the readme for a repository.
+func (r *Repo) GetReadme() string {
+	if r.Readme != "" {
+		return r.Readme
+	}
+	md, err := r.LatestFile("README.md")
+	if err != nil {
+		return ""
+	}
+	return md
 }
 
 // RepoSource is a reference to an on-disk repositories.
@@ -106,16 +233,6 @@ func (rs *RepoSource) InitRepo(name string, bare bool) (*Repo, error) {
 	return r, nil
 }
 
-func (r *Repo) GetCommits(limit int) CommitLog {
-	if limit <= 0 {
-		return r.commits
-	}
-	if limit > len(r.commits) {
-		limit = len(r.commits)
-	}
-	return r.commits[:limit]
-}
-
 // LoadRepos opens Git repositories.
 func (rs *RepoSource) LoadRepos() error {
 	rs.mtx.Lock()
@@ -141,37 +258,29 @@ func (rs *RepoSource) LoadRepos() error {
 }
 
 func (rs *RepoSource) loadRepo(name string, rg *git.Repository) (*Repo, error) {
-	r := &Repo{Name: name}
-	r.commits = make([]RepoCommit, 0)
-	r.Repository = rg
-	l, err := rg.Log(&git.LogOptions{All: true})
+	r := &Repo{
+		Name:       name,
+		Repository: rg,
+	}
+	r.refCommits = make(map[plumbing.Hash]gitypes.Commits)
+	ref, err := rg.Head()
 	if err != nil {
 		return nil, err
 	}
-	err = l.ForEach(func(c *object.Commit) error {
-		if r.LastUpdated == nil {
-			r.LastUpdated = &c.Author.When
-			rf, err := c.File("README.md")
-			if err == nil {
-				rmd, err := rf.Contents()
-				if err == nil {
-					r.Readme = rmd
-				}
-			}
-		}
-		r.commits = append(r.commits, RepoCommit{Name: name, Commit: c})
-		return nil
-	})
+	r.ref = ref
+	rm, err := r.LatestFile("README.md")
 	if err != nil {
 		return nil, err
 	}
-	sort.Sort(r.commits)
+	r.Readme = rm
 	return r, nil
 }
 
 // LatestFile returns the latest file at the specified path in the repository.
 func (r *Repo) LatestFile(path string) (string, error) {
-	lg, err := r.Repository.Log(&git.LogOptions{})
+	lg, err := r.Repository.Log(&git.LogOptions{
+		From: r.GetReference().Hash(),
+	})
 	if err != nil {
 		return "", err
 	}

internal/tui/bubbles/git/about/bubble.go 🔗

@@ -3,9 +3,11 @@ package about
 import (
 	"github.com/charmbracelet/bubbles/viewport"
 	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/refs"
 	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
 	vp "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/viewport"
 	"github.com/charmbracelet/soft-serve/internal/tui/style"
+	"github.com/go-git/go-git/v5/plumbing"
 )
 
 type Bubble struct {
@@ -16,6 +18,7 @@ type Bubble struct {
 	heightMargin   int
 	width          int
 	widthMargin    int
+	ref            *plumbing.Reference
 }
 
 func NewBubble(repo types.Repo, styles *style.Styles, width, wm, height, hm int) *Bubble {
@@ -27,6 +30,7 @@ func NewBubble(repo types.Repo, styles *style.Styles, width, wm, height, hm int)
 		styles:       styles,
 		widthMargin:  wm,
 		heightMargin: hm,
+		ref:          repo.GetReference(),
 	}
 	b.SetSize(width, height)
 	return b
@@ -53,6 +57,9 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		case "R":
 			b.GotoTop()
 		}
+	case refs.RefMsg:
+		b.ref = msg
+		return b, b.setupCmd
 	}
 	rv, cmd := b.readmeViewport.Update(msg)
 	b.readmeViewport = rv.(*vp.ViewportBubble)
@@ -87,7 +94,7 @@ func (b *Bubble) glamourize() (string, error) {
 func (b *Bubble) setupCmd() tea.Msg {
 	md, err := b.glamourize()
 	if err != nil {
-		return types.ErrMsg{err}
+		return types.ErrMsg{Err: err}
 	}
 	b.readmeViewport.Viewport.SetContent(md)
 	b.GotoTop()

internal/tui/bubbles/git/bubble.go 🔗

@@ -34,6 +34,7 @@ type Bubble struct {
 	widthMargin  int
 	style        *style.Styles
 	boxes        []tea.Model
+	ref          *plumbing.Reference
 }
 
 func NewBubble(repo types.Repo, styles *style.Styles, width, wm, height, hm int) *Bubble {
@@ -46,6 +47,7 @@ func NewBubble(repo types.Repo, styles *style.Styles, width, wm, height, hm int)
 		heightMargin: hm,
 		style:        styles,
 		boxes:        make([]tea.Model, 4),
+		ref:          repo.GetReference(),
 	}
 	heightMargin := hm + lipgloss.Height(b.headerView())
 	b.boxes[aboutPage] = about.NewBubble(repo, b.style, b.width, wm, b.height, heightMargin)
@@ -63,7 +65,7 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	cmds := make([]tea.Cmd, 0)
 	switch msg := msg.(type) {
 	case tea.KeyMsg:
-		if b.repo.Name() != "config" {
+		if b.repo.GetName() != "config" {
 			switch msg.String() {
 			case "R":
 				b.state = aboutPage
@@ -87,6 +89,14 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 	case refs.RefMsg:
 		b.state = treePage
+		b.ref = msg
+		for i, bx := range b.boxes {
+			m, cmd := bx.Update(msg)
+			b.boxes[i] = m
+			if cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		}
 	}
 	m, cmd := b.boxes[b.state].Update(msg)
 	b.boxes[b.state] = m
@@ -99,7 +109,7 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 func (b *Bubble) Help() []types.HelpEntry {
 	h := []types.HelpEntry{}
 	h = append(h, b.boxes[b.state].(types.BubbleHelper).Help()...)
-	if b.repo.Name() != "config" {
+	if b.repo.GetName() != "config" {
 		h = append(h, types.HelpEntry{"R", "readme"})
 		h = append(h, types.HelpEntry{"F", "files"})
 		h = append(h, types.HelpEntry{"C", "commits"})
@@ -109,7 +119,7 @@ func (b *Bubble) Help() []types.HelpEntry {
 }
 
 func (b *Bubble) Reference() plumbing.ReferenceName {
-	return b.repo.GetReference().Name()
+	return b.ref.Name()
 }
 
 func (b *Bubble) headerView() string {

internal/tui/bubbles/git/log/bubble.go 🔗

@@ -12,10 +12,12 @@ import (
 	"github.com/charmbracelet/bubbles/viewport"
 	tea "github.com/charmbracelet/bubbletea"
 	gansi "github.com/charmbracelet/glamour/ansi"
+	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/refs"
 	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
 	vp "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/viewport"
 	"github.com/charmbracelet/soft-serve/internal/tui/style"
 	"github.com/dustin/go-humanize/english"
+	"github.com/go-git/go-git/v5/plumbing"
 	"github.com/go-git/go-git/v5/plumbing/object"
 )
 
@@ -91,6 +93,7 @@ type Bubble struct {
 	list           list.Model
 	state          sessionState
 	commitViewport *vp.ViewportBubble
+	ref            *plumbing.Reference
 	style          *style.Styles
 	width          int
 	widthMargin    int
@@ -122,16 +125,23 @@ func NewBubble(repo types.Repo, styles *style.Styles, width, widthMargin, height
 		height:       height,
 		heightMargin: heightMargin,
 		list:         l,
+		ref:          repo.GetReference(),
 	}
 	b.SetSize(width, height)
 	return b
 }
 
+func (b *Bubble) reset() tea.Cmd {
+	b.state = logState
+	b.list.Select(0)
+	return b.updateItems()
+}
+
 func (b *Bubble) updateItems() tea.Cmd {
 	items := make([]list.Item, 0)
-	cc, err := b.repo.GetCommits(0)
+	cc, err := b.repo.GetCommits(b.ref)
 	if err != nil {
-		return func() tea.Msg { return types.ErrMsg{err} }
+		return func() tea.Msg { return types.ErrMsg{Err: err} }
 	}
 	for _, c := range cc {
 		items = append(items, item{c})
@@ -148,7 +158,7 @@ func (b *Bubble) GotoTop() {
 }
 
 func (b *Bubble) Init() tea.Cmd {
-	return b.updateItems()
+	return b.reset()
 }
 
 func (b *Bubble) SetSize(width, height int) {
@@ -168,9 +178,7 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case tea.KeyMsg:
 		switch msg.String() {
 		case "C":
-			b.state = logState
-			b.list.Select(0)
-			cmds = append(cmds, b.updateItems())
+			return b, b.reset()
 		case "enter", "right", "l":
 			if b.state == logState {
 				cmds = append(cmds, b.loadCommit())
@@ -189,6 +197,8 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		b.state = commitState
 		b.commitViewport.Viewport.SetContent(content)
 		b.GotoTop()
+	case refs.RefMsg:
+		b.ref = msg
 	}
 
 	switch b.state {

internal/tui/bubbles/git/refs/bubble.go 🔗

@@ -73,6 +73,7 @@ type Bubble struct {
 	widthMargin  int
 	height       int
 	heightMargin int
+	ref          *plumbing.Reference
 }
 
 func NewBubble(repo types.Repo, styles *style.Styles, width, widthMargin, height, heightMargin int) *Bubble {
@@ -92,14 +93,15 @@ func NewBubble(repo types.Repo, styles *style.Styles, width, widthMargin, height
 		widthMargin:  widthMargin,
 		heightMargin: heightMargin,
 		list:         l,
+		ref:          repo.GetReference(),
 	}
 	b.SetSize(width, height)
 	return b
 }
 
 func (b *Bubble) SetBranch(ref *plumbing.Reference) (tea.Model, tea.Cmd) {
+	b.ref = ref
 	return b, func() tea.Msg {
-		b.repo.SetReference(ref)
 		return RefMsg(ref)
 	}
 }
@@ -121,7 +123,7 @@ func (b *Bubble) Help() []types.HelpEntry {
 func (b *Bubble) updateItems() tea.Cmd {
 	its := make(items, 0)
 	tags := make(items, 0)
-	ri, err := b.repo.Repository().References()
+	ri, err := b.repo.GetRepository().References()
 	if err != nil {
 		return nil
 	}

internal/tui/bubbles/git/tree/bubble.go 🔗

@@ -128,6 +128,7 @@ type Bubble struct {
 	error        types.ErrMsg
 	fileViewport *vp.ViewportBubble
 	lastSelected []int
+	ref          *plumbing.Reference
 }
 
 func NewBubble(repo types.Repo, styles *style.Styles, width, widthMargin, height, heightMargin int) *Bubble {
@@ -153,6 +154,7 @@ func NewBubble(repo types.Repo, styles *style.Styles, width, widthMargin, height
 		heightMargin: heightMargin,
 		list:         l,
 		state:        treeState,
+		ref:          repo.GetReference(),
 	}
 	b.SetSize(width, height)
 	return b
@@ -183,7 +185,7 @@ func (b *Bubble) Help() []types.HelpEntry {
 
 func (b *Bubble) updateItems() tea.Cmd {
 	its := make(items, 0)
-	t, err := b.repo.Tree(b.path)
+	t, err := b.repo.Tree(b.ref, b.path)
 	if err != nil {
 		return func() tea.Msg { return types.ErrMsg{err} }
 	}
@@ -219,6 +221,14 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		b.SetSize(msg.Width, msg.Height)
 
 	case tea.KeyMsg:
+		if b.state == errorState {
+			ref := b.repo.GetReference()
+			b.ref = ref
+			return b, tea.Batch(b.reset(), func() tea.Msg {
+				return ref
+			})
+		}
+
 		switch msg.String() {
 		case "F":
 			return b, b.reset()
@@ -252,6 +262,7 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 
 	case refs.RefMsg:
+		b.ref = msg
 		return b, b.reset()
 
 	case types.ErrMsg:

internal/tui/bubbles/git/types/git.go 🔗

@@ -7,13 +7,13 @@ import (
 )
 
 type Repo interface {
-	Name() string
+	GetName() string
 	GetReference() *plumbing.Reference
 	SetReference(*plumbing.Reference) error
 	GetReadme() string
-	GetCommits(limit int) (Commits, error)
-	Repository() *git.Repository
-	Tree(path string) (*object.Tree, error)
+	GetCommits(*plumbing.Reference) (Commits, error)
+	GetRepository() *git.Repository
+	Tree(*plumbing.Reference, string) (*object.Tree, error)
 }
 
 type Commit struct {

internal/tui/commands.go 🔗

@@ -96,9 +96,6 @@ func (b *Bubble) menuEntriesFromSource() ([]MenuEntry, error) {
 }
 
 func (b *Bubble) newMenuEntry(name string, rn string) (MenuEntry, error) {
-	gr := &Repo{
-		name: rn,
-	}
 	me := MenuEntry{Name: name, Repo: rn}
 	r, err := b.config.Source.GetRepo(rn)
 	if err != nil {
@@ -111,19 +108,13 @@ func (b *Bubble) newMenuEntry(name string, rn string) (MenuEntry, error) {
 		}
 		r.Readme = md
 	}
-	gr.repo = r.Repository
-	gr.readme = r.Readme
-	gr.ref, err = r.Repository.Head()
-	if err != nil {
-		return me, err
-	}
 	boxLeftWidth := b.styles.Menu.GetWidth() + b.styles.Menu.GetHorizontalFrameSize()
 	// TODO: also send this along with a tea.WindowSizeMsg
 	var heightMargin = lipgloss.Height(b.headerView()) +
 		lipgloss.Height(b.footerView()) +
 		b.styles.RepoBody.GetVerticalFrameSize() +
 		b.styles.App.GetVerticalMargins()
-	rb := repo.NewBubble(rn, b.config.Host, b.config.Port, gr, b.styles, b.width, boxLeftWidth, b.height, heightMargin)
+	rb := repo.NewBubble(rn, b.config.Host, b.config.Port, r, b.styles, b.width, boxLeftWidth, b.height, heightMargin)
 	initCmd := rb.Init()
 	msg := initCmd()
 	switch msg := msg.(type) {

internal/tui/git.go 🔗

@@ -1,105 +0,0 @@
-package tui
-
-import (
-	"path/filepath"
-
-	gitypes "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
-	"github.com/go-git/go-git/v5"
-	"github.com/go-git/go-git/v5/plumbing"
-	"github.com/go-git/go-git/v5/plumbing/object"
-)
-
-type Repo struct {
-	name   string
-	repo   *git.Repository
-	readme string
-	ref    *plumbing.Reference
-}
-
-func (r *Repo) Name() string {
-	return r.name
-}
-
-func (r *Repo) GetReference() *plumbing.Reference {
-	return r.ref
-}
-
-func (r *Repo) SetReference(ref *plumbing.Reference) error {
-	r.ref = ref
-	return nil
-}
-
-func (r *Repo) Repository() *git.Repository {
-	return r.repo
-}
-
-func (r *Repo) Tree(path string) (*object.Tree, error) {
-	path = filepath.Clean(path)
-	c, err := r.repo.CommitObject(r.ref.Hash())
-	if err != nil {
-		return nil, err
-	}
-	t, err := c.Tree()
-	if err != nil {
-		return nil, err
-	}
-	if path == "." {
-		return t, nil
-	}
-	return t.Tree(path)
-}
-
-func (r *Repo) GetCommits(limit int) (gitypes.Commits, error) {
-	commits := gitypes.Commits{}
-	l, err := r.repo.Log(&git.LogOptions{
-		Order: git.LogOrderCommitterTime,
-		From:  r.ref.Hash(),
-	})
-	if err != nil {
-		return nil, err
-	}
-	err = l.ForEach(func(c *object.Commit) error {
-		commits = append(commits, &gitypes.Commit{c})
-		return nil
-	})
-	if err != nil {
-		return nil, err
-	}
-	if limit <= 0 || limit > len(commits) {
-		limit = len(commits)
-	}
-	return commits[:limit], nil
-}
-
-func (r *Repo) GetReadme() string {
-	if r.readme != "" {
-		return r.readme
-	}
-	md, err := r.readFile("README.md")
-	if err != nil {
-		return ""
-	}
-	return md
-}
-
-func (r *Repo) readFile(path string) (string, error) {
-	lg, err := r.repo.Log(&git.LogOptions{
-		From: r.ref.Hash(),
-	})
-	if err != nil {
-		return "", err
-	}
-	c, err := lg.Next()
-	if err != nil {
-		return "", err
-	}
-	f, err := c.File(path)
-	if err != nil {
-		return "", err
-	}
-	content, err := f.Contents()
-	if err != nil {
-		return "", err
-	}
-	return content, nil
-}