refactor: use gogs/git-module

Ayman Bagabas created

* use gogs/git-module to handle git operations
* lazy load and cache repo commits on a per page basis
* fix loading repo twice on startup
* use groupcache/lru to cache commit diffs
* move git tui to pkg/tui
* use pkg/git as a friendlier wrapper around gogs/git-module
* fix redrawing the readme every time a repo is selected

Change summary

go.mod                                   |  14 
go.sum                                   |  23 
internal/config/config.go                |  73 +---
internal/git/git.go                      | 393 ++++++++-----------------
internal/tui/bubble.go                   |  17 
internal/tui/bubbles/git/types/git.go    |  30 -
internal/tui/bubbles/repo/bubble.go      |  19 
internal/tui/bubbles/selection/bubble.go |   6 
internal/tui/commands.go                 |   4 
pkg/git/commit.go                        |  42 ++
pkg/git/errors.go                        |   9 
pkg/git/patch.go                         | 329 +++++++++++++++++++++
pkg/git/reference.go                     |  76 +++++
pkg/git/repo.go                          | 185 ++++++++++++
pkg/git/tree.go                          | 191 ++++++++++++
pkg/tui/about/bubble.go                  |  36 +-
pkg/tui/bubble.go                        |  46 +-
pkg/tui/common/consts.go                 |   2 
pkg/tui/common/error.go                  |   2 
pkg/tui/common/formatter.go              |   2 
pkg/tui/common/git.go                    |  16 +
pkg/tui/common/help.go                   |   2 
pkg/tui/common/reset.go                  |   2 
pkg/tui/common/utils.go                  |   2 
pkg/tui/log/bubble.go                    | 255 ++++++---------
pkg/tui/refs/bubble.go                   |  46 +-
pkg/tui/tree/bubble.go                   | 125 +++----
pkg/tui/viewport/viewport_patch.go       |   0 
server/middleware.go                     |  56 +--
server/server.go                         |   1 
30 files changed, 1,313 insertions(+), 691 deletions(-)

Detailed changes

go.mod 🔗

@@ -12,18 +12,20 @@ require (
 	github.com/charmbracelet/wish v0.2.1-0.20220208182816-534842b53d2a
 	github.com/dustin/go-humanize v1.0.0
 	github.com/gliderlabs/ssh v0.3.3
-	github.com/go-git/go-billy/v5 v5.3.1
-	github.com/go-git/go-git/v5 v5.4.2
+	github.com/go-git/go-billy/v5 v5.3.1 // indirect
+	github.com/go-git/go-git/v5 v5.4.3-0.20210630082519-b4368b2a2ca4 // indirect
 	github.com/matryer/is v1.2.0
 	github.com/muesli/reflow v0.3.0
 	github.com/muesli/termenv v0.11.0
-	github.com/sergi/go-diff v1.1.0 // indirect
+	github.com/sergi/go-diff v1.1.0
 	golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce
 	gopkg.in/yaml.v2 v2.4.0
 )
 
 require (
 	github.com/gobwas/glob v0.2.3
+	github.com/gogs/git-module v1.5.0
+	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da
 	github.com/muesli/mango v0.1.0
 	github.com/muesli/roff v0.1.0
 )
