a869926848e4a71e27a205bcb94f1a1ac6d7bbf8

Author
Ayman Bagabas <ayman.bagabas@gmail.com>
Committer
Ayman Bagabas <ayman.bagabas@gmail.com>
Date

Message

refactor: use gogs/git-module

* 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

Diff

This diff is truncated to protect this page.

   1diff --git a/go.mod b/go.mod
   2old mode 100644
   3new mode 100755
   4index cf1b09a59d8632c429f7c463253b5f089fdeebf6..707bafd46d611de2489bb2f0ca43985bcd68ccf4
   5--- a/go.mod
   6+++ b/go.mod
   7@@ -12,18 +12,20 @@ require (
   8 	github.com/charmbracelet/wish v0.2.1-0.20220208182816-534842b53d2a
   9 	github.com/dustin/go-humanize v1.0.0
  10 	github.com/gliderlabs/ssh v0.3.3
  11-	github.com/go-git/go-billy/v5 v5.3.1
  12-	github.com/go-git/go-git/v5 v5.4.2
  13+	github.com/go-git/go-billy/v5 v5.3.1 // indirect
  14+	github.com/go-git/go-git/v5 v5.4.3-0.20210630082519-b4368b2a2ca4 // indirect
  15 	github.com/matryer/is v1.2.0
  16 	github.com/muesli/reflow v0.3.0
  17 	github.com/muesli/termenv v0.11.0
  18-	github.com/sergi/go-diff v1.1.0 // indirect
  19+	github.com/sergi/go-diff v1.1.0
  20 	golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce
  21 	gopkg.in/yaml.v2 v2.4.0
  22 )
  23 
  24 require (
  25 	github.com/gobwas/glob v0.2.3
  26+	github.com/gogs/git-module v1.5.0
  27+	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da
  28 	github.com/muesli/mango v0.1.0
  29 	github.com/muesli/roff v0.1.0
  30 )
  31@@ -40,7 +42,6 @@ require (
  32 	github.com/dlclark/regexp2 v1.4.0 // indirect
  33 	github.com/emirpasic/gods v1.12.0 // indirect
  34 	github.com/go-git/gcfg v1.5.0 // indirect
  35-	github.com/google/go-cmp v0.5.6 // indirect
  36 	github.com/gorilla/css v1.0.0 // indirect
  37 	github.com/imdario/mergo v0.3.12 // indirect
  38 	github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
  39@@ -48,6 +49,7 @@ require (
  40 	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
  41 	github.com/mattn/go-isatty v0.0.14 // indirect
  42 	github.com/mattn/go-runewidth v0.0.13 // indirect
  43+	github.com/mcuadros/go-version v0.0.0-20190308113854-92cdf37c5b75 // indirect
  44 	github.com/microcosm-cc/bluemonday v1.0.17 // indirect
  45 	github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a // indirect
  46 	github.com/mitchellh/go-homedir v1.1.0 // indirect
  47@@ -58,10 +60,8 @@ require (
  48 	github.com/xanzy/ssh-agent v0.3.1 // indirect
  49 	github.com/yuin/goldmark v1.4.4 // indirect
  50 	github.com/yuin/goldmark-emoji v1.0.1 // indirect
  51-	golang.org/x/net v0.0.0-20220111093109-d55c255bac03 // indirect
  52+	golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd // indirect
  53 	golang.org/x/sys v0.0.0-20220111092808-5a964db01320 // indirect
  54 	golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
  55-	golang.org/x/text v0.3.7 // indirect
  56 	gopkg.in/warnings.v0 v0.1.2 // indirect
  57-	gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
  58 )
  59diff --git a/go.sum b/go.sum
  60index 28b4b112f5b43f968ef2cc8e825ee12b8cdc241b..9607f28d3fbd3526e1af37d2f18e85dfd95de631 100644
  61--- a/go.sum
  62+++ b/go.sum
  63@@ -59,13 +59,17 @@ github.com/go-git/go-billy/v5 v5.3.1 h1:CPiOUAzKtMRvolEKw+bG1PLRpT7D3LIs3/3ey4Ai
  64 github.com/go-git/go-billy/v5 v5.3.1/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
  65 github.com/go-git/go-git-fixtures/v4 v4.2.1 h1:n9gGL1Ct/yIw+nfsfr8s4+sbhT+Ncu2SubfXjIWgci8=
  66 github.com/go-git/go-git-fixtures/v4 v4.2.1/go.mod h1:K8zd3kDUAykwTdDCr+I0per6Y6vMiRR/nnVTBtavnB0=
  67-github.com/go-git/go-git/v5 v5.4.2 h1:BXyZu9t0VkbiHtqrsvdq39UDhGJTl1h55VW6CSC4aY4=
  68 github.com/go-git/go-git/v5 v5.4.2/go.mod h1:gQ1kArt6d+n+BGd+/B/I74HwRTLhth2+zti4ihgckDc=
  69+github.com/go-git/go-git/v5 v5.4.3-0.20210630082519-b4368b2a2ca4 h1:1RSUwVK7VjTeA82kcLIqz1EU70QRwFdZUlJW58gP4GY=
  70+github.com/go-git/go-git/v5 v5.4.3-0.20210630082519-b4368b2a2ca4/go.mod h1:gQ1kArt6d+n+BGd+/B/I74HwRTLhth2+zti4ihgckDc=
  71 github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
  72 github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
  73+github.com/gogs/git-module v1.5.0 h1:2aAO79c36R3L6TdKutbVJwr0YwSWfRbPNP456yxDXtk=
  74+github.com/gogs/git-module v1.5.0/go.mod h1:oN37FFStFjdnTJXsSbhIHKJXh2YeDsEcXPATVz/oeuQ=
  75+github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
  76+github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
  77+github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY=
  78 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
  79-github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
  80-github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
  81 github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
  82 github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
  83 github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU=
  84@@ -98,6 +102,8 @@ github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRC
  85 github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
  86 github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
  87 github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
  88+github.com/mcuadros/go-version v0.0.0-20190308113854-92cdf37c5b75 h1:Pijfgr7ZuvX7QIQiEwLdRVr3RoMG+i0SbBO1Qu+7yVk=
  89+github.com/mcuadros/go-version v0.0.0-20190308113854-92cdf37c5b75/go.mod h1:76rfSfYPWj01Z85hUf/ituArm797mNKcvINh1OlsZKo=
  90 github.com/microcosm-cc/bluemonday v1.0.17 h1:Z1a//hgsQ4yjC+8zEkV8IWySkXnsxmdSY642CTFQb5Y=
  91 github.com/microcosm-cc/bluemonday v1.0.17/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM=
  92 github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a h1:eU8j/ClY2Ty3qdHnn0TyW3ivFoPC/0F1gQZz8yTxbbE=
  93@@ -160,8 +166,10 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
  94 golang.org/x/net v0.0.0-20210326060303-6b1517762897/go.mod h1:uSPa2vr4CLtc/ILN5odXGNXS6mhrKVzTaCXzk9m6W3k=
  95 golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
  96 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
  97-golang.org/x/net v0.0.0-20220111093109-d55c255bac03 h1:0FB83qp0AzVJm+0wcIlauAjJ+tNdh7jLuacRYCIVv7s=
  98-golang.org/x/net v0.0.0-20220111093109-d55c255bac03/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
  99+golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd h1:O7DYs+zxREGLKzKoMQrtrEacpb0ZVXA5rIwylE2Xchk=
 100+golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
 101+golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e h1:vcxGaoTs7kV8m5Np9uUNQin4BrLOthgV7252N8V+FwY=
 102+golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 103 golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 104 golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 105 golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 106@@ -178,6 +186,7 @@ golang.org/x/sys v0.0.0-20210502180810-71e4cd670f79/go.mod h1:h1NjWce9XRLGQEsW7w
 107 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 108 golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 109 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 110+golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 111 golang.org/x/sys v0.0.0-20220111092808-5a964db01320 h1:0jf+tOCoZ3LyutmCOWpVni1chK4VfFLhRsDK7MhqGRY=
 112 golang.org/x/sys v0.0.0-20220111092808-5a964db01320/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 113 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 114@@ -189,7 +198,6 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 115 golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
 116 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 117 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 118-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 119 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 120 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 121 gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 122@@ -202,6 +210,5 @@ gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 123 gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 124 gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
 125 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 126+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
 127 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 128-gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
 129-gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 130diff --git a/internal/config/config.go b/internal/config/config.go
 131index 10c9ed3607f220c601d8f585c5df1aeff94733d2..b779a7344f0d3a883d5623b37da71f8dbb298b1b 100644
 132--- a/internal/config/config.go
 133+++ b/internal/config/config.go
 134@@ -3,6 +3,7 @@ package config
 135 import (
 136 	"bytes"
 137 	"log"
 138+	"path/filepath"
 139 	"strings"
 140 	"sync"
 141 	"text/template"
 142@@ -15,8 +16,7 @@ import (
 143 
 144 	"github.com/charmbracelet/soft-serve/config"
 145 	"github.com/charmbracelet/soft-serve/internal/git"
 146-	gg "github.com/go-git/go-git/v5"
 147-	"github.com/go-git/go-git/v5/plumbing/object"
 148+	gg "github.com/gogs/git-module"
 149 )
 150 
 151 // Config is the Soft Serve configuration.
 152@@ -30,7 +30,7 @@ type Config struct {
 153 	Repos        []Repo `yaml:"repos"`
 154 	Source       *git.RepoSource
 155 	Cfg          *config.Config
 156-	reloadMtx    sync.Mutex
 157+	mtx          sync.Mutex
 158 }
 159 
 160 // User contains user-level configuration for a repository.
 161@@ -106,13 +106,17 @@ func NewConfig(cfg *config.Config) (*Config, error) {
 162 	if err != nil {
 163 		return nil, err
 164 	}
 165+	err = c.Reload()
 166+	if err != nil {
 167+		return nil, err
 168+	}
 169 	return c, nil
 170 }
 171 
 172 // Reload reloads the configuration.
 173 func (cfg *Config) Reload() error {
 174-	cfg.reloadMtx.Lock()
 175-	defer cfg.reloadMtx.Unlock()
 176+	cfg.mtx.Lock()
 177+	defer cfg.mtx.Unlock()
 178 	err := cfg.Source.LoadRepos()
 179 	if err != nil {
 180 		return err
 181@@ -121,7 +125,7 @@ func (cfg *Config) Reload() error {
 182 	if err != nil {
 183 		return err
 184 	}
 185-	cs, err := cr.LatestFile("config.yaml")
 186+	cs, _, err := cr.LatestFile("config.yaml")
 187 	if err != nil {
 188 		return err
 189 	}
 190@@ -147,18 +151,8 @@ func (cfg *Config) Reload() error {
 191 			pat = rp
 192 		}
 193 		rm := ""
 194-		f, err := r.FindLatestFile(pat)
 195-		if err != nil && err != object.ErrFileNotFound {
 196-			return err
 197-		}
 198-		if err == nil {
 199-			fc, err := f.Contents()
 200-			if err != nil {
 201-				return err
 202-			}
 203-			rm = fc
 204-			r.ReadmePath = f.Name
 205-		}
 206+		fc, fp, _ := r.LatestFile(pat)
 207+		rm = fc
 208 		if name == "config" {
 209 			md, err := templatize(rm, cfg)
 210 			if err != nil {
 211@@ -166,7 +160,7 @@ func (cfg *Config) Reload() error {
 212 			}
 213 			rm = md
 214 		}
 215-		r.Readme = rm
 216+		r.SetReadme(rm, fp)
 217 	}
 218 	return nil
 219 }
 220@@ -187,21 +181,15 @@ func createFile(path string, content string) error {
 221 func (cfg *Config) createDefaultConfigRepo(yaml string) error {
 222 	cn := "config"
 223 	rs := cfg.Source
 224-	err := rs.LoadRepos()
 225-	if err != nil {
 226-		return err
 227-	}
 228-	_, err = rs.GetRepo(cn)
 229-	if err == git.ErrMissingRepo {
 230-		cr, err := rs.InitRepo(cn, true)
 231-		if err != nil {
 232-			return err
 233-		}
 234diff --git a/internal/git/git.go b/internal/git/git.go
 235index 1762d57d7dec95520b44d1871c4c611c7dd56173..87c84ac9f91d39ec939ae62c248a2e7ac2b79bc1 100644
 236--- a/internal/git/git.go
 237+++ b/internal/git/git.go
 238@@ -1,24 +1,15 @@
 239 package git
 240 
 241 import (
 242-	"context"
 243 	"errors"
 244 	"log"
 245 	"os"
 246-	"os/exec"
 247 	"path/filepath"
 248-	"sort"
 249 	"sync"
 250 
 251-	gitypes "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
 252-	"github.com/go-git/go-billy/v5/memfs"
 253-	"github.com/go-git/go-git/v5"
 254-	"github.com/go-git/go-git/v5/plumbing"
 255-	"github.com/go-git/go-git/v5/plumbing/object"
 256-	"github.com/go-git/go-git/v5/plumbing/storer"
 257-	"github.com/go-git/go-git/v5/plumbing/transport"
 258-	"github.com/go-git/go-git/v5/storage/memory"
 259+	"github.com/charmbracelet/soft-serve/pkg/git"
 260 	"github.com/gobwas/glob"
 261+	"github.com/golang/groupcache/lru"
 262 )
 263 
 264 // ErrMissingRepo indicates that the requested repository could not be found.
 265@@ -28,195 +19,126 @@ var ErrMissingRepo = errors.New("missing repo")
 266 type Repo struct {
 267 	path       string
 268 	repository *git.Repository
 269-	Readme     string
 270-	ReadmePath string
 271-	refCommits map[plumbing.Hash]gitypes.Commits
 272-	head       *plumbing.Reference
 273-	refs       []*plumbing.Reference
 274-	trees      map[plumbing.Hash]*object.Tree
 275-	commits    map[plumbing.Hash]*object.Commit
 276-	patch      map[plumbing.Hash]*object.Patch
 277+	readme     string
 278+	readmePath string
 279+	head       *git.Reference
 280+	refs       []*git.Reference
 281+	patchCache *lru.Cache
 282 }
 283 
 284-// GetName returns the name of the repository.
 285-func (r *Repo) Name() string {
 286-	return filepath.Base(r.path)
 287-}
 288-
 289-// GetHEAD returns the reference for a repository.
 290-func (r *Repo) GetHEAD() *plumbing.Reference {
 291-	return r.head
 292-}
 293-
 294-// SetHEAD sets the repository head reference.
 295-func (r *Repo) SetHEAD(ref *plumbing.Reference) error {
 296-	r.head = ref
 297-	return nil
 298-}
 299-
 300-// GetReferences returns the references for a repository.
 301-func (r *Repo) GetReferences() []*plumbing.Reference {
 302-	return r.refs
 303-}
 304-
 305-// GetRepository returns the underlying go-git repository object.
 306-func (r *Repo) Repository() *git.Repository {
 307-	return r.repository
 308-}
 309-
 310-// Tree returns the git tree for a given path.
 311-func (r *Repo) Tree(ref *plumbing.Reference, path string) (*object.Tree, error) {
 312-	path = filepath.Clean(path)
 313-	hash, err := r.targetHash(ref)
 314+// Open opens a Git repository.
 315+func (rs *RepoSource) Open(path string) (*Repo, error) {
 316+	rg, err := git.Open(path)
 317 	if err != nil {
 318 		return nil, err
 319 	}
 320-	c, err := r.commitForHash(hash)
 321+	r := &Repo{
 322+		path:       path,
 323+		repository: rg,
 324+		patchCache: lru.New(1000),
 325+	}
 326+	_, err = r.HEAD()
 327 	if err != nil {
 328 		return nil, err
 329 	}
 330-	t, err := r.treeForHash(c.TreeHash)
 331+	_, err = r.References()
 332 	if err != nil {
 333 		return nil, err
 334 	}
 335-	if path == "." {
 336-		return t, nil
 337-	}
 338diff --git a/internal/tui/bubble.go b/internal/tui/bubble.go
 339index 321b0fe86a5c3879ff253aecd3fec4fd3a5e4f8e..bacc3de883538dd8cace59bbd1cce185b0b783bf 100644
 340--- a/internal/tui/bubble.go
 341+++ b/internal/tui/bubble.go
 342@@ -7,10 +7,10 @@ import (
 343 	tea "github.com/charmbracelet/bubbletea"
 344 	"github.com/charmbracelet/lipgloss"
 345 	"github.com/charmbracelet/soft-serve/internal/config"
 346-	gittypes "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
 347 	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/repo"
 348 	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/selection"
 349 	"github.com/charmbracelet/soft-serve/internal/tui/style"
 350+	"github.com/charmbracelet/soft-serve/pkg/tui/common"
 351 	"github.com/gliderlabs/ssh"
 352 )
 353 
 354@@ -157,19 +157,19 @@ func (b Bubble) headerView() string {
 355 
 356 func (b Bubble) footerView() string {
 357 	w := &strings.Builder{}
 358-	var h []gittypes.HelpEntry
 359+	var h []common.HelpEntry
 360 	if b.state != errorState {
 361-		h = []gittypes.HelpEntry{
 362+		h = []common.HelpEntry{
 363 			{Key: "tab", Value: "section"},
 364 		}
 365-		if box, ok := b.boxes[b.activeBox].(gittypes.BubbleHelper); ok {
 366+		if box, ok := b.boxes[b.activeBox].(common.BubbleHelper); ok {
 367 			help := box.Help()
 368 			for _, he := range help {
 369 				h = append(h, he)
 370 			}
 371 		}
 372 	}
 373-	h = append(h, gittypes.HelpEntry{Key: "q", Value: "quit"})
 374+	h = append(h, common.HelpEntry{Key: "q", Value: "quit"})
 375 	for i, v := range h {
 376 		fmt.Fprint(w, helpEntryRender(v, b.styles))
 377 		if i != len(h)-1 {
 378@@ -178,13 +178,14 @@ func (b Bubble) footerView() string {
 379 	}
 380 	branch := ""
 381 	if b.state == loadedState {
 382-		branch = b.boxes[1].(*repo.Bubble).Reference().Short()
 383+		ref := b.boxes[1].(*repo.Bubble).Reference()
 384+		branch = ref.Name().Short()
 385 	}
 386 	help := w.String()
 387 	branchMaxWidth := b.width - // bubble width
 388 		lipgloss.Width(help) - // help width
 389 		b.styles.App.GetHorizontalFrameSize() // App paddings
 390-	branch = b.styles.Branch.Render(gittypes.TruncateString(branch, branchMaxWidth-1, "…"))
 391+	branch = b.styles.Branch.Render(common.TruncateString(branch, branchMaxWidth-1, "…"))
 392 	gap := lipgloss.NewStyle().
 393 		Width(b.width -
 394 			lipgloss.Width(help) -
 395@@ -228,6 +229,6 @@ func (b Bubble) View() string {
 396 	return b.styles.App.Render(s.String())
 397 }
 398 
 399-func helpEntryRender(h gittypes.HelpEntry, s *style.Styles) string {
 400+func helpEntryRender(h common.HelpEntry, s *style.Styles) string {
 401 	return fmt.Sprintf("%s %s", s.HelpKey.Render(h.Key), s.HelpValue.Render(h.Value))
 402 }
 403diff --git a/internal/tui/bubbles/git/types/git.go b/internal/tui/bubbles/git/types/git.go
 404deleted file mode 100644
 405index 90e5436e98a21a06413dd177dd5f085c59eac91b..0000000000000000000000000000000000000000
 406--- a/internal/tui/bubbles/git/types/git.go
 407+++ /dev/null
 408@@ -1,30 +0,0 @@
 409-package types
 410-
 411-import (
 412-	"context"
 413-
 414-	"github.com/go-git/go-git/v5"
 415-	"github.com/go-git/go-git/v5/plumbing"
 416-	"github.com/go-git/go-git/v5/plumbing/object"
 417-)
 418-
 419-type Repo interface {
 420-	Name() string
 421-	GetHEAD() *plumbing.Reference
 422-	SetHEAD(*plumbing.Reference) error
 423-	GetReferences() []*plumbing.Reference
 424-	GetReadme() string
 425-	GetReadmePath() string
 426-	GetCommits(*plumbing.Reference) (Commits, error)
 427-	Repository() *git.Repository
 428-	Tree(*plumbing.Reference, string) (*object.Tree, error)
 429-	PatchCtx(context.Context, *object.Commit) (*object.Patch, error)
 430-}
 431-
 432-type Commits []*object.Commit
 433-
 434-func (cl Commits) Len() int      { return len(cl) }
 435-func (cl Commits) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }
 436-func (cl Commits) Less(i, j int) bool {
 437-	return cl[i].Author.When.After(cl[j].Author.When)
 438-}
 439diff --git a/internal/tui/bubbles/repo/bubble.go b/internal/tui/bubbles/repo/bubble.go
 440index 5ba6f8fcacea26ea8af02c61007f1911802ab550..b8cdd0e2d7e689c671fa4d1f8576faf65cb8d14f 100644
 441--- a/internal/tui/bubbles/repo/bubble.go
 442+++ b/internal/tui/bubbles/repo/bubble.go
 443@@ -6,10 +6,10 @@ import (
 444 
 445 	tea "github.com/charmbracelet/bubbletea"
 446 	"github.com/charmbracelet/lipgloss"
 447-	gitui "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git"
 448-	gittypes "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
 449 	"github.com/charmbracelet/soft-serve/internal/tui/style"
 450-	"github.com/go-git/go-git/v5/plumbing"
 451+	"github.com/charmbracelet/soft-serve/pkg/git"
 452+	gitui "github.com/charmbracelet/soft-serve/pkg/tui"
 453+	"github.com/charmbracelet/soft-serve/pkg/tui/common"
 454 	"github.com/muesli/reflow/truncate"
 455 	"github.com/muesli/reflow/wrap"
 456 )
 457@@ -22,7 +22,7 @@ type Bubble struct {
 458 	name         string
 459 	host         string
 460 	port         int
 461-	repo         gittypes.Repo
 462+	repo         common.GitRepo
 463 	styles       *style.Styles
 464 	width        int
 465 	widthMargin  int
 466@@ -33,7 +33,7 @@ type Bubble struct {
 467 	Active bool
 468 }
 469 
 470-func NewBubble(repo gittypes.Repo, host string, port int, styles *style.Styles, width, wm, height, hm int) *Bubble {
 471+func NewBubble(repo common.GitRepo, host string, port int, styles *style.Styles, width, wm, height, hm int) *Bubble {
 472 	b := &Bubble{
 473 		name:         repo.Name(),
 474 		host:         host,
 475@@ -56,6 +56,9 @@ func (b *Bubble) Init() tea.Cmd {
 476 func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 477 	switch msg := msg.(type) {
 478 	case tea.WindowSizeMsg:
 479+		if msg.Width == b.width && msg.Height == b.height {
 480+			return b, nil
 481+		}
 482 		b.width = msg.Width
 483 		b.height = msg.Height
 484 	}
 485@@ -64,7 +67,7 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 486 	return b, cmd
 487 }
 488 
 489-func (b *Bubble) Help() []gittypes.HelpEntry {
 490+func (b *Bubble) Help() []common.HelpEntry {
 491 	return b.box.Help()
 492 }
 493 
 494@@ -95,7 +98,7 @@ func (b Bubble) headerView() string {
 495 	note = b.styles.RepoNote.Copy().Width(noteWidth).Render(note)
 496 
 497 	// Render borders on name and command
 498-	height := gittypes.Max(lipgloss.Height(title), lipgloss.Height(note))
 499+	height := common.Max(lipgloss.Height(title), lipgloss.Height(note))
 500 	titleBoxStyle := b.styles.RepoTitleBox.Copy().Height(height)
 501 	noteBoxStyle := b.styles.RepoNoteBox.Copy().Height(height)
 502 	if b.Active {
 503@@ -121,7 +124,7 @@ func (b *Bubble) View() string {
 504 	return header + body
 505 }
 506 
 507-func (b *Bubble) Reference() plumbing.ReferenceName {
 508+func (b *Bubble) Reference() *git.Reference {
 509 	return b.box.Reference()
 510 }
 511 
 512diff --git a/internal/tui/bubbles/selection/bubble.go b/internal/tui/bubbles/selection/bubble.go
 513index eb84b5d6817ac5174b92a1c187ca3eae14546d94..866de2db81e189426cfad9bc9f1f89f02140c89c 100644
 514--- a/internal/tui/bubbles/selection/bubble.go
 515+++ b/internal/tui/bubbles/selection/bubble.go
 516@@ -5,8 +5,8 @@ import (
 517 
 518 	tea "github.com/charmbracelet/bubbletea"
 519 	"github.com/charmbracelet/lipgloss"
 520-	gittypes "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
 521 	"github.com/charmbracelet/soft-serve/internal/tui/style"
 522+	"github.com/charmbracelet/soft-serve/pkg/tui/common"
 523 	"github.com/muesli/reflow/truncate"
 524 )
 525 
 526@@ -80,8 +80,8 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 527 	return b, tea.Batch(cmds...)
 528 }
 529 
 530-func (b *Bubble) Help() []gittypes.HelpEntry {
 531-	return []gittypes.HelpEntry{
 532+func (b *Bubble) Help() []common.HelpEntry {
 533+	return []common.HelpEntry{
 534 		{Key: "↑/↓", Value: "navigate"},
 535 	}
 536 }
 537diff --git a/internal/tui/commands.go b/internal/tui/commands.go
 538index 2008ae811db8f59d58152979d133c03aea521fd4..e95bfc3dcbd6eba275a4e26c91e11fdef1779b3c 100644
 539--- a/internal/tui/commands.go
 540+++ b/internal/tui/commands.go
 541@@ -5,9 +5,9 @@ import (
 542 
 543 	tea "github.com/charmbracelet/bubbletea"
 544 	"github.com/charmbracelet/lipgloss"
 545-	gitypes "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
 546 	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/repo"
 547 	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/selection"
 548+	"github.com/charmbracelet/soft-serve/pkg/tui/common"
 549 	gm "github.com/charmbracelet/wish/git"
 550 )
 551 
 552@@ -110,7 +110,7 @@ func (b *Bubble) newMenuEntry(name string, rn string) (MenuEntry, error) {
 553 	initCmd := rb.Init()
 554 	msg := initCmd()
 555 	switch msg := msg.(type) {
 556-	case gitypes.ErrMsg:
 557+	case common.ErrMsg:
 558 		return me, fmt.Errorf("missing %s: %s", me.Repo, msg.Err.Error())
 559 	}
 560 	me.bubble = rb
 561diff --git a/pkg/git/commit.go b/pkg/git/commit.go
 562new file mode 100644
 563index 0000000000000000000000000000000000000000..6a7d2778064dd35923e92788eab19f75048a201c
 564--- /dev/null
 565+++ b/pkg/git/commit.go
 566@@ -0,0 +1,42 @@
 567+package git
 568+
 569+import (
 570+	"github.com/gogs/git-module"
 571+)
 572+
 573+var (
 574+	ZeroHash Hash = git.EmptyID
 575+)
 576+
 577+// Hash represents a git hash.
 578+type Hash string
 579+
 580+// String returns the string representation of a hash as a string.
 581+func (h Hash) String() string {
 582+	return string(h)
 583+}
 584+
 585+// SHA1 represents the hash as a SHA1.
 586+func (h Hash) SHA1() *git.SHA1 {
 587+	return git.MustIDFromString(h.String())
 588+}
 589+
 590+// Commit is a wrapper around git.Commit with helper methods.
 591+type Commit struct {
 592+	*git.Commit
 593+	Hash Hash
 594+}
 595+
 596+// Commits is a list of commits.
 597+type Commits []*Commit
 598+
 599+// Len implements sort.Interface.
 600+func (cl Commits) Len() int { return len(cl) }
 601+
 602+// Swap implements sort.Interface.
 603+func (cl Commits) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }
 604+
 605+// Less implements sort.Interface.
 606+func (cl Commits) Less(i, j int) bool {
 607+	return cl[i].Author.When.After(cl[j].Author.When)
 608+}
 609diff --git a/pkg/git/errors.go b/pkg/git/errors.go
 610new file mode 100644
 611index 0000000000000000000000000000000000000000..9f8b705d77c864e5cdae5adc760c12ce1955ccd9
 612--- /dev/null
 613+++ b/pkg/git/errors.go
 614@@ -0,0 +1,9 @@
 615+package git
 616+
 617+import "errors"
 618+
 619+var (
 620+	ErrFileNotFound      = errors.New("file not found")
 621+	ErrDirectoryNotFound = errors.New("directory not found")
 622+	ErrReferenceNotFound = errors.New("reference not found")
 623+)
 624diff --git a/pkg/git/patch.go b/pkg/git/patch.go
 625new file mode 100644
 626index 0000000000000000000000000000000000000000..f2f3e1cc4b3008a7402e5f6759fe5c88747b8311
 627--- /dev/null
 628+++ b/pkg/git/patch.go
 629@@ -0,0 +1,329 @@
 630+package git
 631+
 632+import (
 633+	"bytes"
 634+	"fmt"
 635+	"math"
 636+	"strings"
 637+	"sync"
 638+
 639+	"github.com/dustin/go-humanize/english"
 640+	"github.com/gogs/git-module"
 641+	"github.com/sergi/go-diff/diffmatchpatch"
 642+)
 643+
 644+// DiffSection is a wrapper to git.DiffSection with helper methods.
 645+type DiffSection struct {
 646+	*git.DiffSection
 647+
 648+	initOnce sync.Once
 649+	dmp      *diffmatchpatch.DiffMatchPatch
 650+}
 651+
 652+// diffFor computes inline diff for the given line.
 653+func (s *DiffSection) diffFor(line *git.DiffLine) string {
 654+	fallback := line.Content
 655+
 656+	// Find equivalent diff line, ignore when not found.
 657+	var diff1, diff2 string
 658+	switch line.Type {
 659+	case git.DiffLineAdd:
 660+		compareLine := s.Line(git.DiffLineDelete, line.RightLine)
 661+		if compareLine == nil {
 662+			return fallback
 663+		}
 664+
 665+		diff1 = compareLine.Content
 666+		diff2 = line.Content
 667+
 668+	case git.DiffLineDelete:
 669+		compareLine := s.Line(git.DiffLineAdd, line.LeftLine)
 670+		if compareLine == nil {
 671+			return fallback
 672+		}
 673+
 674+		diff1 = line.Content
 675+		diff2 = compareLine.Content
 676+
 677+	default:
 678+		return fallback
 679+	}
 680+
 681+	s.initOnce.Do(func() {
 682+		s.dmp = diffmatchpatch.New()
 683+		s.dmp.DiffEditCost = 100
 684+	})
 685+
 686+	diffs := s.dmp.DiffMain(diff1[1:], diff2[1:], true)
 687+	diffs = s.dmp.DiffCleanupEfficiency(diffs)
 688+
 689+	return diffsToString(diffs, line.Type)
 690+}
 691+
 692+func diffsToString(diffs []diffmatchpatch.Diff, lineType git.DiffLineType) string {
 693+	buf := bytes.NewBuffer(nil)
 694+
 695+	// Reproduce signs which are cutted for inline diff before.
 696+	switch lineType {
 697+	case git.DiffLineAdd:
 698+		buf.WriteByte('+')
 699+	case git.DiffLineDelete:
 700+		buf.WriteByte('-')
 701+	}
 702+
 703+	for i := range diffs {
 704+		switch {
 705+		case diffs[i].Type == diffmatchpatch.DiffInsert && lineType == git.DiffLineAdd:
 706+			buf.WriteString(diffs[i].Text)
 707+		case diffs[i].Type == diffmatchpatch.DiffDelete && lineType == git.DiffLineDelete:
 708+			buf.WriteString(diffs[i].Text)
 709+		case diffs[i].Type == diffmatchpatch.DiffEqual:
 710+			buf.WriteString(diffs[i].Text)
 711+		}
 712+	}
 713+
 714+	return string(buf.Bytes())
 715+}
 716+
 717+// DiffFile is a wrapper to git.DiffFile with helper methods.
 718+type DiffFile struct {
 719+	*git.DiffFile
 720+	Sections []*DiffSection
 721+}
 722+
 723+type DiffFileChange struct {
 724+	hash string
 725+	name string
 726+	mode git.EntryMode
 727+}
 728+
 729diff --git a/pkg/git/reference.go b/pkg/git/reference.go
 730new file mode 100644
 731index 0000000000000000000000000000000000000000..ec01a53fdc8c70f52283107c33029ae5fa57577d
 732--- /dev/null
 733+++ b/pkg/git/reference.go
 734@@ -0,0 +1,76 @@
 735+package git
 736+
 737+import (
 738+	"strings"
 739+
 740+	"github.com/gogs/git-module"
 741+)
 742+
 743+const (
 744+	// HEAD represents the name of the HEAD reference.
 745+	HEAD = "HEAD"
 746+	// RefsHeads represents the prefix for branch references.
 747+	RefsHeads = git.RefsHeads
 748+	// RefsTags represents the prefix for tag references.
 749+	RefsTags = git.RefsTags
 750+)
 751+
 752+// Reference is a wrapper around git.Reference with helper methods.
 753+type Reference struct {
 754+	*git.Reference
 755+	Hash Hash
 756+	path string // repo path
 757+}
 758+
 759+// ReferenceName is a Refspec wrapper.
 760+type ReferenceName string
 761+
 762+// NewReference creates a new reference.
 763+func NewReference(rp, refspec string) *Reference {
 764+	return &Reference{
 765+		Reference: &git.Reference{
 766+			Refspec: refspec,
 767+		},
 768+		path: rp,
 769+	}
 770+}
 771+
 772+// String returns the reference name i.e. refs/heads/master.
 773+func (r ReferenceName) String() string {
 774+	return string(r)
 775+}
 776+
 777+// Short returns the short name of the reference i.e. master.
 778+func (r ReferenceName) Short() string {
 779+	s := strings.Split(r.String(), "/")
 780+	if len(s) > 0 {
 781+		return s[len(s)-1]
 782+	}
 783+	return r.String()
 784+}
 785+
 786+// Name returns the reference name i.e. refs/heads/master.
 787+func (r *Reference) Name() ReferenceName {
 788+	return ReferenceName(r.Refspec)
 789+}
 790+
 791+// IsBranch returns true if the reference is a branch.
 792+func (r *Reference) IsBranch() bool {
 793+	return strings.HasPrefix(r.Refspec, git.RefsHeads)
 794+}
 795+
 796+// IsTag returns true if the reference is a tag.
 797+func (r *Reference) IsTag() bool {
 798+	return strings.HasPrefix(r.Refspec, git.RefsTags)
 799+}
 800+
 801+// TargetHash returns the hash of the reference target.
 802+func (r *Reference) TargetHash() Hash {
 803+	if r.IsTag() {
 804+		id, err := git.ShowRefVerify(r.path, r.Refspec)
 805+		if err == nil {
 806+			return Hash(id)
 807+		}
 808+	}
 809+	return r.Hash
 810+}
 811diff --git a/pkg/git/repo.go b/pkg/git/repo.go
 812new file mode 100644
 813index 0000000000000000000000000000000000000000..dc749c797dc1383e7851d46257b1184343dc1646
 814--- /dev/null
 815+++ b/pkg/git/repo.go
 816@@ -0,0 +1,185 @@
 817+package git
 818+
 819+import (
 820+	"path/filepath"
 821+
 822+	"github.com/gogs/git-module"
 823+)
 824+
 825+var (
 826+	DiffMaxFiles     = 1000
 827+	DiffMaxFileLines = 1000
 828+	DiffMaxLineChars = 1000
 829+)
 830+
 831+// Repository is a wrapper around git.Repository with helper methods.
 832+type Repository struct {
 833+	*git.Repository
 834+	Path string
 835+}
 836+
 837+// Clone clones a repository.
 838+func Clone(src, dst string, opts ...git.CloneOptions) error {
 839+	return git.Clone(src, dst, opts...)
 840+}
 841+
 842+// Init initializes and opens a new git repository.
 843+func Init(path string, bare bool) (*Repository, error) {
 844+	err := git.Init(path, git.InitOptions{Bare: bare})
 845+	if err != nil {
 846+		return nil, err
 847+	}
 848+	return Open(path)
 849+}
 850+
 851+// Open opens a git repository at the given path.
 852+func Open(path string) (*Repository, error) {
 853+	repo, err := git.Open(path)
 854+	if err != nil {
 855+		return nil, err
 856+	}
 857+	return &Repository{
 858+		Repository: repo,
 859+		Path:       path,
 860+	}, nil
 861+}
 862+
 863+// Name returns the name of the repository.
 864+func (r *Repository) Name() string {
 865+	return filepath.Base(r.Path)
 866+}
 867+
 868+// HEAD returns the HEAD reference for a repository.
 869+func (r *Repository) HEAD() (*Reference, error) {
 870+	rn, err := r.SymbolicRef()
 871+	if err != nil {
 872+		return nil, err
 873+	}
 874+	hash, err := r.ShowRefVerify(rn)
 875+	if err != nil {
 876+		return nil, err
 877+	}
 878+	return &Reference{
 879+		Reference: &git.Reference{
 880+			ID:      hash,
 881+			Refspec: rn,
 882+		},
 883+		Hash: Hash(hash),
 884+		path: r.Path,
 885+	}, nil
 886+}
 887+
 888+// References returns the references for a repository.
 889+func (r *Repository) References() ([]*Reference, error) {
 890+	refs, err := r.ShowRef()
 891+	if err != nil {
 892+		return nil, err
 893+	}
 894+	rrefs := make([]*Reference, 0, len(refs))
 895+	for _, ref := range refs {
 896+		rrefs = append(rrefs, &Reference{
 897+			Reference: ref,
 898+			Hash:      Hash(ref.ID),
 899+			path:      r.Path,
 900+		})
 901+	}
 902+	return rrefs, nil
 903+}
 904+
 905+// Tree returns the tree for the given reference.
 906+func (r *Repository) Tree(ref *Reference) (*Tree, error) {
 907+	if ref == nil {
 908+		rref, err := r.HEAD()
 909+		if err != nil {
 910+			return nil, err
 911+		}
 912+		ref = rref
 913+	}
 914+	tree, err := r.LsTree(ref.Hash.String())
 915+	if err != nil {
 916diff --git a/pkg/git/tree.go b/pkg/git/tree.go
 917new file mode 100644
 918index 0000000000000000000000000000000000000000..517c9effb9ea6df86b1c80b7003837a52a190365
 919--- /dev/null
 920+++ b/pkg/git/tree.go
 921@@ -0,0 +1,191 @@
 922+package git
 923+
 924+import (
 925+	"bufio"
 926+	"bytes"
 927+	"io"
 928+	"io/fs"
 929+	"path/filepath"
 930+	"sort"
 931+
 932+	"github.com/gogs/git-module"
 933+)
 934+
 935+// Tree is a wrapper around git.Tree with helper methods.
 936+type Tree struct {
 937+	*git.Tree
 938+	Path string
 939+}
 940+
 941+// TreeEntry is a wrapper around git.TreeEntry with helper methods.
 942+type TreeEntry struct {
 943+	*git.TreeEntry
 944+	// path is the full path of the file
 945+	path string
 946+}
 947+
 948+// Entries is a wrapper around git.Entries
 949+type Entries []*TreeEntry
 950+
 951+var sorters = []func(t1, t2 *TreeEntry) bool{
 952+	func(t1, t2 *TreeEntry) bool {
 953+		return (t1.IsTree() || t1.IsCommit()) && !t2.IsTree() && !t2.IsCommit()
 954+	},
 955+	func(t1, t2 *TreeEntry) bool {
 956+		return t1.Name() < t2.Name()
 957+	},
 958+}
 959+
 960+// Len implements sort.Interface.
 961+func (es Entries) Len() int { return len(es) }
 962+
 963+// Swap implements sort.Interface.
 964+func (es Entries) Swap(i, j int) { es[i], es[j] = es[j], es[i] }
 965+
 966+// Less implements sort.Interface.
 967+func (es Entries) Less(i, j int) bool {
 968+	t1, t2 := es[i], es[j]
 969+	var k int
 970+	for k = 0; k < len(sorters)-1; k++ {
 971+		sorter := sorters[k]
 972+		switch {
 973+		case sorter(t1, t2):
 974+			return true
 975+		case sorter(t2, t1):
 976+			return false
 977+		}
 978+	}
 979+	return sorters[k](t1, t2)
 980+}
 981+
 982+// Sort sorts the entries in the tree.
 983+func (es Entries) Sort() {
 984+	sort.Sort(es)
 985+}
 986+
 987+// File is a wrapper around git.Blob with helper methods.
 988+type File struct {
 989+	*git.Blob
 990+	Entry *TreeEntry
 991+}
 992+
 993+// Name returns the name of the file.
 994+func (f *File) Name() string {
 995+	return f.Entry.Name()
 996+}
 997+
 998+// Path returns the full path of the file.
 999+func (f *File) Path() string {
1000+	return f.Entry.path
1001+}
1002+
1003+// SubTree returns the sub-tree at the given path.
1004+func (t *Tree) SubTree(path string) (*Tree, error) {
1005+	tree, err := t.Subtree(path)
1006+	if err != nil {
1007+		return nil, err
1008+	}
1009+	return &Tree{
1010+		Tree: tree,
1011+		Path: path,
1012+	}, nil
1013+}
1014+
1015+// Entries returns the entries in the tree.
1016+func (t *Tree) Entries() (Entries, error) {
1017+	entries, err := t.Tree.Entries()
1018+	if err != nil {
1019+		return nil, err
1020+	}
1021diff --git a/internal/tui/bubbles/git/about/bubble.go b/pkg/tui/about/bubble.go
1022rename from internal/tui/bubbles/git/about/bubble.go
1023rename to pkg/tui/about/bubble.go
1024index 2c957397d0bde01e0911261bb5dd5d4cfefc1efc..bc819af6ab6d267e9f4e6afde4f23f12e25d7018 100644
1025--- a/internal/tui/bubbles/git/about/bubble.go
1026+++ b/pkg/tui/about/bubble.go
1027@@ -3,26 +3,26 @@ package about
1028 import (
1029 	"github.com/charmbracelet/bubbles/viewport"
1030 	tea "github.com/charmbracelet/bubbletea"
1031-	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/refs"
1032-	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
1033-	vp "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/viewport"
1034 	"github.com/charmbracelet/soft-serve/internal/tui/style"
1035-	"github.com/go-git/go-git/v5/plumbing"
1036+	"github.com/charmbracelet/soft-serve/pkg/git"
1037+	"github.com/charmbracelet/soft-serve/pkg/tui/common"
1038+	"github.com/charmbracelet/soft-serve/pkg/tui/refs"
1039+	vp "github.com/charmbracelet/soft-serve/pkg/tui/viewport"
1040 	"github.com/muesli/reflow/wrap"
1041 )
1042 
1043 type Bubble struct {
1044 	readmeViewport *vp.ViewportBubble
1045-	repo           types.Repo
1046+	repo           common.GitRepo
1047 	styles         *style.Styles
1048 	height         int
1049 	heightMargin   int
1050 	width          int
1051 	widthMargin    int
1052-	ref            *plumbing.Reference
1053+	ref            *git.Reference
1054 }
1055 
1056-func NewBubble(repo types.Repo, styles *style.Styles, width, wm, height, hm int) *Bubble {
1057+func NewBubble(repo common.GitRepo, styles *style.Styles, width, wm, height, hm int) *Bubble {
1058 	b := &Bubble{
1059 		readmeViewport: &vp.ViewportBubble{
1060 			Viewport: &viewport.Model{},
1061@@ -31,13 +31,12 @@ func NewBubble(repo types.Repo, styles *style.Styles, width, wm, height, hm int)
1062 		styles:       styles,
1063 		widthMargin:  wm,
1064 		heightMargin: hm,
1065-		ref:          repo.GetHEAD(),
1066 	}
1067 	b.SetSize(width, height)
1068 	return b
1069 }
1070 func (b *Bubble) Init() tea.Cmd {
1071-	return b.setupCmd
1072+	return b.reset
1073 }
1074 
1075 func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
1076@@ -56,11 +55,11 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
1077 	case tea.KeyMsg:
1078 		switch msg.String() {
1079 		case "R":
1080-			b.GotoTop()
1081+			return b, b.reset
1082 		}
1083 	case refs.RefMsg:
1084 		b.ref = msg
1085-		return b, b.setupCmd
1086+		return b, b.reset
1087 	}
1088 	rv, cmd := b.readmeViewport.Update(msg)
1089 	b.readmeViewport = rv.(*vp.ViewportBubble)
1090@@ -83,17 +82,17 @@ func (b *Bubble) View() string {
1091 	return b.readmeViewport.View()
1092 }
1093 
1094-func (b *Bubble) Help() []types.HelpEntry {
1095+func (b *Bubble) Help() []common.HelpEntry {
1096 	return nil
1097 }
1098 
1099 func (b *Bubble) glamourize() (string, error) {
1100 	w := b.width - b.widthMargin - b.styles.RepoBody.GetHorizontalFrameSize()
1101-	rm := b.repo.GetReadme()
1102+	rm, rp := b.repo.Readme()
1103 	if rm == "" {
1104 		return b.styles.AboutNoReadme.Render("No readme found."), nil
1105 	}
1106-	f, err := types.RenderFile(b.repo.GetReadmePath(), rm, w)
1107+	f, err := common.RenderFile(rp, rm, w)
1108 	if err != nil {
1109 		return "", err
1110 	}
1111@@ -107,11 +106,16 @@ func (b *Bubble) glamourize() (string, error) {
1112 	return wrap.String(f, w), nil
1113 }
1114 
1115-func (b *Bubble) setupCmd() tea.Msg {
1116+func (b *Bubble) reset() tea.Msg {
1117 	md, err := b.glamourize()
1118 	if err != nil {
1119-		return types.ErrMsg{Err: err}
1120+		return common.ErrMsg{Err: err}
1121 	}
1122+	head, err := b.repo.HEAD()
1123+	if err != nil {
1124+		return common.ErrMsg{Err: err}
1125+	}
1126+	b.ref = head
1127diff --git a/internal/tui/bubbles/git/bubble.go b/pkg/tui/bubble.go
1128rename from internal/tui/bubbles/git/bubble.go
1129rename to pkg/tui/bubble.go
1130index 6b5ea700c42478615b56e36c06f7a554e3cc4e0f..40a3ace61c52730d7fa208dbf2dbcd5c47420a49 100644
1131--- a/internal/tui/bubbles/git/bubble.go
1132+++ b/pkg/tui/bubble.go
1133@@ -1,15 +1,15 @@
1134-package git
1135+package tui
1136 
1137 import (
1138 	tea "github.com/charmbracelet/bubbletea"
1139 	"github.com/charmbracelet/lipgloss"
1140-	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/about"
1141-	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/log"
1142-	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/refs"
1143-	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/tree"
1144-	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
1145 	"github.com/charmbracelet/soft-serve/internal/tui/style"
1146-	"github.com/go-git/go-git/v5/plumbing"
1147+	"github.com/charmbracelet/soft-serve/pkg/git"
1148+	"github.com/charmbracelet/soft-serve/pkg/tui/about"
1149+	"github.com/charmbracelet/soft-serve/pkg/tui/common"
1150+	"github.com/charmbracelet/soft-serve/pkg/tui/log"
1151+	"github.com/charmbracelet/soft-serve/pkg/tui/refs"
1152+	"github.com/charmbracelet/soft-serve/pkg/tui/tree"
1153 )
1154 
1155 const (
1156@@ -27,17 +27,17 @@ const (
1157 
1158 type Bubble struct {
1159 	state        state
1160-	repo         types.Repo
1161+	repo         common.GitRepo
1162 	height       int
1163 	heightMargin int
1164 	width        int
1165 	widthMargin  int
1166 	style        *style.Styles
1167 	boxes        []tea.Model
1168-	ref          *plumbing.Reference
1169+	ref          *git.Reference
1170 }
1171 
1172-func NewBubble(repo types.Repo, styles *style.Styles, width, wm, height, hm int) *Bubble {
1173+func NewBubble(repo common.GitRepo, styles *style.Styles, width, wm, height, hm int) *Bubble {
1174 	b := &Bubble{
1175 		repo:         repo,
1176 		state:        aboutState,
1177@@ -47,7 +47,6 @@ func NewBubble(repo types.Repo, styles *style.Styles, width, wm, height, hm int)
1178 		heightMargin: hm,
1179 		style:        styles,
1180 		boxes:        make([]tea.Model, 4),
1181-		ref:          repo.GetHEAD(),
1182 	}
1183 	heightMargin := hm + lipgloss.Height(b.headerView())
1184 	b.boxes[aboutState] = about.NewBubble(repo, b.style, b.width, wm, b.height, heightMargin)
1185@@ -106,20 +105,20 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
1186 	return b, tea.Batch(cmds...)
1187 }
1188 
1189-func (b *Bubble) Help() []types.HelpEntry {
1190-	h := []types.HelpEntry{}
1191-	h = append(h, b.boxes[b.state].(types.BubbleHelper).Help()...)
1192+func (b *Bubble) Help() []common.HelpEntry {
1193+	h := []common.HelpEntry{}
1194+	h = append(h, b.boxes[b.state].(common.BubbleHelper).Help()...)
1195 	if b.repo.Name() != "config" {
1196-		h = append(h, types.HelpEntry{Key: "R", Value: "readme"})
1197-		h = append(h, types.HelpEntry{Key: "F", Value: "files"})
1198-		h = append(h, types.HelpEntry{Key: "C", Value: "commits"})
1199-		h = append(h, types.HelpEntry{Key: "B", Value: "branches"})
1200+		h = append(h, common.HelpEntry{Key: "R", Value: "readme"})
1201+		h = append(h, common.HelpEntry{Key: "F", Value: "files"})
1202+		h = append(h, common.HelpEntry{Key: "C", Value: "commits"})
1203+		h = append(h, common.HelpEntry{Key: "B", Value: "branches"})
1204 	}
1205 	return h
1206 }
1207 
1208-func (b *Bubble) Reference() plumbing.ReferenceName {
1209-	return b.ref.Name()
1210+func (b *Bubble) Reference() *git.Reference {
1211+	return b.ref
1212 }
1213 
1214 func (b *Bubble) headerView() string {
1215@@ -133,6 +132,11 @@ func (b *Bubble) View() string {
1216 }
1217 
1218 func (b *Bubble) setupCmd() tea.Msg {
1219+	head, err := b.repo.HEAD()
1220+	if err != nil {
1221+		return common.ErrMsg{Err: err}
1222+	}
1223+	b.ref = head
1224 	cmds := make([]tea.Cmd, 0)
1225 	for _, bx := range b.boxes {
1226 		if bx != nil {
1227@@ -140,7 +144,7 @@ func (b *Bubble) setupCmd() tea.Msg {
1228 			if initCmd != nil {
1229 				msg := initCmd()
1230 				switch msg := msg.(type) {
1231-				case types.ErrMsg:
1232+				case common.ErrMsg:
1233diff --git a/internal/tui/bubbles/git/types/consts.go b/pkg/tui/common/consts.go
1234rename from internal/tui/bubbles/git/types/consts.go
1235rename to pkg/tui/common/consts.go
1236index 940938638aa3082404ce339b8cdfbad01c52f7a2..c915df177fbfe6ca0c6c67c58ae246b39d633f6a 100644
1237--- a/internal/tui/bubbles/git/types/consts.go
1238+++ b/pkg/tui/common/consts.go
1239@@ -1,4 +1,4 @@
1240-package types
1241+package common
1242 
1243 import (
1244 	"time"
1245diff --git a/internal/tui/bubbles/git/types/error.go b/pkg/tui/common/error.go
1246rename from internal/tui/bubbles/git/types/error.go
1247rename to pkg/tui/common/error.go
1248index 0e2cf1e4621a00626bba67d2eda23e5ab231c158..b9ecc9594b04ed7a793fee776af2f3835ffa6715 100644
1249--- a/internal/tui/bubbles/git/types/error.go
1250+++ b/pkg/tui/common/error.go
1251@@ -1,4 +1,4 @@
1252-package types
1253+package common
1254 
1255 import (
1256 	"errors"
1257diff --git a/internal/tui/bubbles/git/types/formatter.go b/pkg/tui/common/formatter.go
1258rename from internal/tui/bubbles/git/types/formatter.go
1259rename to pkg/tui/common/formatter.go
1260index a7ef850e834284ddb0a41807ccd059429260d4a0..11143ec48a2694437cf63ae9e7728c2ad61198dc 100644
1261--- a/internal/tui/bubbles/git/types/formatter.go
1262+++ b/pkg/tui/common/formatter.go
1263@@ -1,4 +1,4 @@
1264-package types
1265+package common
1266 
1267 import (
1268 	"strings"
1269diff --git a/pkg/tui/common/git.go b/pkg/tui/common/git.go
1270new file mode 100644
1271index 0000000000000000000000000000000000000000..64a371de644feef6adb5d41c00dda4ed59c28e2f
1272--- /dev/null
1273+++ b/pkg/tui/common/git.go
1274@@ -0,0 +1,16 @@
1275+package common
1276+
1277+import (
1278+	"github.com/charmbracelet/soft-serve/pkg/git"
1279+)
1280+
1281+type GitRepo interface {
1282+	Name() string
1283+	Readme() (string, string)
1284+	HEAD() (*git.Reference, error)
1285+	CommitsByPage(*git.Reference, int, int) (git.Commits, error)
1286+	CountCommits(*git.Reference) (int64, error)
1287+	Diff(*git.Commit) (*git.Diff, error)
1288+	References() ([]*git.Reference, error)
1289+	Tree(*git.Reference, string) (*git.Tree, error)
1290+}
1291diff --git a/internal/tui/bubbles/git/types/help.go b/pkg/tui/common/help.go
1292rename from internal/tui/bubbles/git/types/help.go
1293rename to pkg/tui/common/help.go
1294index c42fa527270b56b469b5d029f933add804d2de99..5bc1a9a8df577879b048fa0bceee2dad4089676d 100644
1295--- a/internal/tui/bubbles/git/types/help.go
1296+++ b/pkg/tui/common/help.go
1297@@ -1,4 +1,4 @@
1298-package types
1299+package common
1300 
1301 type BubbleHelper interface {
1302 	Help() []HelpEntry
1303diff --git a/internal/tui/bubbles/git/types/reset.go b/pkg/tui/common/reset.go
1304rename from internal/tui/bubbles/git/types/reset.go
1305rename to pkg/tui/common/reset.go
1306index 919ab3d896bcc1dfa7d677f7e5df94d9a6ae6a4e..fe92b47154c9fe0850563dea7e9f87550c281c36 100644
1307--- a/internal/tui/bubbles/git/types/reset.go
1308+++ b/pkg/tui/common/reset.go
1309@@ -1,4 +1,4 @@
1310-package types
1311+package common
1312 
1313 import tea "github.com/charmbracelet/bubbletea"
1314 
1315diff --git a/internal/tui/bubbles/git/types/utils.go b/pkg/tui/common/utils.go
1316rename from internal/tui/bubbles/git/types/utils.go
1317rename to pkg/tui/common/utils.go
1318index c1758097d235186a24d120676b42b667604f8fff..2cafee64e60735a350363506c578dcf2bb9cb193 100644
1319--- a/internal/tui/bubbles/git/types/utils.go
1320+++ b/pkg/tui/common/utils.go
1321@@ -1,4 +1,4 @@
1322-package types
1323+package common
1324 
1325 import "github.com/muesli/reflow/truncate"
1326 
1327diff --git a/internal/tui/bubbles/git/log/bubble.go b/pkg/tui/log/bubble.go
1328rename from internal/tui/bubbles/git/log/bubble.go
1329rename to pkg/tui/log/bubble.go
1330index 579876737765bc3ab00251642fb2076600478cae..43128d2bafbbcc3a6541f5f94f35e2e518a59418 100644
1331--- a/internal/tui/bubbles/git/log/bubble.go
1332+++ b/pkg/tui/log/bubble.go
1333@@ -1,10 +1,8 @@
1334 package log
1335 
1336 import (
1337-	"context"
1338 	"fmt"
1339 	"io"
1340-	"math"
1341 	"strings"
1342 	"time"
1343 
1344@@ -13,13 +11,11 @@ import (
1345 	"github.com/charmbracelet/bubbles/viewport"
1346 	tea "github.com/charmbracelet/bubbletea"
1347 	gansi "github.com/charmbracelet/glamour/ansi"
1348-	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/refs"
1349-	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
1350-	vp "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/viewport"
1351 	"github.com/charmbracelet/soft-serve/internal/tui/style"
1352-	"github.com/dustin/go-humanize/english"
1353-	"github.com/go-git/go-git/v5/plumbing"
1354-	"github.com/go-git/go-git/v5/plumbing/object"
1355+	"github.com/charmbracelet/soft-serve/pkg/git"
1356+	"github.com/charmbracelet/soft-serve/pkg/tui/common"
1357+	"github.com/charmbracelet/soft-serve/pkg/tui/refs"
1358+	vp "github.com/charmbracelet/soft-serve/pkg/tui/viewport"
1359 )
1360 
1361 var (
1362@@ -30,7 +26,7 @@ var (
1363 	waitBeforeLoading = time.Millisecond * 300
1364 )
1365 
1366-type commitMsg *object.Commit
1367+type commitMsg *git.Commit
1368 
1369 type sessionState int
1370 
1371@@ -42,13 +38,12 @@ const (
1372 )
1373 
1374 type item struct {
1375-	*object.Commit
1376+	*git.Commit
1377 }
1378 
1379 func (i item) Title() string {
1380-	lines := strings.Split(i.Message, "\n")
1381-	if len(lines) > 0 {
1382-		return lines[0]
1383+	if i.Commit != nil {
1384+		return strings.Split(i.Commit.Message, "\n")[0]
1385 	}
1386 	return ""
1387 }
1388@@ -67,40 +62,45 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
1389 	if !ok {
1390 		return
1391 	}
1392+	if i.Commit == nil {
1393+		return
1394+	}
1395 
1396+	hash := i.ID.String()
1397 	leftMargin := d.style.LogItemSelector.GetMarginLeft() +
1398 		d.style.LogItemSelector.GetWidth() +
1399 		d.style.LogItemHash.GetMarginLeft() +
1400 		d.style.LogItemHash.GetWidth() +
1401 		d.style.LogItemInactive.GetMarginLeft()
1402-	title := types.TruncateString(i.Title(), m.Width()-leftMargin, "…")
1403+	title := common.TruncateString(i.Title(), m.Width()-leftMargin, "…")
1404 	if index == m.Index() {
1405 		fmt.Fprint(w, d.style.LogItemSelector.Render(">")+
1406-			d.style.LogItemHash.Bold(true).Render(i.Hash.String()[:7])+
1407+			d.style.LogItemHash.Bold(true).Render(hash[:7])+
1408 			d.style.LogItemActive.Render(title))
1409 	} else {
1410 		fmt.Fprint(w, d.style.LogItemSelector.Render(" ")+
1411-			d.style.LogItemHash.Render(i.Hash.String()[:7])+
1412+			d.style.LogItemHash.Render(hash[:7])+
1413 			d.style.LogItemInactive.Render(title))
1414 	}
1415 }
1416 
1417 type Bubble struct {
1418-	repo           types.Repo
1419+	repo           common.GitRepo
1420+	count          int64
1421 	list           list.Model
1422 	state          sessionState
1423 	commitViewport *vp.ViewportBubble
1424-	ref            *plumbing.Reference
1425+	ref            *git.Reference
1426 	style          *style.Styles
1427 	width          int
1428 	widthMargin    int
1429 	height         int
1430 	heightMargin   int
1431-	error          types.ErrMsg
1432+	error          common.ErrMsg
1433diff --git a/internal/tui/bubbles/git/refs/bubble.go b/pkg/tui/refs/bubble.go
1434rename from internal/tui/bubbles/git/refs/bubble.go
1435rename to pkg/tui/refs/bubble.go
1436index 0c99b4cbb7bd26a5d8f381de779285955c60f7e3..f31f12aaceb6c2855c93b886788f169411d96588 100644
1437--- a/internal/tui/bubbles/git/refs/bubble.go
1438+++ b/pkg/tui/refs/bubble.go
1439@@ -7,19 +7,19 @@ import (
1440 
1441 	"github.com/charmbracelet/bubbles/list"
1442 	tea "github.com/charmbracelet/bubbletea"
1443-	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
1444 	"github.com/charmbracelet/soft-serve/internal/tui/style"
1445-	"github.com/go-git/go-git/v5/plumbing"
1446+	"github.com/charmbracelet/soft-serve/pkg/git"
1447+	"github.com/charmbracelet/soft-serve/pkg/tui/common"
1448 )
1449 
1450-type RefMsg = *plumbing.Reference
1451+type RefMsg = *git.Reference
1452 
1453 type item struct {
1454-	*plumbing.Reference
1455+	*git.Reference
1456 }
1457 
1458 func (i item) Short() string {
1459-	return i.Name().Short()
1460+	return i.Reference.Name().Short()
1461 }
1462 
1463 func (i item) FilterValue() string { return i.Short() }
1464@@ -29,7 +29,7 @@ type items []item
1465 func (cl items) Len() int      { return len(cl) }
1466 func (cl items) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }
1467 func (cl items) Less(i, j int) bool {
1468-	return cl[i].Name().Short() < cl[j].Name().Short()
1469+	return cl[i].Short() < cl[j].Short()
1470 }
1471 
1472 type itemDelegate struct {
1473@@ -47,7 +47,7 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
1474 	}
1475 
1476 	ref := i.Short()
1477-	if i.Name().IsTag() {
1478+	if i.Reference.IsTag() {
1479 		ref = s.RefItemTag.Render(ref)
1480 	}
1481 	ref = s.RefItemBranch.Render(ref)
1482@@ -55,7 +55,7 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
1483 		s.RefItemSelector.GetMarginLeft() -
1484 		s.RefItemSelector.GetWidth() -
1485 		s.RefItemInactive.GetMarginLeft()
1486-	ref = types.TruncateString(ref, refMaxWidth, "…")
1487+	ref = common.TruncateString(ref, refMaxWidth, "…")
1488 	if index == m.Index() {
1489 		fmt.Fprint(w, s.RefItemSelector.Render(">")+
1490 			s.RefItemActive.Render(ref))
1491@@ -66,17 +66,21 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
1492 }
1493 
1494 type Bubble struct {
1495-	repo         types.Repo
1496+	repo         common.GitRepo
1497 	list         list.Model
1498 	style        *style.Styles
1499 	width        int
1500 	widthMargin  int
1501 	height       int
1502 	heightMargin int
1503-	ref          *plumbing.Reference
1504+	ref          *git.Reference
1505 }
1506 
1507-func NewBubble(repo types.Repo, styles *style.Styles, width, widthMargin, height, heightMargin int) *Bubble {
1508+func NewBubble(repo common.GitRepo, styles *style.Styles, width, widthMargin, height, heightMargin int) *Bubble {
1509+	head, err := repo.HEAD()
1510+	if err != nil {
1511+		return nil
1512+	}
1513 	l := list.NewModel([]list.Item{}, itemDelegate{styles}, width-widthMargin, height-heightMargin)
1514 	l.SetShowFilter(false)
1515 	l.SetShowHelp(false)
1516@@ -93,13 +97,13 @@ func NewBubble(repo types.Repo, styles *style.Styles, width, widthMargin, height
1517 		widthMargin:  widthMargin,
1518 		heightMargin: heightMargin,
1519 		list:         l,
1520-		ref:          repo.GetHEAD(),
1521+		ref:          head,
1522 	}
1523 	b.SetSize(width, height)
1524 	return b
1525 }
1526 
1527-func (b *Bubble) SetBranch(ref *plumbing.Reference) (tea.Model, tea.Cmd) {
1528+func (b *Bubble) SetBranch(ref *git.Reference) (tea.Model, tea.Cmd) {
1529 	b.ref = ref
1530 	return b, func() tea.Msg {
1531 		return RefMsg(ref)
1532@@ -123,21 +127,21 @@ func (b *Bubble) SetSize(width, height int) {
1533 	b.list.Styles.PaginationStyle = b.style.RefPaginator.Copy().Width(width - b.widthMargin)
1534 }
1535 
1536-func (b *Bubble) Help() []types.HelpEntry {
1537+func (b *Bubble) Help() []common.HelpEntry {
1538 	return nil
1539diff --git a/internal/tui/bubbles/git/tree/bubble.go b/pkg/tui/tree/bubble.go
1540rename from internal/tui/bubbles/git/tree/bubble.go
1541rename to pkg/tui/tree/bubble.go
1542index e6c2a22d4698cb964352b1468a33569a80b7b775..c0e735c2f7f375c9aeb77daeb04198ebd4c107b0 100644
1543--- a/internal/tui/bubbles/git/tree/bubble.go
1544+++ b/pkg/tui/tree/bubble.go
1545@@ -3,22 +3,20 @@ package tree
1546 import (
1547 	"fmt"
1548 	"io"
1549+	"io/fs"
1550 	"path/filepath"
1551-	"sort"
1552 	"strings"
1553 
1554 	"github.com/charmbracelet/bubbles/list"
1555 	"github.com/charmbracelet/bubbles/viewport"
1556 	tea "github.com/charmbracelet/bubbletea"
1557 	"github.com/charmbracelet/lipgloss"
1558-	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/refs"
1559-	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
1560-	vp "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/viewport"
1561 	"github.com/charmbracelet/soft-serve/internal/tui/style"
1562+	"github.com/charmbracelet/soft-serve/pkg/git"
1563+	"github.com/charmbracelet/soft-serve/pkg/tui/common"
1564+	"github.com/charmbracelet/soft-serve/pkg/tui/refs"
1565+	vp "github.com/charmbracelet/soft-serve/pkg/tui/viewport"
1566 	"github.com/dustin/go-humanize"
1567-	"github.com/go-git/go-git/v5/plumbing"
1568-	"github.com/go-git/go-git/v5/plumbing/filemode"
1569-	"github.com/go-git/go-git/v5/plumbing/object"
1570 )
1571 
1572 type fileMsg struct {
1573@@ -34,16 +32,15 @@ const (
1574 )
1575 
1576 type item struct {
1577-	entry *object.TreeEntry
1578-	file  *object.File
1579+	entry *git.TreeEntry
1580 }
1581 
1582 func (i item) Name() string {
1583-	return i.entry.Name
1584+	return i.entry.Name()
1585 }
1586 
1587-func (i item) Mode() filemode.FileMode {
1588-	return i.entry.Mode
1589+func (i item) Mode() fs.FileMode {
1590+	return i.entry.Mode()
1591 }
1592 
1593 func (i item) FilterValue() string { return i.Name() }
1594@@ -53,11 +50,11 @@ type items []item
1595 func (cl items) Len() int      { return len(cl) }
1596 func (cl items) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }
1597 func (cl items) Less(i, j int) bool {
1598-	if cl[i].Mode() == filemode.Dir && cl[j].Mode() == filemode.Dir {
1599+	if cl[i].entry.IsTree() && cl[j].entry.IsTree() {
1600 		return cl[i].Name() < cl[j].Name()
1601-	} else if cl[i].Mode() == filemode.Dir {
1602+	} else if cl[i].entry.IsTree() {
1603 		return true
1604-	} else if cl[j].Mode() == filemode.Dir {
1605+	} else if cl[j].entry.IsTree() {
1606 		return false
1607 	} else {
1608 		return cl[i].Name() < cl[j].Name()
1609@@ -79,15 +76,13 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
1610 	}
1611 
1612 	name := i.Name()
1613-	if i.Mode() == filemode.Dir {
1614+	size := humanize.Bytes(uint64(i.entry.Size()))
1615+	if i.entry.IsTree() {
1616+		size = ""
1617 		name = s.TreeFileDir.Render(name)
1618 	}
1619-	size := ""
1620-	if i.file != nil {
1621-		size = humanize.Bytes(uint64(i.file.Size))
1622-	}
1623 	var cs lipgloss.Style
1624-	mode, _ := i.Mode().ToOSFileMode()
1625+	mode := i.Mode()
1626 	if index == m.Index() {
1627 		cs = s.TreeItemActive
1628 		fmt.Fprint(w, s.TreeItemSelector.Render(">"))
1629@@ -101,7 +96,7 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
1630 		s.TreeFileMode.GetWidth() +
1631 		cs.GetMarginLeft()
1632 	rightMargin := s.TreeFileSize.GetMarginLeft() + lipgloss.Width(size)
1633-	name = types.TruncateString(name, m.Width()-leftMargin-rightMargin, "…")
1634+	name = common.TruncateString(name, m.Width()-leftMargin-rightMargin, "…")
1635 	sizeStyle := s.TreeFileSize.Copy().
1636 		Width(m.Width() -
1637 			leftMargin -
1638@@ -114,7 +109,7 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
1639 }
1640 
1641 type Bubble struct {
1642-	repo         types.Repo
1643+	repo         common.GitRepo
1644 	list         list.Model
1645diff --git a/internal/tui/bubbles/git/viewport/viewport_patch.go b/pkg/tui/viewport/viewport_patch.go
1646rename from internal/tui/bubbles/git/viewport/viewport_patch.go
1647rename to pkg/tui/viewport/viewport_patch.go
1648diff --git a/server/middleware.go b/server/middleware.go
1649index e8b86b8c568cf6ac28bf923cd6630ef2cb76174d..a87ae492deaebd1b22e8152109fb5275aae4dbe7 100644
1650--- a/server/middleware.go
1651+++ b/server/middleware.go
1652@@ -3,19 +3,18 @@ package server
1653 import (
1654 	"fmt"
1655 	"path/filepath"
1656-	"sort"
1657 	"strings"
1658 
1659 	"github.com/alecthomas/chroma/lexers"
1660 	gansi "github.com/charmbracelet/glamour/ansi"
1661 	"github.com/charmbracelet/lipgloss"
1662 	appCfg "github.com/charmbracelet/soft-serve/internal/config"
1663-	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
1664+	"github.com/charmbracelet/soft-serve/pkg/git"
1665+	"github.com/charmbracelet/soft-serve/pkg/tui/common"
1666 	"github.com/charmbracelet/wish"
1667 	gitwish "github.com/charmbracelet/wish/git"
1668 	"github.com/gliderlabs/ssh"
1669-	"github.com/go-git/go-git/v5/plumbing/filemode"
1670-	"github.com/go-git/go-git/v5/plumbing/object"
1671+	ggit "github.com/gogs/git-module"
1672 	"github.com/muesli/termenv"
1673 )
1674 
1675@@ -27,22 +26,6 @@ var (
1676 	filemodeStyle  = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777"))
1677 )
1678 
1679-type entries []object.TreeEntry
1680-
1681-func (cl entries) Len() int      { return len(cl) }
1682-func (cl entries) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }
1683-func (cl entries) Less(i, j int) bool {
1684-	if cl[i].Mode == filemode.Dir && cl[j].Mode == filemode.Dir {
1685-		return cl[i].Name < cl[j].Name
1686-	} else if cl[i].Mode == filemode.Dir {
1687-		return true
1688-	} else if cl[j].Mode == filemode.Dir {
1689-		return false
1690-	} else {
1691-		return cl[i].Name < cl[j].Name
1692-	}
1693-}
1694-
1695 // softServeMiddleware is a middleware that handles displaying files with the
1696 // option of syntax highlighting and line numbers.
1697 func softServeMiddleware(ac *appCfg.Config) wish.Middleware {
1698@@ -90,15 +73,27 @@ func softServeMiddleware(ac *appCfg.Config) wish.Middleware {
1699 						_ = s.Exit(1)
1700 						return
1701 					}
1702+					ref, err := rs.HEAD()
1703+					if err != nil {
1704+						_, _ = s.Write([]byte(err.Error()))
1705+						_ = s.Exit(1)
1706+						return
1707+					}
1708 					p := strings.Join(ps[1:], "/")
1709-					t, err := rs.LatestTree(p)
1710-					if err != nil && err != object.ErrDirectoryNotFound {
1711+					t, err := rs.Tree(ref, p)
1712+					if err != nil && err != ggit.ErrRevisionNotExist {
1713 						_, _ = s.Write([]byte(err.Error()))
1714 						_ = s.Exit(1)
1715 						return
1716 					}
1717-					if err == object.ErrDirectoryNotFound {
1718-						fc, err := rs.LatestFile(p)
1719+					if err == ggit.ErrRevisionNotExist {
1720+						_, _ = s.Write([]byte(git.ErrFileNotFound.Error()))
1721+						_ = s.Exit(1)
1722+						return
1723+					}
1724+					ents, err := t.Entries()
1725+					if err != nil {
1726+						fc, _, err := rs.LatestFile(p)
1727 						if err != nil {
1728 							_, _ = s.Write([]byte(err.Error()))
1729 							_ = s.Exit(1)
1730@@ -118,20 +113,19 @@ func softServeMiddleware(ac *appCfg.Config) wish.Middleware {
1731 						}
1732 						s.Write([]byte(fc))
1733 					} else {
1734-						ents := entries(t.Entries)
1735-						sort.Sort(ents)
1736+						ents.Sort()
1737 						for _, e := range ents {
1738-							m, _ := e.Mode.ToOSFileMode()
1739+							m := e.Mode()
1740 							if m == 0 {
1741 								s.Write([]byte(strings.Repeat(" ", 10)))
1742 							} else {
1743 								s.Write([]byte(filemodeStyle.Render(m.String())))
1744 							}
1745 							s.Write([]byte(" "))
1746-							if e.Mode.IsFile() {
1747-								s.Write([]byte(filenameStyle.Render(e.Name)))
1748+							if !e.IsTree() {
1749+								s.Write([]byte(filenameStyle.Render(e.Name())))
1750 							} else {
1751-								s.Write([]byte(dirnameStyle.Render(e.Name)))
1752diff --git a/server/server.go b/server/server.go
1753index 7b72f6ca3705571f3c31248994dbde0e91a636b8..3f6a56ccbfe198da90c8985c25c6c3938729465c 100644
1754--- a/server/server.go
1755+++ b/server/server.go
1756@@ -8,7 +8,6 @@ import (
1757 	"github.com/charmbracelet/soft-serve/config"
1758 	appCfg "github.com/charmbracelet/soft-serve/internal/config"
1759 	"github.com/charmbracelet/soft-serve/internal/tui"
1760-
1761 	"github.com/charmbracelet/wish"
1762 	bm "github.com/charmbracelet/wish/bubbletea"
1763 	gm "github.com/charmbracelet/wish/git"