@@ -40,7 +42,6 @@ require (
 	github.com/dlclark/regexp2 v1.4.0 // indirect
 	github.com/emirpasic/gods v1.12.0 // indirect
 	github.com/go-git/gcfg v1.5.0 // indirect
-	github.com/google/go-cmp v0.5.6 // indirect
 	github.com/gorilla/css v1.0.0 // indirect
 	github.com/imdario/mergo v0.3.12 // indirect
 	github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
@@ -48,6 +49,7 @@ require (
 	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
 	github.com/mattn/go-isatty v0.0.14 // indirect
 	github.com/mattn/go-runewidth v0.0.13 // indirect
+	github.com/mcuadros/go-version v0.0.0-20190308113854-92cdf37c5b75 // indirect
 	github.com/microcosm-cc/bluemonday v1.0.17 // indirect
 	github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a // indirect
 	github.com/mitchellh/go-homedir v1.1.0 // indirect
@@ -58,10 +60,8 @@ require (
 	github.com/xanzy/ssh-agent v0.3.1 // indirect
 	github.com/yuin/goldmark v1.4.4 // indirect
 	github.com/yuin/goldmark-emoji v1.0.1 // indirect
-	golang.org/x/net v0.0.0-20220111093109-d55c255bac03 // indirect
+	golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd // indirect
 	golang.org/x/sys v0.0.0-20220111092808-5a964db01320 // indirect
 	golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
-	golang.org/x/text v0.3.7 // indirect
 	gopkg.in/warnings.v0 v0.1.2 // indirect
-	gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
 )

go.sum 🔗

@@ -59,13 +59,17 @@ github.com/go-git/go-billy/v5 v5.3.1 h1:CPiOUAzKtMRvolEKw+bG1PLRpT7D3LIs3/3ey4Ai
 github.com/go-git/go-billy/v5 v5.3.1/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
 github.com/go-git/go-git-fixtures/v4 v4.2.1 h1:n9gGL1Ct/yIw+nfsfr8s4+sbhT+Ncu2SubfXjIWgci8=
 github.com/go-git/go-git-fixtures/v4 v4.2.1/go.mod h1:K8zd3kDUAykwTdDCr+I0per6Y6vMiRR/nnVTBtavnB0=
-github.com/go-git/go-git/v5 v5.4.2 h1:BXyZu9t0VkbiHtqrsvdq39UDhGJTl1h55VW6CSC4aY4=
 github.com/go-git/go-git/v5 v5.4.2/go.mod h1:gQ1kArt6d+n+BGd+/B/I74HwRTLhth2+zti4ihgckDc=
+github.com/go-git/go-git/v5 v5.4.3-0.20210630082519-b4368b2a2ca4 h1:1RSUwVK7VjTeA82kcLIqz1EU70QRwFdZUlJW58gP4GY=
+github.com/go-git/go-git/v5 v5.4.3-0.20210630082519-b4368b2a2ca4/go.mod h1:gQ1kArt6d+n+BGd+/B/I74HwRTLhth2+zti4ihgckDc=
 github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
 github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
+github.com/gogs/git-module v1.5.0 h1:2aAO79c36R3L6TdKutbVJwr0YwSWfRbPNP456yxDXtk=
+github.com/gogs/git-module v1.5.0/go.mod h1:oN37FFStFjdnTJXsSbhIHKJXh2YeDsEcXPATVz/oeuQ=
+github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
+github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY=
 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
-github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
-github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
 github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
 github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU=
@@ -98,6 +102,8 @@ github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRC
 github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
 github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
 github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mcuadros/go-version v0.0.0-20190308113854-92cdf37c5b75 h1:Pijfgr7ZuvX7QIQiEwLdRVr3RoMG+i0SbBO1Qu+7yVk=
+github.com/mcuadros/go-version v0.0.0-20190308113854-92cdf37c5b75/go.mod h1:76rfSfYPWj01Z85hUf/ituArm797mNKcvINh1OlsZKo=
 github.com/microcosm-cc/bluemonday v1.0.17 h1:Z1a//hgsQ4yjC+8zEkV8IWySkXnsxmdSY642CTFQb5Y=
 github.com/microcosm-cc/bluemonday v1.0.17/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM=
 github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a h1:eU8j/ClY2Ty3qdHnn0TyW3ivFoPC/0F1gQZz8yTxbbE=
@@ -160,8 +166,10 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
 golang.org/x/net v0.0.0-20210326060303-6b1517762897/go.mod h1:uSPa2vr4CLtc/ILN5odXGNXS6mhrKVzTaCXzk9m6W3k=
 golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.0.0-20220111093109-d55c255bac03 h1:0FB83qp0AzVJm+0wcIlauAjJ+tNdh7jLuacRYCIVv7s=
-golang.org/x/net v0.0.0-20220111093109-d55c255bac03/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd h1:O7DYs+zxREGLKzKoMQrtrEacpb0ZVXA5rIwylE2Xchk=
+golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
+golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e h1:vcxGaoTs7kV8m5Np9uUNQin4BrLOthgV7252N8V+FwY=
+golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -178,6 +186,7 @@ golang.org/x/sys v0.0.0-20210502180810-71e4cd670f79/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220111092808-5a964db01320 h1:0jf+tOCoZ3LyutmCOWpVni1chK4VfFLhRsDK7MhqGRY=
 golang.org/x/sys v0.0.0-20220111092808-5a964db01320/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@@ -189,7 +198,6 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -202,6 +210,5 @@ gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
-gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

internal/config/config.go 🔗

@@ -3,6 +3,7 @@ package config
 import (
 	"bytes"
 	"log"
+	"path/filepath"
 	"strings"
 	"sync"
 	"text/template"
@@ -15,8 +16,7 @@ import (
 
 	"github.com/charmbracelet/soft-serve/config"
 	"github.com/charmbracelet/soft-serve/internal/git"
-	gg "github.com/go-git/go-git/v5"
-	"github.com/go-git/go-git/v5/plumbing/object"
+	gg "github.com/gogs/git-module"
 )
 
 // Config is the Soft Serve configuration.
@@ -30,7 +30,7 @@ type Config struct {
 	Repos        []Repo `yaml:"repos"`
 	Source       *git.RepoSource
 	Cfg          *config.Config
-	reloadMtx    sync.Mutex
+	mtx          sync.Mutex
 }
 
 // User contains user-level configuration for a repository.
@@ -106,13 +106,17 @@ func NewConfig(cfg *config.Config) (*Config, error) {
 	if err != nil {
 		return nil, err
 	}
+	err = c.Reload()
+	if err != nil {
+		return nil, err
+	}
 	return c, nil
 }
 
 // Reload reloads the configuration.
 func (cfg *Config) Reload() error {
-	cfg.reloadMtx.Lock()
-	defer cfg.reloadMtx.Unlock()
+	cfg.mtx.Lock()
+	defer cfg.mtx.Unlock()
 	err := cfg.Source.LoadRepos()
 	if err != nil {
 		return err
@@ -121,7 +125,7 @@ func (cfg *Config) Reload() error {
 	if err != nil {
 		return err
 	}
-	cs, err := cr.LatestFile("config.yaml")
+	cs, _, err := cr.LatestFile("config.yaml")
 	if err != nil {
 		return err
 	}
@@ -147,18 +151,8 @@ func (cfg *Config) Reload() error {
 			pat = rp
 		}
 		rm := ""
-		f, err := r.FindLatestFile(pat)
-		if err != nil && err != object.ErrFileNotFound {
-			return err
-		}
-		if err == nil {
-			fc, err := f.Contents()
-			if err != nil {
-				return err
-			}
-			rm = fc
-			r.ReadmePath = f.Name
-		}
+		fc, fp, _ := r.LatestFile(pat)
+		rm = fc
 		if name == "config" {
 			md, err := templatize(rm, cfg)
 			if err != nil {
@@ -166,7 +160,7 @@ func (cfg *Config) Reload() error {
 			}
 			rm = md
 		}
-		r.Readme = rm
+		r.SetReadme(rm, fp)
 	}
 	return nil
 }
@@ -187,21 +181,15 @@ func createFile(path string, content string) error {
 func (cfg *Config) createDefaultConfigRepo(yaml string) error {
 	cn := "config"
 	rs := cfg.Source
-	err := rs.LoadRepos()
-	if err != nil {
-		return err
-	}
-	_, err = rs.GetRepo(cn)
-	if err == git.ErrMissingRepo {
-		cr, err := rs.InitRepo(cn, true)
-		if err != nil {
-			return err
-		}
-		wt, err := cr.Repository().Worktree()
+	err := rs.LoadRepo(cn)
+	if os.IsNotExist(err) {
+		repo, err := rs.InitRepo(cn, true)
 		if err != nil {
 			return err
 		}
-		rm, err := wt.Filesystem.Create("README.md")
+		wt := repo.Path()
+		defer os.RemoveAll(wt)
+		rm, err := os.Create(filepath.Join(wt, "README.md"))
 		if err != nil {
 			return err
 		}
@@ -209,7 +197,7 @@ func (cfg *Config) createDefaultConfigRepo(yaml string) error {
 		if err != nil {
 			return err
 		}
-		cf, err := wt.Filesystem.Create("config.yaml")
+		cf, err := os.Create(filepath.Join(wt, "config.yaml"))
 		if err != nil {
 			return err
 		}
@@ -217,32 +205,25 @@ func (cfg *Config) createDefaultConfigRepo(yaml string) error {
 		if err != nil {
 			return err
 		}
-		_, err = wt.Add("README.md")
+		err = gg.Add(wt, gg.AddOptions{All: true})
 		if err != nil {
 			return err
 		}
-		_, err = wt.Add("config.yaml")
+		err = gg.CreateCommit(wt, &gg.Signature{
+			Name:  "Soft Serve Server",
+			Email: "vt100@charm.sh",
+		}, "Default init")
 		if err != nil {
 			return err
 		}
-		_, err = wt.Commit("Default init", &gg.CommitOptions{
-			All: true,
-			Author: &object.Signature{
-				Name:  "Soft Serve Server",
-				Email: "vt100@charm.sh",
-			},
-		})
-		if err != nil {
-			return err
-		}
-		err = cr.Repository().Push(&gg.PushOptions{})
+		err = repo.Push("origin", "master")
 		if err != nil {
 			return err
 		}
 	} else if err != nil {
 		return err
 	}
-	return cfg.Reload()
+	return nil
 }
 
 func (cfg *Config) isPrivate(repo string) bool {

internal/git/git.go 🔗

@@ -1,24 +1,15 @@
 package git
 
 import (
-	"context"
 	"errors"
 	"log"
 	"os"
-	"os/exec"
 	"path/filepath"
-	"sort"
 	"sync"
 
-	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/storer"
-	"github.com/go-git/go-git/v5/plumbing/transport"
-	"github.com/go-git/go-git/v5/storage/memory"
+	"github.com/charmbracelet/soft-serve/pkg/git"
 	"github.com/gobwas/glob"
+	"github.com/golang/groupcache/lru"
 )
 
 // ErrMissingRepo indicates that the requested repository could not be found.
@@ -28,195 +19,126 @@ var ErrMissingRepo = errors.New("missing repo")
 type Repo struct {
 	path       string
 	repository *git.Repository
-	Readme     string
-	ReadmePath string
-	refCommits map[plumbing.Hash]gitypes.Commits
-	head       *plumbing.Reference
-	refs       []*plumbing.Reference
-	trees      map[plumbing.Hash]*object.Tree
-	commits    map[plumbing.Hash]*object.Commit
-	patch      map[plumbing.Hash]*object.Patch
+	readme     string
+	readmePath string
+	head       *git.Reference
+	refs       []*git.Reference
+	patchCache *lru.Cache
 }
 
-// GetName returns the name of the repository.
-func (r *Repo) Name() string {
-	return filepath.Base(r.path)
-}
-
-// GetHEAD returns the reference for a repository.
-func (r *Repo) GetHEAD() *plumbing.Reference {
-	return r.head
-}
-
-// SetHEAD sets the repository head reference.
-func (r *Repo) SetHEAD(ref *plumbing.Reference) error {
-	r.head = ref
-	return nil
-}
-
-// GetReferences returns the references for a repository.
-func (r *Repo) GetReferences() []*plumbing.Reference {
-	return r.refs
-}
-
-// GetRepository returns the underlying go-git repository object.
-func (r *Repo) Repository() *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)
+// Open opens a Git repository.
+func (rs *RepoSource) Open(path string) (*Repo, error) {
+	rg, err := git.Open(path)
 	if err != nil {
 		return nil, err
 	}
-	c, err := r.commitForHash(hash)
+	r := &Repo{
+		path:       path,
+		repository: rg,
+		patchCache: lru.New(1000),
+	}
+	_, err = r.HEAD()
 	if err != nil {
 		return nil, err
 	}
-	t, err := r.treeForHash(c.TreeHash)
+	_, err = r.References()
 	if err != nil {
 		return nil, err
 	}
-	if path == "." {
-		return t, nil
-	}
-	return t.Tree(path)
+	return r, nil
 }
 
-func (r *Repo) treeForHash(treeHash plumbing.Hash) (*object.Tree, error) {
-	var err error
-	t, ok := r.trees[treeHash]
-	if !ok {
-		t, err = r.repository.TreeObject(treeHash)
-		if err != nil {
-			return nil, err
-		}
-		r.trees[treeHash] = t
-	}
-	return t, nil
+// Path returns the path to the repository.
+func (r *Repo) Path() string {
+	return r.path
 }
 
-func (r *Repo) commitForHash(hash plumbing.Hash) (*object.Commit, error) {
-	var err error
-	co, ok := r.commits[hash]
-	if !ok {
-		co, err = r.repository.CommitObject(hash)
-		if err != nil {
-			return nil, err
-		}
-		r.commits[hash] = co
-	}
-	return co, nil
+// GetName returns the name of the repository.
+func (r *Repo) Name() string {
+	return filepath.Base(r.path)
 }
 
-// PatchCtx returns the patch for a given commit.
-func (r *Repo) PatchCtx(ctx context.Context, commit *object.Commit) (*object.Patch, error) {
-	hash := commit.Hash
-	p, ok := r.patch[hash]
-	if !ok {
-		c, err := r.commitForHash(hash)
-		if err != nil {
-			return nil, err
-		}
-		// Using commit trees fixes the issue when generating diff for the first commit
-		// https://github.com/go-git/go-git/issues/281
-		tree, err := r.treeForHash(c.TreeHash)
-		if err != nil {
-			return nil, err
-		}
-		var parent *object.Commit
-		parentTree := &object.Tree{}
-		if c.NumParents() > 0 {
-			parent, err = r.commitForHash(c.ParentHashes[0])
-			if err != nil {
-				return nil, err
-			}
-			parentTree, err = r.treeForHash(parent.TreeHash)
-			if err != nil {
-				return nil, err
-			}
-		}
-		p, err = parentTree.PatchContext(ctx, tree)
-		if err != nil {
-			return nil, err
-		}
-	}
-	return p, nil
+// Readme returns the readme and its path for the repository.
+func (r *Repo) Readme() (readme string, path string) {
+	return r.readme, r.readmePath
+}
+
+// SetReadme sets the readme for the repository.
+func (r *Repo) SetReadme(readme, path string) {
+	r.readme = readme
+	r.readmePath = path
 }
 
-// GetCommits returns the commits for a repository.
-func (r *Repo) GetCommits(ref *plumbing.Reference) (gitypes.Commits, error) {
-	hash, err := r.targetHash(ref)
+// HEAD returns the reference for a repository.
+func (r *Repo) HEAD() (*git.Reference, error) {
+	if r.head != nil {
+		return r.head, nil
+	}
+	h, err := r.repository.HEAD()
 	if err != nil {
 		return nil, err
 	}
-	// return cached commits if available
-	commits, ok := r.refCommits[hash]
-	if ok {
-		return commits, nil
+	r.head = h
+	return h, nil
+}
+
+// GetReferences returns the references for a repository.
+func (r *Repo) References() ([]*git.Reference, error) {
+	if r.refs != nil {
+		return r.refs, nil
 	}
-	commits = gitypes.Commits{}
-	co, err := r.commitForHash(hash)
+	refs, err := r.repository.References()
 	if err != nil {
 		return nil, err
 	}
-	// traverse the commit tree to get all commits
-	commits = append(commits, co)
-	for co.NumParents() > 0 {
-		co, err = r.commitForHash(co.ParentHashes[0])
-		if err != nil {
-			return nil, err
-		}
-		commits = append(commits, co)
+	r.refs = refs
+	return refs, nil
+}
+
+// Tree returns the git tree for a given path.
+func (r *Repo) Tree(ref *git.Reference, path string) (*git.Tree, error) {
+	return r.repository.TreePath(ref, path)
+}
+
+// Diff returns the diff for a given commit.
+func (r *Repo) Diff(commit *git.Commit) (*git.Diff, error) {
+	hash := commit.Hash.String()
+	c, ok := r.patchCache.Get(hash)
+	if ok {
+		return c.(*git.Diff), nil
 	}
+	diff, err := r.repository.Diff(commit)
 	if err != nil {
 		return nil, err
 	}
-	sort.Sort(commits)
-	// cache the commits in the repo
-	r.refCommits[hash] = commits
-	return commits, nil
+	r.patchCache.Add(hash, diff)
+	return diff, 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
-		}
+// CountCommits returns the number of commits for a repository.
+func (r *Repo) CountCommits(ref *git.Reference) (int64, error) {
+	tc, err := r.repository.CountCommits(ref)
+	if err != nil {
+		return 0, err
 	}
-	return hash, nil
+	return tc, nil
 }
 
-// GetReadme returns the readme for a repository.
-func (r *Repo) GetReadme() string {
-	return r.Readme
+// CommitsByPage returns the commits for a repository.
+func (r *Repo) CommitsByPage(ref *git.Reference, page, size int) (git.Commits, error) {
+	return r.repository.CommitsByPage(ref, page, size)
 }
 
-// GetReadmePath returns the path to the readme for a repository.
-func (r *Repo) GetReadmePath() string {
-	return r.ReadmePath
+// Push pushes the repository to the remote.
+func (r *Repo) Push(remote, branch string) error {
+	return r.repository.Push(remote, branch)
 }
 
 // RepoSource is a reference to an on-disk repositories.
 type RepoSource struct {
 	Path  string
 	mtx   sync.Mutex
-	repos []*Repo
+	repos map[string]*Repo
 }
 
 // NewRepoSource creates a new RepoSource.
@@ -226,6 +148,7 @@ func NewRepoSource(repoPath string) *RepoSource {
 		log.Fatal(err)
 	}
 	rs := &RepoSource{Path: repoPath}
+	rs.repos = make(map[string]*Repo, 0)
 	return rs
 }
 
@@ -233,19 +156,22 @@ func NewRepoSource(repoPath string) *RepoSource {
 func (rs *RepoSource) AllRepos() []*Repo {
 	rs.mtx.Lock()
 	defer rs.mtx.Unlock()
-	return rs.repos
+	repos := make([]*Repo, 0, len(rs.repos))
+	for _, r := range rs.repos {
+		repos = append(repos, r)
+	}
+	return repos
 }
 
 // GetRepo returns a repository by name.
 func (rs *RepoSource) GetRepo(name string) (*Repo, error) {
 	rs.mtx.Lock()
 	defer rs.mtx.Unlock()
-	for _, r := range rs.repos {
-		if filepath.Base(r.path) == name {
-			return r, nil
-		}
+	r, ok := rs.repos[name]
+	if !ok {
+		return nil, ErrMissingRepo
 	}
-	return nil, ErrMissingRepo
+	return r, nil
 }
 
 // InitRepo initializes a new Git repository.
@@ -253,142 +179,75 @@ func (rs *RepoSource) InitRepo(name string, bare bool) (*Repo, error) {
 	rs.mtx.Lock()
 	defer rs.mtx.Unlock()
 	rp := filepath.Join(rs.Path, name)
-	rg, err := git.PlainInit(rp, bare)
+	rg, err := git.Init(rp, bare)
 	if err != nil {
 		return nil, err
 	}
-	if bare {
-		// Clone repo into memory storage
-		ar, err := git.Clone(memory.NewStorage(), memfs.New(), &git.CloneOptions{
-			URL: rp,
-		})
-		if err != nil && err != transport.ErrEmptyRemoteRepository {
-			return nil, err
-		}
-		rg = ar
-	}
 	r := &Repo{
 		path:       rp,
 		repository: rg,
+		refs: []*git.Reference{
+			git.NewReference(rp, git.RefsHeads+"master"),
+		},
 	}
-	rs.repos = append(rs.repos, r)
+	rs.repos[name] = r
 	return r, nil
 }
 
-// LoadRepos opens Git repositories.
-func (rs *RepoSource) LoadRepos() error {
+// LoadRepo loads a repository from disk.
+func (rs *RepoSource) LoadRepo(name string) error {
 	rs.mtx.Lock()
 	defer rs.mtx.Unlock()
+	rp := filepath.Join(rs.Path, name)
+	r, err := rs.Open(rp)
+	if err != nil {
+		return err
+	}
+	rs.repos[name] = r
+	return nil
+}
+
+// LoadRepos opens Git repositories.
+func (rs *RepoSource) LoadRepos() error {
 	rd, err := os.ReadDir(rs.Path)
 	if err != nil {
 		return err
 	}
-	rs.repos = make([]*Repo, 0)
 	for _, de := range rd {
-		rp := filepath.Join(rs.Path, de.Name())
-		rg, err := git.PlainOpen(rp)
+		err = rs.LoadRepo(de.Name())
 		if err != nil {
 			return err
 		}
-		r, err := rs.loadRepo(rp, rg)
-		if err != nil {
-			return err
-		}
-		rs.repos = append(rs.repos, r)
 	}
 	return nil
 }
 
-func (rs *RepoSource) loadRepo(path string, rg *git.Repository) (*Repo, error) {
-	r := &Repo{
-		path:       path,
-		repository: rg,
-		patch:      make(map[plumbing.Hash]*object.Patch),
-	}
-	r.commits = make(map[plumbing.Hash]*object.Commit)
-	r.trees = make(map[plumbing.Hash]*object.Tree)
-	r.refCommits = make(map[plumbing.Hash]gitypes.Commits)
-	ref, err := rg.Head()
-	if err != nil {
-		return nil, err
-	}
-	r.head = ref
-	l, err := r.repository.Log(&git.LogOptions{All: true})
-	if err != nil {
-		return nil, err
-	}
-	err = l.ForEach(func(c *object.Commit) error {
-		r.commits[c.Hash] = c
-		return nil
-	})
-	if err != nil {
-		return nil, err
-	}
-	refs := make([]*plumbing.Reference, 0)
-	ri, err := rg.References()
-	if err != nil {
-		return nil, err
-	}
-	ri.ForEach(func(r *plumbing.Reference) error {
-		refs = append(refs, r)
-		return nil
-	})
-	r.refs = refs
-	return r, nil
-}
-
-// FindLatestFile returns the latest file for a given path.
-func (r *Repo) FindLatestFile(pattern string) (*object.File, error) {
-	g, err := glob.Compile(pattern)
+// LatestFile returns the contents of the latest file at the specified path in the repository.
+func (r *Repo) LatestFile(pattern string) (string, string, error) {
+	g := glob.MustCompile(pattern)
+	dir := filepath.Dir(pattern)
+	t, err := r.repository.TreePath(r.head, dir)
 	if err != nil {
-		return nil, err
+		return "", "", err
 	}
-	c, err := r.commitForHash(r.head.Hash())
+	ents, err := t.Entries()
 	if err != nil {
-		return nil, err
+		return "", "", err
 	}
-	fi, err := c.Files()
-	if err != nil {
-		return nil, err
-	}
-	var f *object.File
-	err = fi.ForEach(func(ff *object.File) error {
-		if g.Match(ff.Name) {
-			f = ff
-			return storer.ErrStop
+	for _, e := range ents {
+		fp := filepath.Join(dir, e.Name())
+		if g.Match(fp) {
+			bts, err := e.Contents()
+			if err != nil {
+				return "", "", err
+			}
+			return string(bts), fp, nil
 		}
-		return nil
-	})
-	if err != nil {
-		return nil, err
-	}
-	if f == nil {
-		return nil, object.ErrFileNotFound
 	}
-	return f, nil
-}
-
-// LatestFile returns the contents of the latest file at the specified path in the repository.
-func (r *Repo) LatestFile(pattern string) (string, error) {
-	f, err := r.FindLatestFile(pattern)
-	if err != nil {
-		return "", err
-	}
-	content, err := f.Contents()
-	if err != nil {
-		return "", err
-	}
-	return content, nil
-}
-
-// LatestTree returns the latest tree at the specified path in the repository.
-func (r *Repo) LatestTree(path string) (*object.Tree, error) {
-	return r.Tree(r.head, path)
+	return "", "", git.ErrFileNotFound
 }
 
 // UpdateServerInfo updates the server info for the repository.
 func (r *Repo) UpdateServerInfo() error {
-	cmd := exec.Command("git", "update-server-info")
-	cmd.Dir = r.path
-	return cmd.Run()
+	return r.repository.UpdateServerInfo()
 }

internal/tui/bubble.go 🔗

@@ -7,10 +7,10 @@ import (
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
 	"github.com/charmbracelet/soft-serve/internal/config"
-	gittypes "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
 	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/repo"
 	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/selection"
 	"github.com/charmbracelet/soft-serve/internal/tui/style"
+	"github.com/charmbracelet/soft-serve/pkg/tui/common"
 	"github.com/gliderlabs/ssh"
 )
 
@@ -157,19 +157,19 @@ func (b Bubble) headerView() string {
 
 func (b Bubble) footerView() string {
 	w := &strings.Builder{}
-	var h []gittypes.HelpEntry
+	var h []common.HelpEntry
 	if b.state != errorState {
-		h = []gittypes.HelpEntry{
+		h = []common.HelpEntry{
 			{Key: "tab", Value: "section"},
 		}
-		if box, ok := b.boxes[b.activeBox].(gittypes.BubbleHelper); ok {
+		if box, ok := b.boxes[b.activeBox].(common.BubbleHelper); ok {
 			help := box.Help()
 			for _, he := range help {
 				h = append(h, he)
 			}
 		}
 	}
-	h = append(h, gittypes.HelpEntry{Key: "q", Value: "quit"})
+	h = append(h, common.HelpEntry{Key: "q", Value: "quit"})
 	for i, v := range h {
 		fmt.Fprint(w, helpEntryRender(v, b.styles))
 		if i != len(h)-1 {
@@ -178,13 +178,14 @@ func (b Bubble) footerView() string {
 	}
 	branch := ""
 	if b.state == loadedState {
-		branch = b.boxes[1].(*repo.Bubble).Reference().Short()
+		ref := b.boxes[1].(*repo.Bubble).Reference()
+		branch = ref.Name().Short()
 	}
 	help := w.String()
 	branchMaxWidth := b.width - // bubble width
 		lipgloss.Width(help) - // help width
 		b.styles.App.GetHorizontalFrameSize() // App paddings
-	branch = b.styles.Branch.Render(gittypes.TruncateString(branch, branchMaxWidth-1, "…"))
+	branch = b.styles.Branch.Render(common.TruncateString(branch, branchMaxWidth-1, "…"))
 	gap := lipgloss.NewStyle().
 		Width(b.width -
 			lipgloss.Width(help) -
@@ -228,6 +229,6 @@ func (b Bubble) View() string {
 	return b.styles.App.Render(s.String())
 }
 
-func helpEntryRender(h gittypes.HelpEntry, s *style.Styles) string {
+func helpEntryRender(h common.HelpEntry, s *style.Styles) string {
 	return fmt.Sprintf("%s %s", s.HelpKey.Render(h.Key), s.HelpValue.Render(h.Value))
 }

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

@@ -1,30 +0,0 @@
-package types
-
-import (
-	"context"
-
-	"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 interface {
-	Name() string
-	GetHEAD() *plumbing.Reference
-	SetHEAD(*plumbing.Reference) error
-	GetReferences() []*plumbing.Reference
-	GetReadme() string
-	GetReadmePath() string
-	GetCommits(*plumbing.Reference) (Commits, error)
-	Repository() *git.Repository
-	Tree(*plumbing.Reference, string) (*object.Tree, error)
-	PatchCtx(context.Context, *object.Commit) (*object.Patch, error)
-}
-
-type Commits []*object.Commit
-
-func (cl Commits) Len() int      { return len(cl) }
-func (cl Commits) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }
-func (cl Commits) Less(i, j int) bool {
-	return cl[i].Author.When.After(cl[j].Author.When)
-}

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

@@ -6,10 +6,10 @@ import (
 
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
-	gitui "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git"
-	gittypes "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
 	"github.com/charmbracelet/soft-serve/internal/tui/style"
-	"github.com/go-git/go-git/v5/plumbing"
+	"github.com/charmbracelet/soft-serve/pkg/git"
+	gitui "github.com/charmbracelet/soft-serve/pkg/tui"
+	"github.com/charmbracelet/soft-serve/pkg/tui/common"
 	"github.com/muesli/reflow/truncate"
 	"github.com/muesli/reflow/wrap"
 )
@@ -22,7 +22,7 @@ type Bubble struct {
 	name         string
 	host         string
 	port         int
-	repo         gittypes.Repo
+	repo         common.GitRepo
 	styles       *style.Styles
 	width        int
 	widthMargin  int
@@ -33,7 +33,7 @@ type Bubble struct {
 	Active bool
 }
 
-func NewBubble(repo gittypes.Repo, host string, port int, styles *style.Styles, width, wm, height, hm int) *Bubble {
+func NewBubble(repo common.GitRepo, host string, port int, styles *style.Styles, width, wm, height, hm int) *Bubble {
 	b := &Bubble{
 		name:         repo.Name(),
 		host:         host,
@@ -56,6 +56,9 @@ func (b *Bubble) Init() tea.Cmd {
 func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
+		if msg.Width == b.width && msg.Height == b.height {
+			return b, nil
+		}
 		b.width = msg.Width
 		b.height = msg.Height
 	}
@@ -64,7 +67,7 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return b, cmd
 }
 
-func (b *Bubble) Help() []gittypes.HelpEntry {
+func (b *Bubble) Help() []common.HelpEntry {
 	return b.box.Help()
 }
 
@@ -95,7 +98,7 @@ func (b Bubble) headerView() string {
 	note = b.styles.RepoNote.Copy().Width(noteWidth).Render(note)
 
 	// Render borders on name and command
-	height := gittypes.Max(lipgloss.Height(title), lipgloss.Height(note))
+	height := common.Max(lipgloss.Height(title), lipgloss.Height(note))
 	titleBoxStyle := b.styles.RepoTitleBox.Copy().Height(height)
 	noteBoxStyle := b.styles.RepoNoteBox.Copy().Height(height)
 	if b.Active {
@@ -121,7 +124,7 @@ func (b *Bubble) View() string {
 	return header + body
 }
 
-func (b *Bubble) Reference() plumbing.ReferenceName {
+func (b *Bubble) Reference() *git.Reference {
 	return b.box.Reference()
 }
 

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

@@ -5,8 +5,8 @@ import (
 
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
-	gittypes "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
 	"github.com/charmbracelet/soft-serve/internal/tui/style"
+	"github.com/charmbracelet/soft-serve/pkg/tui/common"
 	"github.com/muesli/reflow/truncate"
 )
 
@@ -80,8 +80,8 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return b, tea.Batch(cmds...)
 }
 
-func (b *Bubble) Help() []gittypes.HelpEntry {
-	return []gittypes.HelpEntry{
+func (b *Bubble) Help() []common.HelpEntry {
+	return []common.HelpEntry{
 		{Key: "↑/↓", Value: "navigate"},
 	}
 }

internal/tui/commands.go 🔗

@@ -5,9 +5,9 @@ import (
 
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
-	gitypes "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
 	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/repo"
 	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/selection"
+	"github.com/charmbracelet/soft-serve/pkg/tui/common"
 	gm "github.com/charmbracelet/wish/git"
 )
 
@@ -110,7 +110,7 @@ func (b *Bubble) newMenuEntry(name string, rn string) (MenuEntry, error) {
 	initCmd := rb.Init()
 	msg := initCmd()
 	switch msg := msg.(type) {
-	case gitypes.ErrMsg:
+	case common.ErrMsg:
 		return me, fmt.Errorf("missing %s: %s", me.Repo, msg.Err.Error())
 	}
 	me.bubble = rb

pkg/git/commit.go 🔗

@@ -0,0 +1,42 @@
+package git
+
+import (
+	"github.com/gogs/git-module"
+)
+
+var (
+	ZeroHash Hash = git.EmptyID
+)
+
+// Hash represents a git hash.
+type Hash string
+
+// String returns the string representation of a hash as a string.
+func (h Hash) String() string {
+	return string(h)
+}
+
+// SHA1 represents the hash as a SHA1.
+func (h Hash) SHA1() *git.SHA1 {
+	return git.MustIDFromString(h.String())
+}
+
+// Commit is a wrapper around git.Commit with helper methods.
+type Commit struct {
+	*git.Commit
+	Hash Hash
+}
+
+// Commits is a list of commits.
+type Commits []*Commit
+
+// Len implements sort.Interface.
+func (cl Commits) Len() int { return len(cl) }
+
+// Swap implements sort.Interface.
+func (cl Commits) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }
+
+// Less implements sort.Interface.
+func (cl Commits) Less(i, j int) bool {
+	return cl[i].Author.When.After(cl[j].Author.When)
+}

pkg/git/errors.go 🔗

@@ -0,0 +1,9 @@
+package git
+
+import "errors"
+
+var (
+	ErrFileNotFound      = errors.New("file not found")
+	ErrDirectoryNotFound = errors.New("directory not found")
+	ErrReferenceNotFound = errors.New("reference not found")
+)

pkg/git/patch.go 🔗

@@ -0,0 +1,329 @@
+package git
+
+import (
+	"bytes"
+	"fmt"
+	"math"
+	"strings"
+	"sync"
+
+	"github.com/dustin/go-humanize/english"
+	"github.com/gogs/git-module"
+	"github.com/sergi/go-diff/diffmatchpatch"
+)
+
+// DiffSection is a wrapper to git.DiffSection with helper methods.
+type DiffSection struct {
+	*git.DiffSection
+
+	initOnce sync.Once
+	dmp      *diffmatchpatch.DiffMatchPatch
+}
+
+// diffFor computes inline diff for the given line.
+func (s *DiffSection) diffFor(line *git.DiffLine) string {
+	fallback := line.Content
+
+	// Find equivalent diff line, ignore when not found.
+	var diff1, diff2 string
+	switch line.Type {
+	case git.DiffLineAdd:
+		compareLine := s.Line(git.DiffLineDelete, line.RightLine)
+		if compareLine == nil {
+			return fallback
+		}
+
+		diff1 = compareLine.Content
+		diff2 = line.Content
+
+	case git.DiffLineDelete:
+		compareLine := s.Line(git.DiffLineAdd, line.LeftLine)
+		if compareLine == nil {
+			return fallback
+		}
+
+		diff1 = line.Content
+		diff2 = compareLine.Content
+
+	default:
+		return fallback
+	}
+
+	s.initOnce.Do(func() {
+		s.dmp = diffmatchpatch.New()
+		s.dmp.DiffEditCost = 100
+	})
+
+	diffs := s.dmp.DiffMain(diff1[1:], diff2[1:], true)
+	diffs = s.dmp.DiffCleanupEfficiency(diffs)
+
+	return diffsToString(diffs, line.Type)
+}
+
+func diffsToString(diffs []diffmatchpatch.Diff, lineType git.DiffLineType) string {
+	buf := bytes.NewBuffer(nil)
+
+	// Reproduce signs which are cutted for inline diff before.
+	switch lineType {
+	case git.DiffLineAdd:
+		buf.WriteByte('+')
+	case git.DiffLineDelete:
+		buf.WriteByte('-')
+	}
+
+	for i := range diffs {
+		switch {
+		case diffs[i].Type == diffmatchpatch.DiffInsert && lineType == git.DiffLineAdd:
+			buf.WriteString(diffs[i].Text)
+		case diffs[i].Type == diffmatchpatch.DiffDelete && lineType == git.DiffLineDelete:
+			buf.WriteString(diffs[i].Text)
+		case diffs[i].Type == diffmatchpatch.DiffEqual:
+			buf.WriteString(diffs[i].Text)
+		}
+	}
+
+	return string(buf.Bytes())
+}
+
+// DiffFile is a wrapper to git.DiffFile with helper methods.
+type DiffFile struct {
+	*git.DiffFile
+	Sections []*DiffSection
+}
+
+type DiffFileChange struct {
+	hash string
+	name string
+	mode git.EntryMode
+}
+
+func (f *DiffFileChange) Hash() string {
+	return f.hash
+}
+
+func (f *DiffFileChange) Name() string {
+	return f.name
+}
+
+func (f *DiffFileChange) Mode() git.EntryMode {
+	return f.mode
+}
+
+func (f *DiffFile) Files() (from *DiffFileChange, to *DiffFileChange) {
+	if f.OldIndex != ZeroHash.String() {
+		from = &DiffFileChange{
+			hash: f.OldIndex,
+			name: f.OldName(),
+			mode: f.OldMode(),
+		}
+	}
+	if f.Index != ZeroHash.String() {
+		to = &DiffFileChange{
+			hash: f.Index,
+			name: f.Name,
+			mode: f.Mode(),
+		}
+	}
+	return
+}
+
+// FileStats
+type FileStats []*DiffFile
+
+// String returns a string representation of file stats.
+func (fs FileStats) String() string {
+	return printStats(fs)
+}
+
+func printStats(stats FileStats) string {
+	padLength := float64(len(" "))
+	newlineLength := float64(len("\n"))
+	separatorLength := float64(len("|"))
+	// Soft line length limit. The text length calculation below excludes
+	// length of the change number. Adding that would take it closer to 80,
+	// but probably not more than 80, until it's a huge number.
+	lineLength := 72.0
+
+	// Get the longest filename and longest total change.
+	var longestLength float64
+	var longestTotalChange float64
+	for _, fs := range stats {
+		if int(longestLength) < len(fs.Name) {
+			longestLength = float64(len(fs.Name))
+		}
+		totalChange := fs.NumAdditions() + fs.NumDeletions()
+		if int(longestTotalChange) < totalChange {
+			longestTotalChange = float64(totalChange)
+		}
+	}
+
+	// Parts of the output:
+	// <pad><filename><pad>|<pad><changeNumber><pad><+++/---><newline>
+	// example: " main.go | 10 +++++++--- "
+
+	// <pad><filename><pad>
+	leftTextLength := padLength + longestLength + padLength
+
+	// <pad><number><pad><+++++/-----><newline>
+	// Excluding number length here.
+	rightTextLength := padLength + padLength + newlineLength
+
+	totalTextArea := leftTextLength + separatorLength + rightTextLength
+	heightOfHistogram := lineLength - totalTextArea
+
+	// Scale the histogram.
+	var scaleFactor float64
+	if longestTotalChange > heightOfHistogram {
+		// Scale down to heightOfHistogram.
+		scaleFactor = longestTotalChange / heightOfHistogram
+	} else {
+		scaleFactor = 1.0
+	}
+
+	taddc := 0
+	tdelc := 0
+	output := strings.Builder{}
+	for _, fs := range stats {
+		taddc += fs.NumAdditions()
+		tdelc += fs.NumDeletions()
+		addn := float64(fs.NumAdditions())
+		deln := float64(fs.NumDeletions())
+		addc := int(math.Floor(addn / scaleFactor))
+		delc := int(math.Floor(deln / scaleFactor))
+		if addc < 0 {
+			addc = 0
+		}
+		if delc < 0 {
+			delc = 0
+		}
+		adds := strings.Repeat("+", addc)
+		dels := strings.Repeat("-", delc)
+		diffLines := fmt.Sprint(fs.NumAdditions() + fs.NumDeletions())
+		totalDiffLines := fmt.Sprint(int(longestTotalChange))
+		fmt.Fprintf(&output, "%s | %s %s%s\n",
+			fs.Name+strings.Repeat(" ", int(longestLength)-len(fs.Name)),
+			strings.Repeat(" ", len(totalDiffLines)-len(diffLines))+diffLines,
+			adds,
+			dels)
+	}
+	files := len(stats)
+	fc := fmt.Sprintf("%s changed", english.Plural(files, "file", ""))
+	ins := fmt.Sprintf("%s(+)", english.Plural(taddc, "insertion", ""))
+	dels := fmt.Sprintf("%s(-)", english.Plural(tdelc, "deletion", ""))
+	fmt.Fprint(&output, fc)
+	if taddc > 0 {
+		fmt.Fprintf(&output, ", %s", ins)
+	}
+	if tdelc > 0 {
+		fmt.Fprintf(&output, ", %s", dels)
+	}
+	fmt.Fprint(&output, "\n")
+
+	return output.String()
+}
+
+// Diff is a wrapper around git.Diff with helper methods.
+type Diff struct {
+	*git.Diff
+	Files []*DiffFile
+}
+
+// FileStats returns the diff file stats.
+func (d *Diff) Stats() FileStats {
+	return d.Files
+}
+
+const (
+	dstPrefix = "b/"
+	srcPrefix = "a/"
+)
+
+func appendPathLines(lines []string, fromPath, toPath string, isBinary bool) []string {
+	if isBinary {
+		return append(lines,
+			fmt.Sprintf("Binary files %s and %s differ", fromPath, toPath),
+		)
+	}
+	return append(lines,
+		fmt.Sprintf("--- %s", fromPath),
+		fmt.Sprintf("+++ %s", toPath),
+	)
+}
+
+func writeFilePatchHeader(sb *strings.Builder, filePatch *DiffFile) {
+	from, to := filePatch.Files()
+	if from == nil && to == nil {
+		return
+	}
+	isBinary := filePatch.IsBinary()
+
+	var lines []string
+	switch {
+	case from != nil && to != nil:
+		hashEquals := from.Hash() == to.Hash()
+		lines = append(lines,
+			fmt.Sprintf("diff --git %s%s %s%s",
+				srcPrefix, from.Name(), dstPrefix, to.Name()),
+		)
+		if from.Mode() != to.Mode() {
+			lines = append(lines,
+				fmt.Sprintf("old mode %o", from.Mode()),
+				fmt.Sprintf("new mode %o", to.Mode()),
+			)
+		}
+		if from.Name() != to.Name() {
+			lines = append(lines,
+				fmt.Sprintf("rename from %s", from.Name()),
+				fmt.Sprintf("rename to %s", to.Name()),
+			)
+		}
+		if from.Mode() != to.Mode() && !hashEquals {
+			lines = append(lines,
+				fmt.Sprintf("index %s..%s", from.Hash(), to.Hash()),
+			)
+		} else if !hashEquals {
+			lines = append(lines,
+				fmt.Sprintf("index %s..%s %o", from.Hash(), to.Hash(), from.Mode()),
+			)
+		}
+		if !hashEquals {
+			lines = appendPathLines(lines, srcPrefix+from.Name(), dstPrefix+to.Name(), isBinary)
+		}
+	case from == nil:
+		lines = append(lines,
+			fmt.Sprintf("diff --git %s %s", srcPrefix+to.Name(), dstPrefix+to.Name()),
+			fmt.Sprintf("new file mode %o", to.Mode()),
+			fmt.Sprintf("index %s..%s", ZeroHash, to.Hash()),
+		)
+		lines = appendPathLines(lines, "/dev/null", dstPrefix+to.Name(), isBinary)
+	case to == nil:
+		lines = append(lines,
+			fmt.Sprintf("diff --git %s %s", srcPrefix+from.Name(), dstPrefix+from.Name()),
+			fmt.Sprintf("deleted file mode %o", from.Mode()),
+			fmt.Sprintf("index %s..%s", from.Hash(), ZeroHash),
+		)
+		lines = appendPathLines(lines, srcPrefix+from.Name(), "/dev/null", isBinary)
+	}
+
+	sb.WriteString(lines[0])
+	for _, line := range lines[1:] {
+		sb.WriteByte('\n')
+		sb.WriteString(line)
+	}
+	sb.WriteByte('\n')
+}
+
+// Patch returns the diff as a patch.
+func (d *Diff) Patch() string {
+	var p strings.Builder
+	for _, f := range d.Files {
+		writeFilePatchHeader(&p, f)
+		for _, s := range f.Sections {
+			for _, l := range s.Lines {
+				p.WriteString(s.diffFor(l))
+				p.WriteString("\n")
+			}
+		}
+	}
+	return p.String()
+}

pkg/git/reference.go 🔗

@@ -0,0 +1,76 @@
+package git
+
+import (
+	"strings"
+
+	"github.com/gogs/git-module"
+)
+
+const (
+	// HEAD represents the name of the HEAD reference.
+	HEAD = "HEAD"
+	// RefsHeads represents the prefix for branch references.
+	RefsHeads = git.RefsHeads
+	// RefsTags represents the prefix for tag references.
+	RefsTags = git.RefsTags
+)
+
+// Reference is a wrapper around git.Reference with helper methods.
+type Reference struct {
+	*git.Reference
+	Hash Hash
+	path string // repo path
+}
+
+// ReferenceName is a Refspec wrapper.
+type ReferenceName string
+
+// NewReference creates a new reference.
+func NewReference(rp, refspec string) *Reference {
+	return &Reference{
+		Reference: &git.Reference{
+			Refspec: refspec,
+		},
+		path: rp,
+	}
+}
+
+// String returns the reference name i.e. refs/heads/master.
+func (r ReferenceName) String() string {
+	return string(r)
+}
+
+// Short returns the short name of the reference i.e. master.
+func (r ReferenceName) Short() string {
+	s := strings.Split(r.String(), "/")
+	if len(s) > 0 {
+		return s[len(s)-1]
+	}
+	return r.String()
+}
+
+// Name returns the reference name i.e. refs/heads/master.
+func (r *Reference) Name() ReferenceName {
+	return ReferenceName(r.Refspec)
+}
+
+// IsBranch returns true if the reference is a branch.
+func (r *Reference) IsBranch() bool {
+	return strings.HasPrefix(r.Refspec, git.RefsHeads)
+}
+
+// IsTag returns true if the reference is a tag.
+func (r *Reference) IsTag() bool {
+	return strings.HasPrefix(r.Refspec, git.RefsTags)
+}
+
+// TargetHash returns the hash of the reference target.
+func (r *Reference) TargetHash() Hash {
+	if r.IsTag() {
+		id, err := git.ShowRefVerify(r.path, r.Refspec)
+		if err == nil {
+			return Hash(id)
+		}
+	}
+	return r.Hash
+}

pkg/git/repo.go 🔗

@@ -0,0 +1,185 @@
+package git
+
+import (
+	"path/filepath"
+
+	"github.com/gogs/git-module"
+)
+
+var (
+	DiffMaxFiles     = 1000
+	DiffMaxFileLines = 1000
+	DiffMaxLineChars = 1000
+)
+
+// Repository is a wrapper around git.Repository with helper methods.
+type Repository struct {
+	*git.Repository
+	Path string
+}
+
+// Clone clones a repository.
+func Clone(src, dst string, opts ...git.CloneOptions) error {
+	return git.Clone(src, dst, opts...)
+}
+
+// Init initializes and opens a new git repository.
+func Init(path string, bare bool) (*Repository, error) {
+	err := git.Init(path, git.InitOptions{Bare: bare})
+	if err != nil {
+		return nil, err
+	}
+	return Open(path)
+}
+
+// Open opens a git repository at the given path.
+func Open(path string) (*Repository, error) {
+	repo, err := git.Open(path)
+	if err != nil {
+		return nil, err
+	}
+	return &Repository{
+		Repository: repo,
+		Path:       path,
+	}, nil
+}
+
+// Name returns the name of the repository.
+func (r *Repository) Name() string {
+	return filepath.Base(r.Path)
+}
+
+// HEAD returns the HEAD reference for a repository.
+func (r *Repository) HEAD() (*Reference, error) {
+	rn, err := r.SymbolicRef()
+	if err != nil {
+		return nil, err
+	}
+	hash, err := r.ShowRefVerify(rn)
+	if err != nil {
+		return nil, err
+	}
+	return &Reference{
+		Reference: &git.Reference{
+			ID:      hash,
+			Refspec: rn,
+		},
+		Hash: Hash(hash),
+		path: r.Path,
+	}, nil
+}
+
+// References returns the references for a repository.
+func (r *Repository) References() ([]*Reference, error) {
+	refs, err := r.ShowRef()
+	if err != nil {
+		return nil, err
+	}
+	rrefs := make([]*Reference, 0, len(refs))
+	for _, ref := range refs {
+		rrefs = append(rrefs, &Reference{
+			Reference: ref,
+			Hash:      Hash(ref.ID),
+			path:      r.Path,
+		})
+	}
+	return rrefs, nil
+}
+
+// Tree returns the tree for the given reference.
+func (r *Repository) Tree(ref *Reference) (*Tree, error) {
+	if ref == nil {
+		rref, err := r.HEAD()
+		if err != nil {
+			return nil, err
+		}
+		ref = rref
+	}
+	tree, err := r.LsTree(ref.Hash.String())
+	if err != nil {
+		return nil, err
+	}
+	return &Tree{
+		Tree: tree,
+		Path: "",
+	}, nil
+}
+
+// TreePath returns the tree for the given path.
+func (r *Repository) TreePath(ref *Reference, path string) (*Tree, error) {
+	path = filepath.Clean(path)
+	if path == "." {
+		path = ""
+	}
+	if path == "" {
+		return r.Tree(ref)
+	}
+	t, err := r.Tree(ref)
+	if err != nil {
+		return nil, err
+	}
+	return t.SubTree(path)
+}
+
+// Diff returns the diff for the given commit.
+func (r *Repository) Diff(commit *Commit) (*Diff, error) {
+	ddiff, err := r.Repository.Diff(commit.Hash.String(), DiffMaxFiles, DiffMaxFileLines, DiffMaxLineChars)
+	if err != nil {
+		return nil, err
+	}
+	files := make([]*DiffFile, 0, len(ddiff.Files))
+	for _, df := range ddiff.Files {
+		sections := make([]*DiffSection, 0, len(df.Sections))
+		for _, ds := range df.Sections {
+			sections = append(sections, &DiffSection{
+				DiffSection: ds,
+			})
+		}
+		files = append(files, &DiffFile{
+			DiffFile: df,
+			Sections: sections,
+		})
+	}
+	diff := &Diff{
+		Diff:  ddiff,
+		Files: files,
+	}
+	return diff, nil
+}
+
+// Patch returns the patch for the given reference.
+func (r *Repository) Patch(commit *Commit) (string, error) {
+	diff, err := r.Diff(commit)
+	if err != nil {
+		return "", err
+	}
+	return diff.Patch(), err
+}
+
+// CountCommits returns the number of commits in the repository.
+func (r *Repository) CountCommits(ref *Reference) (int64, error) {
+	return r.Repository.RevListCount([]string{ref.Name().String()})
+}
+
+// CommitsByPage returns the commits for a given page and size.
+func (r *Repository) CommitsByPage(ref *Reference, page, size int) (Commits, error) {
+	cs, err := r.Repository.CommitsByPage(ref.Name().String(), page, size)
+	if err != nil {
+		return nil, err
+	}
+	commits := make(Commits, len(cs))
+	for i, c := range cs {
+		commits[i] = &Commit{
+			Commit: c,
+			Hash:   Hash(c.ID.String()),
+		}
+	}
+	return commits, nil
+}
+
+// UpdateServerInfo updates the repository server info.
+func (r *Repository) UpdateServerInfo() error {
+	cmd := git.NewCommand("update-server-info")
+	_, err := cmd.RunInDir(r.Path)
+	return err
+}

pkg/git/tree.go 🔗

@@ -0,0 +1,191 @@
+package git
+
+import (
+	"bufio"
+	"bytes"
+	"io"
+	"io/fs"
+	"path/filepath"
+	"sort"
+
+	"github.com/gogs/git-module"
+)
+
+// Tree is a wrapper around git.Tree with helper methods.
+type Tree struct {
+	*git.Tree
+	Path string
+}
+
+// TreeEntry is a wrapper around git.TreeEntry with helper methods.
+type TreeEntry struct {
+	*git.TreeEntry
+	// path is the full path of the file
+	path string
+}
+
+// Entries is a wrapper around git.Entries
+type Entries []*TreeEntry
+
+var sorters = []func(t1, t2 *TreeEntry) bool{
+	func(t1, t2 *TreeEntry) bool {
+		return (t1.IsTree() || t1.IsCommit()) && !t2.IsTree() && !t2.IsCommit()
+	},
+	func(t1, t2 *TreeEntry) bool {
+		return t1.Name() < t2.Name()
+	},
+}
+
+// Len implements sort.Interface.
+func (es Entries) Len() int { return len(es) }
+
+// Swap implements sort.Interface.
+func (es Entries) Swap(i, j int) { es[i], es[j] = es[j], es[i] }
+
+// Less implements sort.Interface.
+func (es Entries) Less(i, j int) bool {
+	t1, t2 := es[i], es[j]
+	var k int
+	for k = 0; k < len(sorters)-1; k++ {
+		sorter := sorters[k]
+		switch {
+		case sorter(t1, t2):
+			return true
+		case sorter(t2, t1):
+			return false
+		}
+	}
+	return sorters[k](t1, t2)
+}
+
+// Sort sorts the entries in the tree.
+func (es Entries) Sort() {
+	sort.Sort(es)
+}
+
+// File is a wrapper around git.Blob with helper methods.
+type File struct {
+	*git.Blob
+	Entry *TreeEntry
+}
+
+// Name returns the name of the file.
+func (f *File) Name() string {
+	return f.Entry.Name()
+}
+
+// Path returns the full path of the file.
+func (f *File) Path() string {
+	return f.Entry.path
+}
+
+// SubTree returns the sub-tree at the given path.
+func (t *Tree) SubTree(path string) (*Tree, error) {
+	tree, err := t.Subtree(path)
+	if err != nil {
+		return nil, err
+	}
+	return &Tree{
+		Tree: tree,
+		Path: path,
+	}, nil
+}
+
+// Entries returns the entries in the tree.
+func (t *Tree) Entries() (Entries, error) {
+	entries, err := t.Tree.Entries()
+	if err != nil {
+		return nil, err
+	}
+	ret := make(Entries, len(entries))
+	for i, e := range entries {
+		ret[i] = &TreeEntry{
+			TreeEntry: e,
+			path:      filepath.Join(t.Path, e.Name()),
+		}
+	}
+	return ret, nil
+}
+
+func (t *Tree) TreeEntry(path string) (*TreeEntry, error) {
+	entry, err := t.Tree.TreeEntry(path)
+	if err != nil {
+		return nil, err
+	}
+	return &TreeEntry{
+		TreeEntry: entry,
+		path:      filepath.Join(t.Path, entry.Name()),
+	}, nil
+}
+
+const sniffLen = 8000
+
+// IsBinary detects if data is a binary value based on:
+// http://git.kernel.org/cgit/git/git.git/tree/xdiff-interface.c?id=HEAD#n198
+func IsBinary(r io.Reader) (bool, error) {
+	reader := bufio.NewReader(r)
+	c := 0
+	for {
+		if c == sniffLen {
+			break
+		}
+
+		b, err := reader.ReadByte()
+		if err == io.EOF {
+			break
+		}
+		if err != nil {
+			return false, err
+		}
+
+		if b == byte(0) {
+			return true, nil
+		}
+
+		c++
+	}
+
+	return false, nil
+}
+
+// IsBinary returns true if the file is binary.
+func (f *File) IsBinary() (bool, error) {
+	stdout := new(bytes.Buffer)
+	stderr := new(bytes.Buffer)
+	err := f.Pipeline(stdout, stderr)
+	if err != nil {
+		return false, err
+	}
+	r := bufio.NewReader(stdout)
+	return IsBinary(r)
+}
+
+// Mode returns the mode of the file in fs.FileMode format.
+func (e *TreeEntry) Mode() fs.FileMode {
+	m := e.Blob().Mode()
+	switch m {
+	case git.EntryTree:
+		return fs.ModeDir | fs.ModePerm
+	default:
+		return fs.FileMode(m)
+	}
+}
+
+// File returns the file for the TreeEntry.
+func (e *TreeEntry) File() *File {
+	b := e.Blob()
+	return &File{
+		Blob:  b,
+		Entry: e,
+	}
+}
+
+// Contents returns the contents of the file.
+func (e *TreeEntry) Contents() ([]byte, error) {
+	return e.File().Contents()
+}
+
+// Contents returns the contents of the file.
+func (f *File) Contents() ([]byte, error) {
+	return f.Blob.Bytes()
+}

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

@@ -3,26 +3,26 @@ 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"
+	"github.com/charmbracelet/soft-serve/pkg/git"
+	"github.com/charmbracelet/soft-serve/pkg/tui/common"
+	"github.com/charmbracelet/soft-serve/pkg/tui/refs"
+	vp "github.com/charmbracelet/soft-serve/pkg/tui/viewport"
 	"github.com/muesli/reflow/wrap"
 )
 
 type Bubble struct {
 	readmeViewport *vp.ViewportBubble
-	repo           types.Repo
+	repo           common.GitRepo
 	styles         *style.Styles
 	height         int
 	heightMargin   int
 	width          int
 	widthMargin    int
-	ref            *plumbing.Reference
+	ref            *git.Reference
 }
 
-func NewBubble(repo types.Repo, styles *style.Styles, width, wm, height, hm int) *Bubble {
+func NewBubble(repo common.GitRepo, styles *style.Styles, width, wm, height, hm int) *Bubble {
 	b := &Bubble{
 		readmeViewport: &vp.ViewportBubble{
 			Viewport: &viewport.Model{},
@@ -31,13 +31,12 @@ func NewBubble(repo types.Repo, styles *style.Styles, width, wm, height, hm int)
 		styles:       styles,
 		widthMargin:  wm,
 		heightMargin: hm,
-		ref:          repo.GetHEAD(),
 	}
 	b.SetSize(width, height)
 	return b
 }
 func (b *Bubble) Init() tea.Cmd {
-	return b.setupCmd
+	return b.reset
 }
 
 func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -56,11 +55,11 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case tea.KeyMsg:
 		switch msg.String() {
 		case "R":
-			b.GotoTop()
+			return b, b.reset
 		}
 	case refs.RefMsg:
 		b.ref = msg
-		return b, b.setupCmd
+		return b, b.reset
 	}
 	rv, cmd := b.readmeViewport.Update(msg)
 	b.readmeViewport = rv.(*vp.ViewportBubble)
@@ -83,17 +82,17 @@ func (b *Bubble) View() string {
 	return b.readmeViewport.View()
 }
 
-func (b *Bubble) Help() []types.HelpEntry {
+func (b *Bubble) Help() []common.HelpEntry {
 	return nil
 }
 
 func (b *Bubble) glamourize() (string, error) {
 	w := b.width - b.widthMargin - b.styles.RepoBody.GetHorizontalFrameSize()
-	rm := b.repo.GetReadme()
+	rm, rp := b.repo.Readme()
 	if rm == "" {
 		return b.styles.AboutNoReadme.Render("No readme found."), nil
 	}
-	f, err := types.RenderFile(b.repo.GetReadmePath(), rm, w)
+	f, err := common.RenderFile(rp, rm, w)
 	if err != nil {
 		return "", err
 	}
@@ -107,11 +106,16 @@ func (b *Bubble) glamourize() (string, error) {
 	return wrap.String(f, w), nil
 }
 
-func (b *Bubble) setupCmd() tea.Msg {
+func (b *Bubble) reset() tea.Msg {
 	md, err := b.glamourize()
 	if err != nil {
-		return types.ErrMsg{Err: err}
+		return common.ErrMsg{Err: err}
 	}
+	head, err := b.repo.HEAD()
+	if err != nil {
+		return common.ErrMsg{Err: err}
+	}
+	b.ref = head
 	b.readmeViewport.Viewport.SetContent(md)
 	b.GotoTop()
 	return nil

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

@@ -1,15 +1,15 @@
-package git
+package tui
 
 import (
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
-	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/about"
-	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/log"
-	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/refs"
-	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/tree"
-	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
 	"github.com/charmbracelet/soft-serve/internal/tui/style"
-	"github.com/go-git/go-git/v5/plumbing"
+	"github.com/charmbracelet/soft-serve/pkg/git"
+	"github.com/charmbracelet/soft-serve/pkg/tui/about"
+	"github.com/charmbracelet/soft-serve/pkg/tui/common"
+	"github.com/charmbracelet/soft-serve/pkg/tui/log"
+	"github.com/charmbracelet/soft-serve/pkg/tui/refs"
+	"github.com/charmbracelet/soft-serve/pkg/tui/tree"
 )
 
 const (
@@ -27,17 +27,17 @@ const (
 
 type Bubble struct {
 	state        state
-	repo         types.Repo
+	repo         common.GitRepo
 	height       int
 	heightMargin int
 	width        int
 	widthMargin  int
 	style        *style.Styles
 	boxes        []tea.Model
-	ref          *plumbing.Reference
+	ref          *git.Reference
 }
 
-func NewBubble(repo types.Repo, styles *style.Styles, width, wm, height, hm int) *Bubble {
+func NewBubble(repo common.GitRepo, styles *style.Styles, width, wm, height, hm int) *Bubble {
 	b := &Bubble{
 		repo:         repo,
 		state:        aboutState,
@@ -47,7 +47,6 @@ func NewBubble(repo types.Repo, styles *style.Styles, width, wm, height, hm int)
 		heightMargin: hm,
 		style:        styles,
 		boxes:        make([]tea.Model, 4),
-		ref:          repo.GetHEAD(),
 	}
 	heightMargin := hm + lipgloss.Height(b.headerView())
 	b.boxes[aboutState] = about.NewBubble(repo, b.style, b.width, wm, b.height, heightMargin)
@@ -106,20 +105,20 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return b, tea.Batch(cmds...)
 }
 
-func (b *Bubble) Help() []types.HelpEntry {
-	h := []types.HelpEntry{}
-	h = append(h, b.boxes[b.state].(types.BubbleHelper).Help()...)
+func (b *Bubble) Help() []common.HelpEntry {
+	h := []common.HelpEntry{}
+	h = append(h, b.boxes[b.state].(common.BubbleHelper).Help()...)
 	if b.repo.Name() != "config" {
-		h = append(h, types.HelpEntry{Key: "R", Value: "readme"})
-		h = append(h, types.HelpEntry{Key: "F", Value: "files"})
-		h = append(h, types.HelpEntry{Key: "C", Value: "commits"})
-		h = append(h, types.HelpEntry{Key: "B", Value: "branches"})
+		h = append(h, common.HelpEntry{Key: "R", Value: "readme"})
+		h = append(h, common.HelpEntry{Key: "F", Value: "files"})
+		h = append(h, common.HelpEntry{Key: "C", Value: "commits"})
+		h = append(h, common.HelpEntry{Key: "B", Value: "branches"})
 	}
 	return h
 }
 
-func (b *Bubble) Reference() plumbing.ReferenceName {
-	return b.ref.Name()
+func (b *Bubble) Reference() *git.Reference {
+	return b.ref
 }
 
 func (b *Bubble) headerView() string {
@@ -133,6 +132,11 @@ func (b *Bubble) View() string {
 }
 
 func (b *Bubble) setupCmd() tea.Msg {
+	head, err := b.repo.HEAD()
+	if err != nil {
+		return common.ErrMsg{Err: err}
+	}
+	b.ref = head
 	cmds := make([]tea.Cmd, 0)
 	for _, bx := range b.boxes {
 		if bx != nil {
@@ -140,7 +144,7 @@ func (b *Bubble) setupCmd() tea.Msg {
 			if initCmd != nil {
 				msg := initCmd()
 				switch msg := msg.(type) {
-				case types.ErrMsg:
+				case common.ErrMsg:
 					return msg
 				}
 			}

internal/tui/bubbles/git/types/error.go → pkg/tui/common/error.go 🔗

@@ -1,4 +1,4 @@
-package types
+package common
 
 import (
 	"errors"

pkg/tui/common/git.go 🔗

@@ -0,0 +1,16 @@
+package common
+
+import (
+	"github.com/charmbracelet/soft-serve/pkg/git"
+)
+
+type GitRepo interface {
+	Name() string
+	Readme() (string, string)
+	HEAD() (*git.Reference, error)
+	CommitsByPage(*git.Reference, int, int) (git.Commits, error)
+	CountCommits(*git.Reference) (int64, error)
+	Diff(*git.Commit) (*git.Diff, error)
+	References() ([]*git.Reference, error)
+	Tree(*git.Reference, string) (*git.Tree, error)
+}

internal/tui/bubbles/git/types/help.go → pkg/tui/common/help.go 🔗

@@ -1,4 +1,4 @@
-package types
+package common
 
 type BubbleHelper interface {
 	Help() []HelpEntry

internal/tui/bubbles/git/types/reset.go → pkg/tui/common/reset.go 🔗

@@ -1,4 +1,4 @@
-package types
+package common
 
 import tea "github.com/charmbracelet/bubbletea"
 

internal/tui/bubbles/git/types/utils.go → pkg/tui/common/utils.go 🔗

@@ -1,4 +1,4 @@
-package types
+package common
 
 import "github.com/muesli/reflow/truncate"
 

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

@@ -1,10 +1,8 @@
 package log
 
 import (
-	"context"
 	"fmt"
 	"io"
-	"math"
 	"strings"
 	"time"
 
@@ -13,13 +11,11 @@ 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"
+	"github.com/charmbracelet/soft-serve/pkg/git"
+	"github.com/charmbracelet/soft-serve/pkg/tui/common"
+	"github.com/charmbracelet/soft-serve/pkg/tui/refs"
+	vp "github.com/charmbracelet/soft-serve/pkg/tui/viewport"
 )
 
 var (
@@ -30,7 +26,7 @@ var (
 	waitBeforeLoading = time.Millisecond * 300
 )
 
-type commitMsg *object.Commit
+type commitMsg *git.Commit
 
 type sessionState int
 
@@ -42,13 +38,12 @@ const (
 )
 
 type item struct {
-	*object.Commit
+	*git.Commit
 }
 
 func (i item) Title() string {
-	lines := strings.Split(i.Message, "\n")
-	if len(lines) > 0 {
-		return lines[0]
+	if i.Commit != nil {
+		return strings.Split(i.Commit.Message, "\n")[0]
 	}
 	return ""
 }
@@ -67,40 +62,45 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
 	if !ok {
 		return
 	}
+	if i.Commit == nil {
+		return
+	}
 
+	hash := i.ID.String()
 	leftMargin := d.style.LogItemSelector.GetMarginLeft() +
 		d.style.LogItemSelector.GetWidth() +
 		d.style.LogItemHash.GetMarginLeft() +
 		d.style.LogItemHash.GetWidth() +
 		d.style.LogItemInactive.GetMarginLeft()
-	title := types.TruncateString(i.Title(), m.Width()-leftMargin, "…")
+	title := common.TruncateString(i.Title(), m.Width()-leftMargin, "…")
 	if index == m.Index() {
 		fmt.Fprint(w, d.style.LogItemSelector.Render(">")+
-			d.style.LogItemHash.Bold(true).Render(i.Hash.String()[:7])+
+			d.style.LogItemHash.Bold(true).Render(hash[:7])+
 			d.style.LogItemActive.Render(title))
 	} else {
 		fmt.Fprint(w, d.style.LogItemSelector.Render(" ")+
-			d.style.LogItemHash.Render(i.Hash.String()[:7])+
+			d.style.LogItemHash.Render(hash[:7])+
 			d.style.LogItemInactive.Render(title))
 	}
 }
 
 type Bubble struct {
-	repo           types.Repo
+	repo           common.GitRepo
+	count          int64
 	list           list.Model
 	state          sessionState
 	commitViewport *vp.ViewportBubble
-	ref            *plumbing.Reference
+	ref            *git.Reference
 	style          *style.Styles
 	width          int
 	widthMargin    int
 	height         int
 	heightMargin   int
-	error          types.ErrMsg
+	error          common.ErrMsg
 	spinner        spinner.Model
 }
 
-func NewBubble(repo types.Repo, styles *style.Styles, width, widthMargin, height, heightMargin int) *Bubble {
+func NewBubble(repo common.GitRepo, styles *style.Styles, width, widthMargin, height, heightMargin int) *Bubble {
 	l := list.New([]list.Item{}, itemDelegate{styles}, width-widthMargin, height-heightMargin)
 	l.SetShowFilter(false)
 	l.SetShowHelp(false)
@@ -109,8 +109,8 @@ func NewBubble(repo types.Repo, styles *style.Styles, width, widthMargin, height
 	l.SetShowTitle(false)
 	l.SetFilteringEnabled(false)
 	l.DisableQuitKeybindings()
-	l.KeyMap.NextPage = types.NextPage
-	l.KeyMap.PrevPage = types.PrevPage
+	l.KeyMap.NextPage = common.NextPage
+	l.KeyMap.PrevPage = common.PrevPage
 	s := spinner.New()
 	s.Spinner = spinner.Dot
 	s.Style = styles.Spinner
@@ -126,7 +126,6 @@ func NewBubble(repo types.Repo, styles *style.Styles, width, widthMargin, height
 		height:       height,
 		heightMargin: heightMargin,
 		list:         l,
-		ref:          repo.GetHEAD(),
 		spinner:      s,
 	}
 	b.SetSize(width, height)
@@ -134,26 +133,49 @@ func NewBubble(repo types.Repo, styles *style.Styles, width, widthMargin, height
 }
 
 func (b *Bubble) reset() tea.Cmd {
+	errMsg := func(err error) tea.Cmd {
+		return func() tea.Msg { return common.ErrMsg{Err: err} }
+	}
+	ref, err := b.repo.HEAD()
+	if err != nil {
+		return errMsg(err)
+	}
+	b.ref = ref
+	count, err := b.repo.CountCommits(ref)
+	if err != nil {
+		return errMsg(err)
+	}
+	b.count = count
 	b.state = logState
 	b.list.Select(0)
-	cmd := b.updateItems()
 	b.SetSize(b.width, b.height)
+	cmd := b.updateItems()
 	return cmd
 }
 
 func (b *Bubble) updateItems() tea.Cmd {
-	items := make([]list.Item, 0)
-	cc, err := b.repo.GetCommits(b.ref)
+	count := b.count
+	items := make([]list.Item, count)
+	b.list.SetItems(items)
+	page := b.list.Paginator.Page
+	limit := b.list.Paginator.PerPage
+	skip := page * limit
+	cc, err := b.repo.CommitsByPage(b.ref, page+1, limit)
 	if err != nil {
-		return func() tea.Msg { return types.ErrMsg{Err: err} }
+		return func() tea.Msg { return common.ErrMsg{Err: err} }
 	}
-	for _, c := range cc {
-		items = append(items, item{c})
+	for i, c := range cc {
+		idx := i + skip
+		if idx >= int(count) {
+			break
+		}
+		items[idx] = item{c}
 	}
-	return b.list.SetItems(items)
+	cmd := b.list.SetItems(items)
+	return cmd
 }
 
-func (b *Bubble) Help() []types.HelpEntry {
+func (b *Bubble) Help() []common.HelpEntry {
 	return nil
 }
 
@@ -179,6 +201,7 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		b.SetSize(msg.Width, msg.Height)
+		cmds = append(cmds, b.updateItems())
 
 	case tea.KeyMsg:
 		switch msg.String() {
@@ -193,7 +216,22 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				b.state = logState
 			}
 		}
-	case types.ErrMsg:
+		switch b.state {
+		case logState:
+			curPage := b.list.Paginator.Page
+			m, cmd := b.list.Update(msg)
+			b.list = m
+			if m.Paginator.Page != curPage {
+				cmds = append(cmds, b.updateItems())
+			}
+			cmds = append(cmds, cmd)
+		case commitState:
+			rv, cmd := b.commitViewport.Update(msg)
+			b.commitViewport = rv.(*vp.ViewportBubble)
+			cmds = append(cmds, cmd)
+		}
+		return b, tea.Batch(cmds...)
+	case common.ErrMsg:
 		b.error = msg
 		b.state = errorState
 		return b, nil
@@ -203,6 +241,11 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 	case refs.RefMsg:
 		b.ref = msg
+		count, err := b.repo.CountCommits(msg)
+		if err != nil {
+			b.error = common.ErrMsg{Err: err}
+		}
+		b.count = count
 	case spinner.TickMsg:
 		if b.state == loadingState {
 			s, cmd := b.spinner.Update(msg)
@@ -213,42 +256,39 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 	}
 
-	switch b.state {
-	case commitState:
-		rv, cmd := b.commitViewport.Update(msg)
-		b.commitViewport = rv.(*vp.ViewportBubble)
-		cmds = append(cmds, cmd)
-	case logState:
-		l, cmd := b.list.Update(msg)
-		b.list = l
-		cmds = append(cmds, cmd)
-	}
-
 	return b, tea.Batch(cmds...)
 }
 
-func (b *Bubble) loadPatch(c *object.Commit) error {
+func (b *Bubble) loadPatch(c *git.Commit) error {
 	var patch strings.Builder
 	style := b.style.LogCommit.Copy().Width(b.width - b.widthMargin - b.style.LogCommit.GetHorizontalFrameSize())
-	ctx, cancel := context.WithTimeout(context.TODO(), types.MaxPatchWait)
-	defer cancel()
-	p, err := b.repo.PatchCtx(ctx, c)
+	p, err := b.repo.Diff(c)
 	if err != nil {
 		return err
 	}
+	stats := strings.Split(p.Stats().String(), "\n")
+	for i, l := range stats {
+		ch := strings.Split(l, "|")
+		if len(ch) > 1 {
+			adddel := ch[len(ch)-1]
+			adddel = strings.ReplaceAll(adddel, "+", b.style.LogCommitStatsAdd.Render("+"))
+			adddel = strings.ReplaceAll(adddel, "-", b.style.LogCommitStatsDel.Render("-"))
+			stats[i] = strings.Join(ch[:len(ch)-1], "|") + "|" + adddel
+		}
+	}
 	patch.WriteString(b.renderCommit(c))
-	fpl := len(p.FilePatches())
-	if fpl > types.MaxDiffFiles {
-		patch.WriteString("\n" + types.ErrDiffFilesTooLong.Error())
+	fpl := len(p.Files)
+	if fpl > common.MaxDiffFiles {
+		patch.WriteString("\n" + common.ErrDiffFilesTooLong.Error())
 	} else {
-		patch.WriteString("\n" + b.renderStats(p.Stats()))
+		patch.WriteString("\n" + strings.Join(stats, "\n"))
 	}
-	if fpl <= types.MaxDiffFiles {
-		ps := p.String()
-		if len(strings.Split(ps, "\n")) > types.MaxDiffLines {
-			patch.WriteString("\n" + types.ErrDiffTooLong.Error())
+	if fpl <= common.MaxDiffFiles {
+		ps := ""
+		if len(strings.Split(ps, "\n")) > common.MaxDiffLines {
+			patch.WriteString("\n" + common.ErrDiffTooLong.Error())
 		} else {
-			patch.WriteString("\n" + b.renderDiff(ps))
+			patch.WriteString("\n" + b.renderDiff(p))
 		}
 	}
 	content := style.Render(patch.String())
@@ -280,118 +320,31 @@ func (b *Bubble) loadCommit() tea.Cmd {
 			b.state = loadingState
 		}
 		if err != nil {
-			return types.ErrMsg{Err: err}
+			return common.ErrMsg{Err: err}
 		}
 		return commitMsg(c.Commit)
 	}
 }
 
-func (b *Bubble) renderCommit(c *object.Commit) string {
+func (b *Bubble) renderCommit(c *git.Commit) string {
 	s := strings.Builder{}
 	// FIXME: lipgloss prints empty lines when CRLF is used
 	// sanitize commit message from CRLF
 	msg := strings.ReplaceAll(c.Message, "\r\n", "\n")
 	s.WriteString(fmt.Sprintf("%s\n%s\n%s\n%s\n",
-		b.style.LogCommitHash.Render("commit "+c.Hash.String()),
-		b.style.LogCommitAuthor.Render("Author: "+c.Author.String()),
+		b.style.LogCommitHash.Render("commit "+c.ID.String()),
+		b.style.LogCommitAuthor.Render(fmt.Sprintf("Author: %s <%s>", c.Author.Name, c.Author.Email)),
 		b.style.LogCommitDate.Render("Date:   "+c.Committer.When.Format(time.UnixDate)),
 		b.style.LogCommitBody.Render(msg),
 	))
 	return s.String()
 }
 
-func (b *Bubble) renderStats(fileStats object.FileStats) string {
-	padLength := float64(len(" "))
-	newlineLength := float64(len("\n"))
-	separatorLength := float64(len("|"))
-	// Soft line length limit. The text length calculation below excludes
-	// length of the change number. Adding that would take it closer to 80,
-	// but probably not more than 80, until it's a huge number.
-	lineLength := 72.0
-
-	// Get the longest filename and longest total change.
-	var longestLength float64
-	var longestTotalChange float64
-	for _, fs := range fileStats {
-		if int(longestLength) < len(fs.Name) {
-			longestLength = float64(len(fs.Name))
-		}
-		totalChange := fs.Addition + fs.Deletion
-		if int(longestTotalChange) < totalChange {
-			longestTotalChange = float64(totalChange)
-		}
-	}
-
-	// Parts of the output:
-	// <pad><filename><pad>|<pad><changeNumber><pad><+++/---><newline>
-	// example: " main.go | 10 +++++++--- "
-
-	// <pad><filename><pad>
-	leftTextLength := padLength + longestLength + padLength
-
-	// <pad><number><pad><+++++/-----><newline>
-	// Excluding number length here.
-	rightTextLength := padLength + padLength + newlineLength
-
-	totalTextArea := leftTextLength + separatorLength + rightTextLength
-	heightOfHistogram := lineLength - totalTextArea
-
-	// Scale the histogram.
-	var scaleFactor float64
-	if longestTotalChange > heightOfHistogram {
-		// Scale down to heightOfHistogram.
-		scaleFactor = longestTotalChange / heightOfHistogram
-	} else {
-		scaleFactor = 1.0
-	}
-
-	taddc := 0
-	tdelc := 0
-	output := strings.Builder{}
-	for _, fs := range fileStats {
-		taddc += fs.Addition
-		tdelc += fs.Deletion
-		addn := float64(fs.Addition)
-		deln := float64(fs.Deletion)
-		addc := int(math.Floor(addn / scaleFactor))
-		delc := int(math.Floor(deln / scaleFactor))
-		if addc < 0 {
-			addc = 0
-		}
-		if delc < 0 {
-			delc = 0
-		}
-		adds := strings.Repeat("+", addc)
-		dels := strings.Repeat("-", delc)
-		diffLines := fmt.Sprint(fs.Addition + fs.Deletion)
-		totalDiffLines := fmt.Sprint(int(longestTotalChange))
-		fmt.Fprintf(&output, "%s | %s %s%s\n",
-			fs.Name+strings.Repeat(" ", int(longestLength)-len(fs.Name)),
-			strings.Repeat(" ", len(totalDiffLines)-len(diffLines))+diffLines,
-			b.style.LogCommitStatsAdd.Render(adds),
-			b.style.LogCommitStatsDel.Render(dels))
-	}
-	files := len(fileStats)
-	fc := fmt.Sprintf("%s changed", english.Plural(files, "file", ""))
-	ins := fmt.Sprintf("%s(+)", english.Plural(taddc, "insertion", ""))
-	dels := fmt.Sprintf("%s(-)", english.Plural(tdelc, "deletion", ""))
-	fmt.Fprint(&output, fc)
-	if taddc > 0 {
-		fmt.Fprintf(&output, ", %s", ins)
-	}
-	if tdelc > 0 {
-		fmt.Fprintf(&output, ", %s", dels)
-	}
-	fmt.Fprint(&output, "\n")
-
-	return output.String()
-}
-
-func (b *Bubble) renderDiff(diff string) string {
+func (b *Bubble) renderDiff(diff *git.Diff) string {
 	var s strings.Builder
-	pr := strings.Builder{}
-	diffChroma.Code = diff
-	err := diffChroma.Render(&pr, types.RenderCtx)
+	var pr strings.Builder
+	diffChroma.Code = diff.Patch()
+	err := diffChroma.Render(&pr, common.RenderCtx)
 	if err != nil {
 		s.WriteString(fmt.Sprintf("\n%s", err.Error()))
 	} else {

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

@@ -7,19 +7,19 @@ import (
 
 	"github.com/charmbracelet/bubbles/list"
 	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
 	"github.com/charmbracelet/soft-serve/internal/tui/style"
-	"github.com/go-git/go-git/v5/plumbing"
+	"github.com/charmbracelet/soft-serve/pkg/git"
+	"github.com/charmbracelet/soft-serve/pkg/tui/common"
 )
 
-type RefMsg = *plumbing.Reference
+type RefMsg = *git.Reference
 
 type item struct {
-	*plumbing.Reference
+	*git.Reference
 }
 
 func (i item) Short() string {
-	return i.Name().Short()
+	return i.Reference.Name().Short()
 }
 
 func (i item) FilterValue() string { return i.Short() }
@@ -29,7 +29,7 @@ type items []item
 func (cl items) Len() int      { return len(cl) }
 func (cl items) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }
 func (cl items) Less(i, j int) bool {
-	return cl[i].Name().Short() < cl[j].Name().Short()
+	return cl[i].Short() < cl[j].Short()
 }
 
 type itemDelegate struct {
@@ -47,7 +47,7 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
 	}
 
 	ref := i.Short()
-	if i.Name().IsTag() {
+	if i.Reference.IsTag() {
 		ref = s.RefItemTag.Render(ref)
 	}
 	ref = s.RefItemBranch.Render(ref)
@@ -55,7 +55,7 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
 		s.RefItemSelector.GetMarginLeft() -
 		s.RefItemSelector.GetWidth() -
 		s.RefItemInactive.GetMarginLeft()
-	ref = types.TruncateString(ref, refMaxWidth, "…")
+	ref = common.TruncateString(ref, refMaxWidth, "…")
 	if index == m.Index() {
 		fmt.Fprint(w, s.RefItemSelector.Render(">")+
 			s.RefItemActive.Render(ref))
@@ -66,17 +66,21 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
 }
 
 type Bubble struct {
-	repo         types.Repo
+	repo         common.GitRepo
 	list         list.Model
 	style        *style.Styles
 	width        int
 	widthMargin  int
 	height       int
 	heightMargin int
-	ref          *plumbing.Reference
+	ref          *git.Reference
 }
 
-func NewBubble(repo types.Repo, styles *style.Styles, width, widthMargin, height, heightMargin int) *Bubble {
+func NewBubble(repo common.GitRepo, styles *style.Styles, width, widthMargin, height, heightMargin int) *Bubble {
+	head, err := repo.HEAD()
+	if err != nil {
+		return nil
+	}
 	l := list.NewModel([]list.Item{}, itemDelegate{styles}, width-widthMargin, height-heightMargin)
 	l.SetShowFilter(false)
 	l.SetShowHelp(false)
@@ -93,13 +97,13 @@ func NewBubble(repo types.Repo, styles *style.Styles, width, widthMargin, height
 		widthMargin:  widthMargin,
 		heightMargin: heightMargin,
 		list:         l,
-		ref:          repo.GetHEAD(),
+		ref:          head,
 	}
 	b.SetSize(width, height)
 	return b
 }
 
-func (b *Bubble) SetBranch(ref *plumbing.Reference) (tea.Model, tea.Cmd) {
+func (b *Bubble) SetBranch(ref *git.Reference) (tea.Model, tea.Cmd) {
 	b.ref = ref
 	return b, func() tea.Msg {
 		return RefMsg(ref)
@@ -123,21 +127,21 @@ func (b *Bubble) SetSize(width, height int) {
 	b.list.Styles.PaginationStyle = b.style.RefPaginator.Copy().Width(width - b.widthMargin)
 }
 
-func (b *Bubble) Help() []types.HelpEntry {
+func (b *Bubble) Help() []common.HelpEntry {
 	return nil
 }
 
 func (b *Bubble) updateItems() tea.Cmd {
 	its := make(items, 0)
 	tags := make(items, 0)
-	for _, r := range b.repo.GetReferences() {
-		if r.Type() != plumbing.HashReference {
-			continue
-		}
-		n := r.Name()
-		if n.IsTag() {
+	refs, err := b.repo.References()
+	if err != nil {
+		return func() tea.Msg { return common.ErrMsg{Err: err} }
+	}
+	for _, r := range refs {
+		if r.IsTag() {
 			tags = append(tags, item{r})
-		} else if n.IsBranch() {
+		} else if r.IsBranch() {
 			its = append(its, item{r})
 		}
 	}

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

@@ -3,22 +3,20 @@ package tree
 import (
 	"fmt"
 	"io"
+	"io/fs"
 	"path/filepath"
-	"sort"
 	"strings"
 
 	"github.com/charmbracelet/bubbles/list"
 	"github.com/charmbracelet/bubbles/viewport"
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
-	"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/charmbracelet/soft-serve/pkg/git"
+	"github.com/charmbracelet/soft-serve/pkg/tui/common"
+	"github.com/charmbracelet/soft-serve/pkg/tui/refs"
+	vp "github.com/charmbracelet/soft-serve/pkg/tui/viewport"
 	"github.com/dustin/go-humanize"
-	"github.com/go-git/go-git/v5/plumbing"
-	"github.com/go-git/go-git/v5/plumbing/filemode"
-	"github.com/go-git/go-git/v5/plumbing/object"
 )
 
 type fileMsg struct {
@@ -34,16 +32,15 @@ const (
 )
 
 type item struct {
-	entry *object.TreeEntry
-	file  *object.File
+	entry *git.TreeEntry
 }
 
 func (i item) Name() string {
-	return i.entry.Name
+	return i.entry.Name()
 }
 
-func (i item) Mode() filemode.FileMode {
-	return i.entry.Mode
+func (i item) Mode() fs.FileMode {
+	return i.entry.Mode()
 }
 
 func (i item) FilterValue() string { return i.Name() }
@@ -53,11 +50,11 @@ type items []item
 func (cl items) Len() int      { return len(cl) }
 func (cl items) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }
 func (cl items) Less(i, j int) bool {
-	if cl[i].Mode() == filemode.Dir && cl[j].Mode() == filemode.Dir {
+	if cl[i].entry.IsTree() && cl[j].entry.IsTree() {
 		return cl[i].Name() < cl[j].Name()
-	} else if cl[i].Mode() == filemode.Dir {
+	} else if cl[i].entry.IsTree() {
 		return true
-	} else if cl[j].Mode() == filemode.Dir {
+	} else if cl[j].entry.IsTree() {
 		return false
 	} else {
 		return cl[i].Name() < cl[j].Name()
@@ -79,15 +76,13 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
 	}
 
 	name := i.Name()
-	if i.Mode() == filemode.Dir {
+	size := humanize.Bytes(uint64(i.entry.Size()))
+	if i.entry.IsTree() {
+		size = ""
 		name = s.TreeFileDir.Render(name)
 	}
-	size := ""
-	if i.file != nil {
-		size = humanize.Bytes(uint64(i.file.Size))
-	}
 	var cs lipgloss.Style
-	mode, _ := i.Mode().ToOSFileMode()
+	mode := i.Mode()
 	if index == m.Index() {
 		cs = s.TreeItemActive
 		fmt.Fprint(w, s.TreeItemSelector.Render(">"))
@@ -101,7 +96,7 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
 		s.TreeFileMode.GetWidth() +
 		cs.GetMarginLeft()
 	rightMargin := s.TreeFileSize.GetMarginLeft() + lipgloss.Width(size)
-	name = types.TruncateString(name, m.Width()-leftMargin-rightMargin, "…")
+	name = common.TruncateString(name, m.Width()-leftMargin-rightMargin, "…")
 	sizeStyle := s.TreeFileSize.Copy().
 		Width(m.Width() -
 			leftMargin -
@@ -114,7 +109,7 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
 }
 
 type Bubble struct {
-	repo         types.Repo
+	repo         common.GitRepo
 	list         list.Model
 	style        *style.Styles
 	width        int
@@ -123,13 +118,13 @@ type Bubble struct {
 	heightMargin int
 	path         string
 	state        sessionState
-	error        types.ErrMsg
+	error        common.ErrMsg
 	fileViewport *vp.ViewportBubble
 	lastSelected []int
-	ref          *plumbing.Reference
+	ref          *git.Reference
 }
 
-func NewBubble(repo types.Repo, styles *style.Styles, width, widthMargin, height, heightMargin int) *Bubble {
+func NewBubble(repo common.GitRepo, styles *style.Styles, width, widthMargin, height, heightMargin int) *Bubble {
 	l := list.New([]list.Item{}, itemDelegate{styles}, width-widthMargin, height-heightMargin)
 	l.SetShowFilter(false)
 	l.SetShowHelp(false)
@@ -138,8 +133,8 @@ func NewBubble(repo types.Repo, styles *style.Styles, width, widthMargin, height
 	l.SetShowTitle(false)
 	l.SetFilteringEnabled(false)
 	l.DisableQuitKeybindings()
-	l.KeyMap.NextPage = types.NextPage
-	l.KeyMap.PrevPage = types.PrevPage
+	l.KeyMap.NextPage = common.NextPage
+	l.KeyMap.PrevPage = common.PrevPage
 	l.Styles.NoItems = styles.TreeNoItems
 	b := &Bubble{
 		fileViewport: &vp.ViewportBubble{
@@ -153,7 +148,6 @@ func NewBubble(repo types.Repo, styles *style.Styles, width, widthMargin, height
 		heightMargin: heightMargin,
 		list:         l,
 		state:        treeState,
-		ref:          repo.GetHEAD(),
 	}
 	b.SetSize(width, height)
 	return b
@@ -169,6 +163,13 @@ func (b *Bubble) reset() tea.Cmd {
 }
 
 func (b *Bubble) Init() tea.Cmd {
+	head, err := b.repo.HEAD()
+	if err != nil {
+		return func() tea.Msg {
+			return common.ErrMsg{Err: err}
+		}
+	}
+	b.ref = head
 	return nil
 }
 
@@ -181,37 +182,30 @@ func (b *Bubble) SetSize(width, height int) {
 	b.list.Styles.PaginationStyle = b.style.LogPaginator.Copy().Width(width - b.widthMargin)
 }
 
-func (b *Bubble) Help() []types.HelpEntry {
+func (b *Bubble) Help() []common.HelpEntry {
 	return nil
 }
 
 func (b *Bubble) updateItems() tea.Cmd {
-	its := make(items, 0)
+	files := make([]list.Item, 0)
+	dirs := make([]list.Item, 0)
 	t, err := b.repo.Tree(b.ref, b.path)
 	if err != nil {
-		return func() tea.Msg { return types.ErrMsg{Err: err} }
+		return func() tea.Msg { return common.ErrMsg{Err: err} }
 	}
-	tw := object.NewTreeWalker(t, false, map[plumbing.Hash]bool{})
-	defer tw.Close()
-	for {
-		_, e, err := tw.Next()
-		if err != nil {
-			break
-		}
-		i := item{entry: &e}
-		if e.Mode.IsFile() {
-			if f, err := t.TreeEntryFile(&e); err == nil {
-				i.file = f
-			}
-		}
-		its = append(its, i)
+	ents, err := t.Entries()
+	if err != nil {
+		return func() tea.Msg { return common.ErrMsg{Err: err} }
 	}
-	sort.Sort(its)
-	itt := make([]list.Item, len(its))
-	for i, it := range its {
-		itt[i] = it
+	ents.Sort()
+	for _, e := range ents {
+		if e.IsTree() {
+			dirs = append(dirs, item{e})
+		} else {
+			files = append(files, item{e})
+		}
 	}
-	cmd := b.list.SetItems(itt)
+	cmd := b.list.SetItems(append(dirs, files...))
 	b.list.Select(0)
 	return cmd
 }
@@ -224,7 +218,7 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 	case tea.KeyMsg:
 		if b.state == errorState {
-			ref := b.repo.GetHEAD()
+			ref, _ := b.repo.HEAD()
 			b.ref = ref
 			return b, tea.Batch(b.reset(), func() tea.Msg {
 				return ref
@@ -240,7 +234,7 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				item := b.list.SelectedItem().(item)
 				mode := item.Mode()
 				b.path = filepath.Join(b.path, item.Name())
-				if mode == filemode.Dir {
+				if mode.IsDir() {
 					b.lastSelected = append(b.lastSelected, index)
 					cmds = append(cmds, b.updateItems())
 				} else {
@@ -267,7 +261,7 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		b.ref = msg
 		return b, b.reset()
 
-	case types.ErrMsg:
+	case common.ErrMsg:
 		b.error = msg
 		b.state = errorState
 		return b, nil
@@ -308,22 +302,23 @@ func (b *Bubble) View() string {
 
 func (b *Bubble) loadFile(i item) tea.Cmd {
 	return func() tea.Msg {
-		if !i.Mode().IsFile() || i.file == nil {
-			return types.ErrMsg{Err: types.ErrInvalidFile}
+		f := i.entry.File()
+		if i.Mode().IsDir() || f == nil {
+			return common.ErrMsg{Err: common.ErrInvalidFile}
 		}
-		bin, err := i.file.IsBinary()
+		bin, err := f.IsBinary()
 		if err != nil {
-			return types.ErrMsg{Err: err}
+			return common.ErrMsg{Err: err}
 		}
 		if bin {
-			return types.ErrMsg{Err: types.ErrBinaryFile}
+			return common.ErrMsg{Err: common.ErrBinaryFile}
 		}
-		c, err := i.file.Contents()
+		c, err := f.Bytes()
 		if err != nil {
-			return types.ErrMsg{Err: err}
+			return common.ErrMsg{Err: err}
 		}
 		return fileMsg{
-			content: c,
+			content: string(c),
 		}
 	}
 }
@@ -331,11 +326,11 @@ func (b *Bubble) loadFile(i item) tea.Cmd {
 func (b *Bubble) renderFile(m fileMsg) string {
 	s := strings.Builder{}
 	c := m.content
-	if len(strings.Split(c, "\n")) > types.MaxDiffLines {
-		s.WriteString(types.ErrFileTooLarge.Error())
+	if len(strings.Split(c, "\n")) > common.MaxDiffLines {
+		s.WriteString(b.style.TreeNoItems.Render(common.ErrFileTooLarge.Error()))
 	} else {
 		w := b.width - b.widthMargin - b.style.RepoBody.GetHorizontalFrameSize()
-		f, err := types.RenderFile(b.path, m.content, w)
+		f, err := common.RenderFile(b.path, m.content, w)
 		if err != nil {
 			s.WriteString(err.Error())
 		} else {

server/middleware.go 🔗

@@ -3,19 +3,18 @@ package server
 import (
 	"fmt"
 	"path/filepath"
-	"sort"
 	"strings"
 
 	"github.com/alecthomas/chroma/lexers"
 	gansi "github.com/charmbracelet/glamour/ansi"
 	"github.com/charmbracelet/lipgloss"
 	appCfg "github.com/charmbracelet/soft-serve/internal/config"
-	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
+	"github.com/charmbracelet/soft-serve/pkg/git"
+	"github.com/charmbracelet/soft-serve/pkg/tui/common"
 	"github.com/charmbracelet/wish"
 	gitwish "github.com/charmbracelet/wish/git"
 	"github.com/gliderlabs/ssh"
-	"github.com/go-git/go-git/v5/plumbing/filemode"
-	"github.com/go-git/go-git/v5/plumbing/object"
+	ggit "github.com/gogs/git-module"
 	"github.com/muesli/termenv"
 )
 
@@ -27,22 +26,6 @@ var (
 	filemodeStyle  = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777"))
 )
 
-type entries []object.TreeEntry
-
-func (cl entries) Len() int      { return len(cl) }
-func (cl entries) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }
-func (cl entries) Less(i, j int) bool {
-	if cl[i].Mode == filemode.Dir && cl[j].Mode == filemode.Dir {
-		return cl[i].Name < cl[j].Name
-	} else if cl[i].Mode == filemode.Dir {
-		return true
-	} else if cl[j].Mode == filemode.Dir {
-		return false
-	} else {
-		return cl[i].Name < cl[j].Name
-	}
-}
-
 // softServeMiddleware is a middleware that handles displaying files with the
 // option of syntax highlighting and line numbers.
 func softServeMiddleware(ac *appCfg.Config) wish.Middleware {
@@ -90,15 +73,27 @@ func softServeMiddleware(ac *appCfg.Config) wish.Middleware {
 						_ = s.Exit(1)
 						return
 					}
+					ref, err := rs.HEAD()
+					if err != nil {
+						_, _ = s.Write([]byte(err.Error()))
+						_ = s.Exit(1)
+						return
+					}
 					p := strings.Join(ps[1:], "/")
-					t, err := rs.LatestTree(p)
-					if err != nil && err != object.ErrDirectoryNotFound {
+					t, err := rs.Tree(ref, p)
+					if err != nil && err != ggit.ErrRevisionNotExist {
 						_, _ = s.Write([]byte(err.Error()))
 						_ = s.Exit(1)
 						return
 					}
-					if err == object.ErrDirectoryNotFound {
-						fc, err := rs.LatestFile(p)
+					if err == ggit.ErrRevisionNotExist {
+						_, _ = s.Write([]byte(git.ErrFileNotFound.Error()))
+						_ = s.Exit(1)
+						return
+					}
+					ents, err := t.Entries()
+					if err != nil {
+						fc, _, err := rs.LatestFile(p)
 						if err != nil {
 							_, _ = s.Write([]byte(err.Error()))
 							_ = s.Exit(1)
@@ -118,20 +113,19 @@ func softServeMiddleware(ac *appCfg.Config) wish.Middleware {
 						}
 						s.Write([]byte(fc))
 					} else {
-						ents := entries(t.Entries)
-						sort.Sort(ents)
+						ents.Sort()
 						for _, e := range ents {
-							m, _ := e.Mode.ToOSFileMode()
+							m := e.Mode()
 							if m == 0 {
 								s.Write([]byte(strings.Repeat(" ", 10)))
 							} else {
 								s.Write([]byte(filemodeStyle.Render(m.String())))
 							}
 							s.Write([]byte(" "))
-							if e.Mode.IsFile() {
-								s.Write([]byte(filenameStyle.Render(e.Name)))
+							if !e.IsTree() {
+								s.Write([]byte(filenameStyle.Render(e.Name())))
 							} else {
-								s.Write([]byte(dirnameStyle.Render(e.Name)))
+								s.Write([]byte(dirnameStyle.Render(e.Name())))
 							}
 							s.Write([]byte("\n"))
 						}
@@ -177,7 +171,7 @@ func withFormatting(p, c string) (string, error) {
 		Language: lang,
 	}
 	r := strings.Builder{}
-	styles := types.DefaultStyles()
+	styles := common.DefaultStyles()
 	styles.CodeBlock.Margin = &zero
 	rctx := gansi.NewRenderContext(gansi.Options{
 		Styles:       styles,

server/server.go 🔗

@@ -8,7 +8,6 @@ import (
 	"github.com/charmbracelet/soft-serve/config"
 	appCfg "github.com/charmbracelet/soft-serve/internal/config"
 	"github.com/charmbracelet/soft-serve/internal/tui"
-
 	"github.com/charmbracelet/wish"
 	bm "github.com/charmbracelet/wish/bubbletea"
 	gm "github.com/charmbracelet/wish/git"