8e68dba683ab45107d5ab100b77ea1bfccdb1a2c

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

Message

feat: implement git-lfs preliminary support

- Support Git LFS SSH transfer (server-side)
- Implement Git LFS Basic transfer (client)
- Import missing LFS objects when importing a repository

wip

fix: user can be nil for anonymous connections

fix: wrap db errors

fix: lint errors

Diff

This diff is truncated to protect this page.

   1diff --git a/cmd/soft/root.go b/cmd/soft/root.go
   2index b3216162f5601ed44bddfd3fa0bdcc86a8a1825c..7f03982d437c09440c5c65e0932bd3c9af294f9f 100644
   3--- a/cmd/soft/root.go
   4+++ b/cmd/soft/root.go
   5@@ -12,6 +12,8 @@ import (
   6 	"github.com/charmbracelet/soft-serve/server/backend"
   7 	"github.com/charmbracelet/soft-serve/server/config"
   8 	"github.com/charmbracelet/soft-serve/server/db"
   9+	"github.com/charmbracelet/soft-serve/server/store"
  10+	"github.com/charmbracelet/soft-serve/server/store/database"
  11 	_ "github.com/lib/pq" // postgres driver
  12 	"github.com/spf13/cobra"
  13 	"go.uber.org/automaxprocs/maxprocs"
  14@@ -150,6 +152,8 @@ func initBackendContext(cmd *cobra.Command, _ []string) error {
  15 	}
  16 
  17 	ctx = db.WithContext(ctx, dbx)
  18+	dbstore := database.New(ctx, dbx)
  19+	ctx = store.WithContext(ctx, dbstore)
  20 	be := backend.New(ctx, cfg, dbx)
  21 	ctx = backend.WithContext(ctx, be)
  22 
  23diff --git a/git/config.go b/git/config.go
  24index 4e9af6ed500302e8d1e7e7afeabf13bbba4bde48..1ebb2470384145be6ff8b2ab4cafd346d41bac64 100644
  25--- a/git/config.go
  26+++ b/git/config.go
  27@@ -1,51 +1,39 @@
  28 package git
  29 
  30-// ConfigOptions are options for Config.
  31-type ConfigOptions struct {
  32-	File string
  33-	All  bool
  34-	Add  bool
  35-	CommandOptions
  36-}
  37+import (
  38+	"os"
  39+	"path/filepath"
  40 
  41-// Config gets a git configuration.
  42-func Config(key string, opts ...ConfigOptions) (string, error) {
  43-	var opt ConfigOptions
  44-	if len(opts) > 0 {
  45-		opt = opts[0]
  46-	}
  47-	cmd := NewCommand("config")
  48-	if opt.File != "" {
  49-		cmd.AddArgs("--file", opt.File)
  50-	}
  51-	if opt.All {
  52-		cmd.AddArgs("--get-all")
  53-	}
  54-	for _, a := range opt.Args {
  55-		cmd.AddArgs(a)
  56-	}
  57-	cmd.AddArgs(key)
  58-	bts, err := cmd.Run()
  59+	gcfg "github.com/go-git/go-git/v5/plumbing/format/config"
  60+)
  61+
  62+// Config returns the repository Git configuration.
  63+func (r *Repository) Config() (*gcfg.Config, error) {
  64+	cp := filepath.Join(r.Path, "config")
  65+	f, err := os.Open(cp)
  66 	if err != nil {
  67-		return "", err
  68+		return nil, err
  69 	}
  70-	return string(bts), nil
  71-}
  72 
  73-// SetConfig sets a git configuration.
  74-func SetConfig(key string, value string, opts ...ConfigOptions) error {
  75-	var opt ConfigOptions
  76-	if len(opts) > 0 {
  77-		opt = opts[0]
  78+	defer f.Close() // nolint: errcheck
  79+	d := gcfg.NewDecoder(f)
  80+	cfg := gcfg.New()
  81+	if err := d.Decode(cfg); err != nil {
  82+		return nil, err
  83 	}
  84-	cmd := NewCommand("config")
  85-	if opt.File != "" {
  86-		cmd.AddArgs("--file", opt.File)
  87-	}
  88-	for _, a := range opt.Args {
  89-		cmd.AddArgs(a)
  90+
  91+	return cfg, nil
  92+}
  93+
  94+// SetConfig sets the repository Git configuration.
  95+func (r *Repository) SetConfig(cfg *gcfg.Config) error {
  96+	cp := filepath.Join(r.Path, "config")
  97+	f, err := os.Create(cp)
  98+	if err != nil {
  99+		return err
 100 	}
 101-	cmd.AddArgs(key, value)
 102-	_, err := cmd.Run()
 103-	return err
 104+
 105+	defer f.Close() // nolint: errcheck
 106+	e := gcfg.NewEncoder(f)
 107+	return e.Encode(cfg)
 108 }
 109diff --git a/git/repo.go b/git/repo.go
 110index 479a4581a6a0ddf3c2094224f98a37f595da1734..ef3d2759d63411b4d7f297ebb838eb959bd6b00d 100644
 111--- a/git/repo.go
 112+++ b/git/repo.go
 113@@ -200,34 +200,6 @@ func (r *Repository) CommitsByPage(ref *Reference, page, size int) (Commits, err
 114 	return commits, nil
 115 }
 116 
 117-// Config returns the config value for the given key.
 118-func (r *Repository) Config(key string, opts ...ConfigOptions) (string, error) {
 119-	dir, err := gitDir(r.Repository)
 120-	if err != nil {
 121-		return "", err
 122-	}
 123-	var opt ConfigOptions
 124-	if len(opts) > 0 {
 125-		opt = opts[0]
 126-	}
 127-	opt.File = filepath.Join(dir, "config")
 128-	return Config(key, opt)
 129-}
 130-
 131-// SetConfig sets the config value for the given key.
 132-func (r *Repository) SetConfig(key, value string, opts ...ConfigOptions) error {
 133-	dir, err := gitDir(r.Repository)
 134-	if err != nil {
 135-		return err
 136-	}
 137-	var opt ConfigOptions
 138-	if len(opts) > 0 {
 139-		opt = opts[0]
 140-	}
 141-	opt.File = filepath.Join(dir, "config")
 142-	return SetConfig(key, value, opt)
 143-}
 144-
 145 // SymbolicRef returns or updates the symbolic reference for the given name.
 146 // Both name and ref can be empty.
 147 func (r *Repository) SymbolicRef(name string, ref string, opts ...git.SymbolicRefOptions) (string, error) {
 148diff --git a/go.mod b/go.mod
 149index e84be4d037b67ddf7e1ad1af07739e5f0d883638..0a9d11dcdd3532e6ac2c27621cc67f3a1a04a057 100644
 150--- a/go.mod
 151+++ b/go.mod
 152@@ -19,9 +19,10 @@ require (
 153 
 154 require (
 155 	github.com/caarlos0/env/v8 v8.0.0
 156+	github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20230721203144-64d90e7a36a1
 157 	github.com/charmbracelet/keygen v0.4.3
 158 	github.com/charmbracelet/log v0.2.3-0.20230713155356-557335e40e35
 159-	github.com/charmbracelet/ssh v0.0.0-20230712221603-7e03c5063afc
 160+	github.com/charmbracelet/ssh v0.0.0-20230720143903-5bdd92839155
 161 	github.com/gobwas/glob v0.2.3
 162 	github.com/gogs/git-module v1.8.2
 163 	github.com/hashicorp/golang-lru/v2 v2.0.4
 164@@ -33,6 +34,7 @@ require (
 165 	github.com/prometheus/client_golang v1.16.0
 166 	github.com/robfig/cron/v3 v3.0.1
 167 	github.com/rogpeppe/go-internal v1.11.0
 168+	github.com/rubyist/tracerx v0.0.0-20170927163412-787959303086
 169 	github.com/spf13/cobra v1.7.0
 170 	go.uber.org/automaxprocs v1.5.3
 171 	goji.io v2.0.2+incompatible
 172@@ -52,6 +54,8 @@ require (
 173 	github.com/cespare/xxhash/v2 v2.2.0 // indirect
 174 	github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
 175 	github.com/dlclark/regexp2 v1.4.0 // indirect
 176+	github.com/git-lfs/pktline v0.0.0-20230103162542-ca444d533ef1 // indirect
 177+	github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
 178 	github.com/go-logfmt/logfmt v0.6.0 // indirect
 179 	github.com/golang/protobuf v1.5.3 // indirect
 180 	github.com/google/uuid v1.3.0 // indirect
 181@@ -80,12 +84,13 @@ require (
 182 	github.com/yuin/goldmark v1.5.2 // indirect
 183 	github.com/yuin/goldmark-emoji v1.0.1 // indirect
 184 	golang.org/x/mod v0.9.0 // indirect
 185-	golang.org/x/net v0.10.0 // indirect
 186+	golang.org/x/net v0.12.0 // indirect
 187 	golang.org/x/sys v0.10.0 // indirect
 188 	golang.org/x/term v0.10.0 // indirect
 189 	golang.org/x/text v0.11.0 // indirect
 190 	golang.org/x/tools v0.6.0 // indirect
 191 	google.golang.org/protobuf v1.30.0 // indirect
 192+	gopkg.in/warnings.v0 v0.1.2 // indirect
 193 	lukechampine.com/uint128 v1.2.0 // indirect
 194 	modernc.org/cc/v3 v3.40.0 // indirect
 195 	modernc.org/ccgo/v3 v3.16.13 // indirect
 196diff --git a/go.sum b/go.sum
 197index 30314107e3eb2ae3642a2291bd1d6968186e03ad..7846d7c62bcc53de401544721db60589731d64ac 100644
 198--- a/go.sum
 199+++ b/go.sum
 200@@ -21,6 +21,8 @@ github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5
 201 github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc=
 202 github.com/charmbracelet/bubbletea v0.24.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06RaW2cx/SY=
 203 github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg=
 204+github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20230721203144-64d90e7a36a1 h1:/QzZzTDdlDYGZeC2O2y/Qw+AiHqh3vCsO4yrKDWXtqs=
 205+github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20230721203144-64d90e7a36a1/go.mod h1:eXJuVicxnjRgRMokmutZdistxoMRjBjjfqvrYq7bCIU=
 206 github.com/charmbracelet/glamour v0.6.0 h1:wi8fse3Y7nfcabbbDuwolqTqMQPMnVPeZhDM273bISc=
 207 github.com/charmbracelet/glamour v0.6.0/go.mod h1:taqWV4swIMMbWALc0m7AfE9JkPSU8om2538k9ITBxOc=
 208 github.com/charmbracelet/keygen v0.4.3 h1:ywOZRwkDlpmkawl0BgLTxaYWDSqp6Y4nfVVmgyyO1Mg=
 209@@ -29,8 +31,8 @@ github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZ
 210 github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c=
 211 github.com/charmbracelet/log v0.2.3-0.20230713155356-557335e40e35 h1:VXEaJ1iM2L5N8T2WVbv4y631pzCD3O9s75dONqK+87g=
 212 github.com/charmbracelet/log v0.2.3-0.20230713155356-557335e40e35/go.mod h1:ZApwwzDbbETVTIRTk7724yQRJAXIktt98yGVMMaa3y8=
 213-github.com/charmbracelet/ssh v0.0.0-20230712221603-7e03c5063afc h1:JUm+5HigAM5utFiThwIDX9iU0BaheKpuNVr+umi3sFg=
 214-github.com/charmbracelet/ssh v0.0.0-20230712221603-7e03c5063afc/go.mod h1:F1vgddWsb/Yr/OZilFeRZEh5sE/qU0Dt1mKkmke6Zvg=
 215+github.com/charmbracelet/ssh v0.0.0-20230720143903-5bdd92839155 h1:vJqYhlL0doAWQPz+EX/hK5x/ZYguoua773oRz77zYKo=
 216+github.com/charmbracelet/ssh v0.0.0-20230720143903-5bdd92839155/go.mod h1:F1vgddWsb/Yr/OZilFeRZEh5sE/qU0Dt1mKkmke6Zvg=
 217 github.com/charmbracelet/wish v1.1.1 h1:KdICASKd2oh2JPvk1Z4CJtAi97cFErXF7NKienPICO4=
 218 github.com/charmbracelet/wish v1.1.1/go.mod h1:xh4KZpSULw+Xqb9bcbhw92QAinVB75CVLWrFuyY6IVs=
 219 github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
 220@@ -43,6 +45,10 @@ github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E
 221 github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
 222 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
 223 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
 224+github.com/git-lfs/pktline v0.0.0-20230103162542-ca444d533ef1 h1:mtDjlmloH7ytdblogrMz1/8Hqua1y8B4ID+bh3rvod0=
 225+github.com/git-lfs/pktline v0.0.0-20230103162542-ca444d533ef1/go.mod h1:fenKRzpXDjNpsIBhuhUzvjCKlDjKam0boRAenTE0Q6A=
 226+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
 227+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
 228 github.com/go-git/go-git/v5 v5.7.0 h1:t9AudWVLmqzlo+4bqdf7GY+46SUuRsx59SboFxkq2aE=
 229 github.com/go-git/go-git/v5 v5.7.0/go.mod h1:coJHKEOk5kUClpsNlXrUvPrDxY3w3gjHvhcZd8Fodw8=
 230 github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
 231@@ -124,6 +130,8 @@ github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo
 232 github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
 233 github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
 234 github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
 235+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 236+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 237 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 238 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 239 github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
 240@@ -145,6 +153,8 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
 241 github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
 242 github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
 243 github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
 244+github.com/rubyist/tracerx v0.0.0-20170927163412-787959303086 h1:mncRSDOqYCng7jOD+Y6+IivdRI6Kzv2BLWYkWkdQfu0=
 245+github.com/rubyist/tracerx v0.0.0-20170927163412-787959303086/go.mod h1:YpdgDXpumPB/+EGmGTYHeiW/0QVFRzBYTNFaxWfPDk4=
 246 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 247 github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI=
 248 github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
 249@@ -157,6 +167,7 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
 250 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 251 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 252 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
 253+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 254 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
 255 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 256 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 257@@ -179,8 +190,8 @@ golang.org/x/mod v0.9.0 h1:KENHtAZL2y3NLMYZeHY9DW8HW8V+kQyJsY/V9JlKvCs=
 258 golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 259 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 260 golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
 261-golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
 262-golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
 263+golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50=
 264+golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
 265 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 266 golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 267 golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
 268@@ -215,6 +226,8 @@ google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqw
 269 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 270 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 271 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
 272+gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
 273+gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
 274 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 275 gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 276 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 277diff --git a/server/backend/backend.go b/server/backend/backend.go
 278index 2d6104d56f2a32563c7724ccd762312895c804a2..586d95132504a852dc8fa339de464d3360aca59d 100644
 279--- a/server/backend/backend.go
 280+++ b/server/backend/backend.go
 281@@ -7,7 +7,6 @@ import (
 282 	"github.com/charmbracelet/soft-serve/server/config"
 283 	"github.com/charmbracelet/soft-serve/server/db"
 284 	"github.com/charmbracelet/soft-serve/server/store"
 285-	"github.com/charmbracelet/soft-serve/server/store/database"
 286 )
 287 
 288 // Backend is the Soft Serve backend that handles users, repositories, and
 289@@ -23,7 +22,7 @@ type Backend struct {
 290 
 291 // New returns a new Soft Serve backend.
 292 func New(ctx context.Context, cfg *config.Config, db *db.DB) *Backend {
 293-	dbstore := database.New(ctx, db)
 294+	dbstore := store.FromContext(ctx)
 295 	logger := log.FromContext(ctx).WithPrefix("backend")
 296 	b := &Backend{
 297 		ctx:    ctx,
 298diff --git a/server/backend/lfs.go b/server/backend/lfs.go
 299new file mode 100644
 300index 0000000000000000000000000000000000000000..dfc21ea69d5eb9193d347b39011591f4fd9c9367
 301--- /dev/null
 302+++ b/server/backend/lfs.go
 303@@ -0,0 +1,85 @@
 304+package backend
 305+
 306+import (
 307+	"context"
 308+	"errors"
 309+	"io"
 310+	"path"
 311+	"path/filepath"
 312+
 313+	"github.com/charmbracelet/soft-serve/server/config"
 314+	"github.com/charmbracelet/soft-serve/server/db"
 315+	"github.com/charmbracelet/soft-serve/server/lfs"
 316+	"github.com/charmbracelet/soft-serve/server/proto"
 317+	"github.com/charmbracelet/soft-serve/server/storage"
 318+	"github.com/charmbracelet/soft-serve/server/store"
 319+)
 320+
 321+// StoreRepoMissingLFSObjects stores missing LFS objects for a repository.
 322+func StoreRepoMissingLFSObjects(ctx context.Context, repo proto.Repository, dbx *db.DB, store store.Store, lfsClient lfs.Client) error {
 323+	cfg := config.FromContext(ctx)
 324+	lfsRoot := filepath.Join(cfg.DataPath, "lfs")
 325+
 326+	// TODO: support S3 storage
 327+	strg := storage.NewLocalStorage(lfsRoot)
 328+	pointerChan := make(chan lfs.PointerBlob)
 329+	errChan := make(chan error, 1)
 330+	r, err := repo.Open()
 331+	if err != nil {
 332+		return err
 333+	}
 334+
 335+	go lfs.SearchPointerBlobs(ctx, r, pointerChan, errChan)
 336+
 337+	download := func(pointers []lfs.Pointer) error {
 338+		return lfsClient.Download(ctx, pointers, func(p lfs.Pointer, content io.ReadCloser, objectError error) error {
 339+			if objectError != nil {
 340+				return objectError
 341+			}
 342+
 343+			defer content.Close() // nolint: errcheck
 344+			return dbx.TransactionContext(ctx, func(tx *db.Tx) error {
 345+				if err := store.CreateLFSObject(ctx, tx, repo.ID(), p.Oid, p.Size); err != nil {
 346+					return db.WrapError(err)
 347+				}
 348+
 349+				return strg.Put(path.Join("objects", p.RelativePath()), content)
 350+			})
 351+		})
 352+	}
 353+
 354+	var batch []lfs.Pointer
 355+	for pointer := range pointerChan {
 356+		obj, err := store.GetLFSObjectByOid(ctx, dbx, repo.ID(), pointer.Oid)
 357+		if err != nil && !errors.Is(err, db.ErrRecordNotFound) {
 358+			return db.WrapError(err)
 359+		}
 360+
 361+		exist, err := strg.Exists(path.Join("objects", pointer.RelativePath()))
 362+		if err != nil {
 363+			return err
 364+		}
 365+
 366+		if exist && obj.ID == 0 {
 367+			if err := store.CreateLFSObject(ctx, dbx, repo.ID(), pointer.Oid, pointer.Size); err != nil {
 368+				return db.WrapError(err)
 369+			}
 370+		} else {
 371+			batch = append(batch, pointer.Pointer)
 372+			// Limit batch requests to 20 objects
 373+			if len(batch) >= 20 {
 374+				if err := download(batch); err != nil {
 375+					return err
 376+				}
 377+
 378+				batch = nil
 379+			}
 380+		}
 381+	}
 382+
 383+	if err, ok := <-errChan; ok {
 384+		return err
 385+	}
 386+
 387+	return nil
 388+}
 389diff --git a/server/backend/repo.go b/server/backend/repo.go
 390index ebd846f4e9328ea71fa10ca1b56d592f5a62b2b4..15eaea6ff72ea919d66ff897633471373a7c6048 100644
 391--- a/server/backend/repo.go
 392+++ b/server/backend/repo.go
 393@@ -7,6 +7,7 @@ import (
 394 	"fmt"
 395 	"io/fs"
 396 	"os"
 397+	"path"
 398 	"path/filepath"
 399 	"time"
 400 
 401@@ -14,7 +15,9 @@ import (
 402 	"github.com/charmbracelet/soft-serve/server/db"
 403 	"github.com/charmbracelet/soft-serve/server/db/models"
 404 	"github.com/charmbracelet/soft-serve/server/hooks"
 405+	"github.com/charmbracelet/soft-serve/server/lfs"
 406 	"github.com/charmbracelet/soft-serve/server/proto"
 407+	"github.com/charmbracelet/soft-serve/server/storage"
 408 	"github.com/charmbracelet/soft-serve/server/utils"
 409 )
 410 
 411@@ -103,7 +106,6 @@ func (d *Backend) ImportRepository(ctx context.Context, name string, remote stri
 412 				),
 413 			},
 414 		},
 415-		// Timeout: time.Hour,
 416 	}
 417 
 418 	if err := git.Clone(remote, rp, copts); err != nil {
 419@@ -115,13 +117,51 @@ func (d *Backend) ImportRepository(ctx context.Context, name string, remote stri
 420 		return nil, err
 421 	}
 422 
 423-	return d.CreateRepository(ctx, name, opts)
 424+	r, err := d.CreateRepository(ctx, name, opts)
 425+	if err != nil {
 426+		d.logger.Error("failed to create repository", "err", err, "name", name)
 427+		return nil, err
 428+	}
 429+
 430+	rr, err := r.Open()
 431+	if err != nil {
 432+		d.logger.Error("failed to open repository", "err", err, "path", rp)
 433+		return nil, err
 434+	}
 435+
 436+	rcfg, err := rr.Config()
 437+	if err != nil {
 438+		d.logger.Error("failed to get repository config", "err", err, "path", rp)
 439+		return nil, err
 440+	}
 441+
 442+	rcfg.Section("lfs").SetOption("url", remote)
 443+
 444+	if err := rr.SetConfig(rcfg); err != nil {
 445+		d.logger.Error("failed to set repository config", "err", err, "path", rp)
 446+		return nil, err
 447+	}
 448+
 449+	endpoint, err := lfs.NewEndpoint(remote)
 450+	if err != nil {
 451+		d.logger.Error("failed to create lfs endpoint", "err", err, "path", rp)
 452+		return nil, err
 453+	}
 454+
 455+	client := lfs.NewClient(endpoint)
 456+
 457+	if err := StoreRepoMissingLFSObjects(ctx, r, d.db, d.store, client); err != nil {
 458+		d.logger.Error("failed to store missing lfs objects", "err", err, "path", rp)
 459+		return nil, err
 460+	}
 461+
 462+	return r, nil
 463 }
 464 
 465 // DeleteRepository deletes a repository.
 466 //
 467 // It implements backend.Backend.
 468-func (d *Backend) DeleteRepository(ctx context.Context, name string) error {
 469+func (d *Backend) DeleteRepository(ctx context.Context, name string, deleteLFS bool) error {
 470 	name = utils.SanitizeRepo(name)
 471 	repo := name + ".git"
 472 	rp := filepath.Join(d.reposPath(), repo)
 473@@ -130,6 +170,26 @@ func (d *Backend) DeleteRepository(ctx context.Context, name string) error {
 474 		// Delete repo from cache
 475 		defer d.cache.Delete(name)
 476 
 477+		if deleteLFS {
 478+			strg := storage.NewLocalStorage(filepath.Join(d.cfg.DataPath, "lfs"))
 479+			objs, err := d.store.GetLFSObjectsByName(ctx, tx, name)
 480+			if err != nil {
 481+				return err
 482+			}
 483+
 484+			for _, obj := range objs {
 485+				p := lfs.Pointer{
 486+					Oid:  obj.Oid,
 487+					Size: obj.Size,
 488+				}
 489+
 490+				d.logger.Debug("deleting lfs object", "repo", name, "oid", obj.Oid)
 491+				if err := strg.Delete(path.Join("objects", p.RelativePath())); err != nil {
 492+					d.logger.Error("failed to delete lfs object", "repo", name, "err", err, "oid", obj.Oid)
 493diff --git a/server/backend/user.go b/server/backend/user.go
 494index edefaf686e55eea1b08e43e9fb9a5809ba4d61b7..8b5e2a2aeae549bec6d1f19dec0a5e0d00685bbd 100644
 495--- a/server/backend/user.go
 496+++ b/server/backend/user.go
 497@@ -17,8 +17,36 @@ import (
 498 //
 499 // It implements backend.Backend.
 500 func (d *Backend) AccessLevel(ctx context.Context, repo string, username string) access.AccessLevel {
 501-	anon := d.AnonAccess(ctx)
 502 	user, _ := d.User(ctx, username)
 503+	return d.AccessLevelForUser(ctx, repo, user)
 504+}
 505+
 506+// AccessLevelByPublicKey returns the access level of a user's public key for a repository.
 507+//
 508+// It implements backend.Backend.
 509+func (d *Backend) AccessLevelByPublicKey(ctx context.Context, repo string, pk ssh.PublicKey) access.AccessLevel {
 510+	for _, k := range d.cfg.AdminKeys() {
 511+		if sshutils.KeysEqual(pk, k) {
 512+			return access.AdminAccess
 513+		}
 514+	}
 515+
 516+	user, _ := d.UserByPublicKey(ctx, pk)
 517+	if user != nil {
 518+		return d.AccessLevel(ctx, repo, user.Username())
 519+	}
 520+
 521+	return d.AccessLevel(ctx, repo, "")
 522+}
 523+
 524+// AccessLevelForUser returns the access level of a user for a repository.
 525+func (d *Backend) AccessLevelForUser(ctx context.Context, repo string, user proto.User) access.AccessLevel {
 526+	var username string
 527+	anon := d.AnonAccess(ctx)
 528+	if user != nil {
 529+		username = user.Username()
 530+	}
 531+
 532 	// If the user is an admin, they have admin access.
 533 	if user != nil && user.IsAdmin() {
 534 		return access.AdminAccess
 535@@ -58,24 +86,6 @@ func (d *Backend) AccessLevel(ctx context.Context, repo string, username string)
 536 	return anon
 537 }
 538 
 539-// AccessLevelByPublicKey returns the access level of a user's public key for a repository.
 540-//
 541-// It implements backend.Backend.
 542-func (d *Backend) AccessLevelByPublicKey(ctx context.Context, repo string, pk ssh.PublicKey) access.AccessLevel {
 543-	for _, k := range d.cfg.AdminKeys() {
 544-		if sshutils.KeysEqual(pk, k) {
 545-			return access.AdminAccess
 546-		}
 547-	}
 548-
 549-	user, _ := d.UserByPublicKey(ctx, pk)
 550-	if user != nil {
 551-		return d.AccessLevel(ctx, repo, user.Username())
 552-	}
 553-
 554-	return d.AccessLevel(ctx, repo, "")
 555-}
 556-
 557 // User finds a user by username.
 558 //
 559 // It implements backend.Backend.
 560@@ -273,17 +283,22 @@ type user struct {
 561 
 562 var _ proto.User = (*user)(nil)
 563 
 564-// IsAdmin implements store.User
 565+// IsAdmin implements proto.User
 566 func (u *user) IsAdmin() bool {
 567 	return u.user.Admin
 568 }
 569 
 570-// PublicKeys implements store.User
 571+// PublicKeys implements proto.User
 572 func (u *user) PublicKeys() []ssh.PublicKey {
 573 	return u.publicKeys
 574 }
 575 
 576-// Username implements store.User
 577+// Username implements proto.User
 578 func (u *user) Username() string {
 579 	return u.user.Username
 580 }
 581+
 582+// ID implements proto.User.
 583+func (u *user) ID() int64 {
 584+	return u.user.ID
 585+}
 586diff --git a/server/daemon/daemon_test.go b/server/daemon/daemon_test.go
 587index c11ddefb5648ad306b0489aa0d24a5d136b08b26..f1ad44caaa23b0422c1389d4f9833d2dc80a54e7 100644
 588--- a/server/daemon/daemon_test.go
 589+++ b/server/daemon/daemon_test.go
 590@@ -17,6 +17,8 @@ import (
 591 	"github.com/charmbracelet/soft-serve/server/db"
 592 	"github.com/charmbracelet/soft-serve/server/db/migrate"
 593 	"github.com/charmbracelet/soft-serve/server/git"
 594+	"github.com/charmbracelet/soft-serve/server/store"
 595+	"github.com/charmbracelet/soft-serve/server/store/database"
 596 	"github.com/charmbracelet/soft-serve/server/test"
 597 	"github.com/go-git/go-git/v5/plumbing/format/pktline"
 598 	_ "modernc.org/sqlite" // sqlite driver
 599@@ -41,15 +43,17 @@ func TestMain(m *testing.M) {
 600 		log.Fatal(err)
 601 	}
 602 	ctx = config.WithContext(ctx, cfg)
 603-	db, err := db.Open(ctx, cfg.DB.Driver, cfg.DB.DataSource)
 604+	dbx, err := db.Open(ctx, cfg.DB.Driver, cfg.DB.DataSource)
 605 	if err != nil {
 606 		log.Fatal(err)
 607 	}
 608-	defer db.Close() // nolint: errcheck
 609-	if err := migrate.Migrate(ctx, db); err != nil {
 610+	defer dbx.Close() // nolint: errcheck
 611+	if err := migrate.Migrate(ctx, dbx); err != nil {
 612 		log.Fatal(err)
 613 	}
 614-	be := backend.New(ctx, cfg, db)
 615+	datastore := database.New(ctx, dbx)
 616+	ctx = store.WithContext(ctx, datastore)
 617+	be := backend.New(ctx, cfg, dbx)
 618 	ctx = backend.WithContext(ctx, be)
 619 	d, err := NewGitDaemon(ctx)
 620 	if err != nil {
 621@@ -68,7 +72,7 @@ func TestMain(m *testing.M) {
 622 	os.Unsetenv("SOFT_SERVE_GIT_IDLE_TIMEOUT")
 623 	os.Unsetenv("SOFT_SERVE_GIT_LISTEN_ADDR")
 624 	_ = d.Close()
 625-	_ = db.Close()
 626+	_ = dbx.Close()
 627 	os.Exit(code)
 628 }
 629 
 630diff --git a/server/db/context.go b/server/db/context.go
 631index 17c70ee4978d8b3c97e0a05177d9cd30d82c3ae8..5e289d8df96a0941d4d594aaba3494f553465e24 100644
 632--- a/server/db/context.go
 633+++ b/server/db/context.go
 634@@ -2,11 +2,12 @@ package db
 635 
 636 import "context"
 637 
 638-var contextKey = struct{ string }{"db"}
 639+// ContextKey is the key used to store the database in the context.
 640+var ContextKey = struct{ string }{"db"}
 641 
 642 // FromContext returns the database from the context.
 643 func FromContext(ctx context.Context) *DB {
 644-	if db, ok := ctx.Value(contextKey).(*DB); ok {
 645+	if db, ok := ctx.Value(ContextKey).(*DB); ok {
 646 		return db
 647 	}
 648 	return nil
 649@@ -14,5 +15,5 @@ func FromContext(ctx context.Context) *DB {
 650 
 651 // WithContext returns a new context with the database.
 652 func WithContext(ctx context.Context, db *DB) context.Context {
 653-	return context.WithValue(ctx, contextKey, db)
 654+	return context.WithValue(ctx, ContextKey, db)
 655 }
 656diff --git a/server/db/handler.go b/server/db/handler.go
 657new file mode 100644
 658index 0000000000000000000000000000000000000000..981cadf21ef38275d7e27f6a2539e9b80f2e7a49
 659--- /dev/null
 660+++ b/server/db/handler.go
 661@@ -0,0 +1,25 @@
 662+package db
 663+
 664+import (
 665+	"context"
 666+	"database/sql"
 667+
 668+	"github.com/jmoiron/sqlx"
 669+)
 670+
 671+// Handler is a database handler.
 672+type Handler interface {
 673+	Rebind(string) string
 674+
 675+	Select(interface{}, string, ...interface{}) error
 676+	Get(interface{}, string, ...interface{}) error
 677+	Queryx(string, ...interface{}) (*sqlx.Rows, error)
 678+	QueryRowx(string, ...interface{}) *sqlx.Row
 679+	Exec(string, ...interface{}) (sql.Result, error)
 680+
 681+	SelectContext(context.Context, interface{}, string, ...interface{}) error
 682+	GetContext(context.Context, interface{}, string, ...interface{}) error
 683+	QueryxContext(context.Context, string, ...interface{}) (*sqlx.Rows, error)
 684+	QueryRowxContext(context.Context, string, ...interface{}) *sqlx.Row
 685+	ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
 686+}
 687diff --git a/server/db/migrate/0002_create_lfs_tables.go b/server/db/migrate/0002_create_lfs_tables.go
 688new file mode 100644
 689index 0000000000000000000000000000000000000000..8d4ace82c1f425cdf77e0a06059485589e1fba29
 690--- /dev/null
 691+++ b/server/db/migrate/0002_create_lfs_tables.go
 692@@ -0,0 +1,23 @@
 693+package migrate
 694+
 695+import (
 696+	"context"
 697+
 698+	"github.com/charmbracelet/soft-serve/server/db"
 699+)
 700+
 701+const (
 702+	createLFSTablesName    = "create lfs tables"
 703+	createLFSTablesVersion = 2
 704+)
 705+
 706+var createLFSTables = Migration{
 707+	Version: createLFSTablesVersion,
 708+	Name:    createLFSTablesName,
 709+	Migrate: func(ctx context.Context, tx *db.Tx) error {
 710+		return migrateUp(ctx, tx, createLFSTablesVersion, createLFSTablesName)
 711+	},
 712+	Rollback: func(ctx context.Context, tx *db.Tx) error {
 713+		return migrateDown(ctx, tx, createLFSTablesVersion, createLFSTablesName)
 714+	},
 715+}
 716diff --git a/server/db/migrate/0002_create_lfs_tables_postgres.down.sql b/server/db/migrate/0002_create_lfs_tables_postgres.down.sql
 717new file mode 100644
 718index 0000000000000000000000000000000000000000..bae6ea0cd13f5ccf90114fb2e05146bb822a6658
 719--- /dev/null
 720+++ b/server/db/migrate/0002_create_lfs_tables_postgres.down.sql
 721@@ -0,0 +1,2 @@
 722+DROP TABLE IF EXISTS lfs_locks;
 723+DROP TABLE IF EXISTS lfs_objects;
 724diff --git a/server/db/migrate/0002_create_lfs_tables_postgres.up.sql b/server/db/migrate/0002_create_lfs_tables_postgres.up.sql
 725new file mode 100644
 726index 0000000000000000000000000000000000000000..fed48900f876e09a5777966c82bf088f41809821
 727--- /dev/null
 728+++ b/server/db/migrate/0002_create_lfs_tables_postgres.up.sql
 729@@ -0,0 +1,28 @@
 730+CREATE TABLE IF NOT EXISTS lfs_objects (
 731+  id SERIAL PRIMARY KEY,
 732+  oid TEXT NOT NULL,
 733+  size INTEGER NOT NULL,
 734+  repo_id INTEGER NOT NULL,
 735+  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
 736+  updated_at DATETIME NOT NULL,
 737+  UNIQUE (oid, repo_id),
 738+  CONSTRAINT repo_id_fk
 739+  FOREIGN KEY(repo_id) REFERENCES repos(id)
 740+  ON DELETE CASCADE
 741+  ON UPDATE CASCADE
 742+);
 743+
 744+CREATE TABLE IF NOT EXISTS lfs_locks (
 745+  id SERIAL PRIMARY KEY,
 746+  repo_id INTEGER NOT NULL,
 747+  user_id INTEGER NOT NULL,
 748+  path TEXT NOT NULL,
 749+  refname TEXT,
 750+  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
 751+  updated_at DATETIME NOT NULL,
 752+  UNIQUE (repo_id, path),
 753+  CONSTRAINT repo_id_fk
 754+  FOREIGN KEY(repo_id) REFERENCES repos(id)
 755+  ON DELETE CASCADE
 756+  ON UPDATE CASCADE
 757+);
 758diff --git a/server/db/migrate/0002_create_lfs_tables_sqlite.down.sql b/server/db/migrate/0002_create_lfs_tables_sqlite.down.sql
 759new file mode 100644
 760index 0000000000000000000000000000000000000000..bae6ea0cd13f5ccf90114fb2e05146bb822a6658
 761--- /dev/null
 762+++ b/server/db/migrate/0002_create_lfs_tables_sqlite.down.sql
 763@@ -0,0 +1,2 @@
 764+DROP TABLE IF EXISTS lfs_locks;
 765+DROP TABLE IF EXISTS lfs_objects;
 766diff --git a/server/db/migrate/0002_create_lfs_tables_sqlite.up.sql b/server/db/migrate/0002_create_lfs_tables_sqlite.up.sql
 767new file mode 100644
 768index 0000000000000000000000000000000000000000..0a43d6849340b93cb0783b1c560128ef005cb83c
 769--- /dev/null
 770+++ b/server/db/migrate/0002_create_lfs_tables_sqlite.up.sql
 771@@ -0,0 +1,28 @@
 772+CREATE TABLE IF NOT EXISTS lfs_objects (
 773+  id INTEGER PRIMARY KEY AUTOINCREMENT,
 774+  oid TEXT NOT NULL,
 775+  size INTEGER NOT NULL,
 776+  repo_id INTEGER NOT NULL,
 777+  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
 778+  updated_at DATETIME NOT NULL,
 779+  UNIQUE (oid, repo_id),
 780+  CONSTRAINT repo_id_fk
 781+  FOREIGN KEY(repo_id) REFERENCES repos(id)
 782+  ON DELETE CASCADE
 783+  ON UPDATE CASCADE
 784+);
 785+
 786+CREATE TABLE IF NOT EXISTS lfs_locks (
 787+  id INTEGER PRIMARY KEY AUTOINCREMENT,
 788+  repo_id INTEGER NOT NULL,
 789+  user_id INTEGER NOT NULL,
 790+  path TEXT NOT NULL,
 791+  refname TEXT,
 792+  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
 793+  updated_at DATETIME NOT NULL,
 794+  UNIQUE (repo_id, path),
 795+  CONSTRAINT repo_id_fk
 796+  FOREIGN KEY(repo_id) REFERENCES repos(id)
 797+  ON DELETE CASCADE
 798+  ON UPDATE CASCADE
 799+);
 800diff --git a/server/db/migrate/migrations.go b/server/db/migrate/migrations.go
 801index 88a9e434696ecdfe5ccb58d844d1f244d3bbfce5..8935ff05c8bff39197a5514f680a4ad91f85d61b 100644
 802--- a/server/db/migrate/migrations.go
 803+++ b/server/db/migrate/migrations.go
 804@@ -16,6 +16,7 @@ var sqls embed.FS
 805 // Keep this in order of execution, oldest to newest.
 806 var migrations = []Migration{
 807 	createTables,
 808+	createLFSTables,
 809 }
 810 
 811 func execMigration(ctx context.Context, tx *db.Tx, version int, name string, down bool) error {
 812diff --git a/server/db/models/lfs.go b/server/db/models/lfs.go
 813new file mode 100644
 814index 0000000000000000000000000000000000000000..f93ea55bad91ce1b795184a3e4e86754ecfd89aa
 815--- /dev/null
 816+++ b/server/db/models/lfs.go
 817@@ -0,0 +1,24 @@
 818+package models
 819+
 820+import "time"
 821+
 822+// LFSObject is a Git LFS object.
 823+type LFSObject struct {
 824+	ID        int64     `db:"id"`
 825+	Oid       string    `db:"oid"`
 826+	Size      int64     `db:"size"`
 827+	RepoID    int64     `db:"repo_id"`
 828+	CreatedAt time.Time `db:"created_at"`
 829+	UpdatedAt time.Time `db:"updated_at"`
 830+}
 831+
 832+// LFSLock is a Git LFS lock.
 833+type LFSLock struct {
 834+	ID        int64     `db:"id"`
 835+	Path      string    `db:"path"`
 836+	UserID    int64     `db:"user_id"`
 837+	RepoID    int64     `db:"repo_id"`
 838+	Refname   string    `db:"refname"`
 839+	CreatedAt time.Time `db:"created_at"`
 840+	UpdatedAt time.Time `db:"updated_at"`
 841+}
 842diff --git a/server/git/lfs.go b/server/git/lfs.go
 843new file mode 100644
 844index 0000000000000000000000000000000000000000..047c59bbe66ee5896be423bf1342e5c407136351
 845--- /dev/null
 846+++ b/server/git/lfs.go
 847@@ -0,0 +1,451 @@
 848+package git
 849+
 850+import (
 851+	"context"
 852+	"crypto/rand"
 853+	"errors"
 854+	"fmt"
 855+	"io"
 856+	"io/fs"
 857+	"path"
 858+	"path/filepath"
 859+	"strconv"
 860+	"time"
 861+
 862+	"github.com/charmbracelet/git-lfs-transfer/transfer"
 863+	"github.com/charmbracelet/log"
 864+	"github.com/charmbracelet/soft-serve/server/backend"
 865+	"github.com/charmbracelet/soft-serve/server/config"
 866+	"github.com/charmbracelet/soft-serve/server/db"
 867+	"github.com/charmbracelet/soft-serve/server/db/models"
 868+	"github.com/charmbracelet/soft-serve/server/proto"
 869+	"github.com/charmbracelet/soft-serve/server/storage"
 870+	"github.com/charmbracelet/soft-serve/server/store"
 871+	"github.com/charmbracelet/soft-serve/server/utils"
 872+	"github.com/rubyist/tracerx"
 873+)
 874+
 875+func init() {
 876+	// git-lfs-transfer uses tracerx for logging.
 877+	// use a custom key to avoid conflicts
 878+	// SOFT_SERVE_TRACE=1 to enable tracing git-lfs-transfer in soft-serve
 879+	tracerx.DefaultKey = "SOFT_SERVE"
 880+	tracerx.Prefix = "trace soft-serve-lfs-transfer: "
 881+}
 882+
 883+// lfsTransfer implements transfer.Backend.
 884+type lfsTransfer struct {
 885+	ctx     context.Context
 886+	cfg     *config.Config
 887+	dbx     *db.DB
 888+	store   store.Store
 889+	logger  *log.Logger
 890+	storage storage.Storage
 891+	repo    proto.Repository
 892+}
 893+
 894+var _ transfer.Backend = &lfsTransfer{}
 895+
 896+// LFSTransfer is a Git LFS transfer service handler.
 897+// ctx is expected to have proto.User, *backend.Backend, *log.Logger,
 898+// *config.Config, *db.DB, and store.Store.
 899+// The first arg in cmd.Args should be the repo path.
 900+// The second arg in cmd.Args should be the LFS operation (download or upload).
 901+func LFSTransfer(ctx context.Context, cmd ServiceCommand) error {
 902+	if len(cmd.Args) < 2 {
 903+		return errors.New("missing args")
 904+	}
 905+
 906+	logger := log.FromContext(ctx).WithPrefix("lfs-transfer")
 907+	handler := transfer.NewPktline(cmd.Stdin, cmd.Stdout)
 908+	be := backend.FromContext(ctx)
 909+	repoName := cmd.Args[0]
 910+	repoName = utils.SanitizeRepo(repoName)
 911+	op := cmd.Args[1]
 912+
 913+	repo, err := be.Repository(ctx, repoName)
 914+	if err != nil {
 915+		logger.Errorf("error getting repo: %v", err)
 916+		return err
 917+	}
 918+
 919+	ctx = context.WithValue(ctx, proto.ContextKeyRepository, repo)
 920+
 921+	// Advertise capabilities.
 922+	for _, cap := range []string{
 923+		"version=1",
 924+		"locking",
 925+	} {
 926+		if err := handler.WritePacketText(cap); err != nil {
 927+			logger.Errorf("error sending capability: %s: %v", cap, err)
 928+			return err
 929+		}
 930+	}
 931+
 932+	if err := handler.WriteFlush(); err != nil {
 933+		logger.Error("error sending flush", "err", err)
 934+		return err
 935+	}
 936+
 937+	cfg := config.FromContext(ctx)
 938+	processor := transfer.NewProcessor(handler, &lfsTransfer{
 939+		ctx:     ctx,
 940+		cfg:     cfg,
 941+		dbx:     db.FromContext(ctx),
 942+		store:   store.FromContext(ctx),
 943+		logger:  logger,
 944+		storage: storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs")),
 945+		repo:    repo,
 946+	})
 947diff --git a/server/git/service.go b/server/git/service.go
 948index b2cd6aa26b294a95d38d072e45054358be56bacd..51a53a0c887b56c4596ecaaaa90fed1617d1c161 100644
 949--- a/server/git/service.go
 950+++ b/server/git/service.go
 951@@ -23,6 +23,9 @@ const (
 952 	UploadArchiveService Service = "git-upload-archive"
 953 	// ReceivePackService is the receive-pack service.
 954 	ReceivePackService Service = "git-receive-pack"
 955+	// LFSTransferService is the LFS transfer service.
 956+	LFSTransferService Service = "git-lfs-transfer"
 957+	// TODO: add support for git-lfs-authenticate
 958 )
 959 
 960 // String returns the string representation of the service.
 961@@ -40,6 +43,8 @@ func (s Service) Handler(ctx context.Context, cmd ServiceCommand) error {
 962 	switch s {
 963 	case UploadPackService, UploadArchiveService, ReceivePackService:
 964 		return gitServiceHandler(ctx, s, cmd)
 965+	case LFSTransferService:
 966+		return LFSTransfer(ctx, cmd)
 967 	default:
 968 		return fmt.Errorf("unsupported service: %s", s)
 969 	}
 970@@ -57,6 +62,8 @@ func gitServiceHandler(ctx context.Context, svc Service, scmd ServiceCommand) er
 971 		"-c", "uploadpack.allowFilter=true",
 972 		// Enable push options
 973 		"-c", "receive.advertisePushOptions=true",
 974+		// Disable LFS filters
 975+		"-c", "filter.lfs.required=", "-c", "filter.lfs.smudge=", "-c", "filter.lfs.clean=",
 976 		svc.Name(),
 977 	}...)
 978 	if len(scmd.Args) > 0 {
 979diff --git a/server/lfs/basic_transfer.go b/server/lfs/basic_transfer.go
 980new file mode 100644
 981index 0000000000000000000000000000000000000000..609197c1035c176f6d31495d3a8cf57de7f5830c
 982--- /dev/null
 983+++ b/server/lfs/basic_transfer.go
 984@@ -0,0 +1,124 @@
 985+package lfs
 986+
 987+import (
 988+	"bytes"
 989+	"context"
 990+	"encoding/json"
 991+	"errors"
 992+	"fmt"
 993+	"io"
 994+	"net/http"
 995+
 996+	"github.com/charmbracelet/log"
 997+)
 998+
 999+// BasicTransferAdapter implements the "basic" adapter
1000+type BasicTransferAdapter struct {
1001+	client *http.Client
1002+}
1003+
1004+// Name returns the name of the adapter
1005+func (a *BasicTransferAdapter) Name() string {
1006+	return "basic"
1007+}
1008+
1009+// Download reads the download location and downloads the data
1010+func (a *BasicTransferAdapter) Download(ctx context.Context, _ Pointer, l *Link) (io.ReadCloser, error) {
1011+	resp, err := a.performRequest(ctx, "GET", l, nil, nil)
1012+	if err != nil {
1013+		return nil, err
1014+	}
1015+	return resp.Body, nil
1016+}
1017+
1018+// Upload sends the content to the LFS server
1019+func (a *BasicTransferAdapter) Upload(ctx context.Context, p Pointer, r io.Reader, l *Link) error {
1020+	res, err := a.performRequest(ctx, "PUT", l, r, func(req *http.Request) {
1021+		if len(req.Header.Get("Content-Type")) == 0 {
1022+			req.Header.Set("Content-Type", "application/octet-stream")
1023+		}
1024+
1025+		if req.Header.Get("Transfer-Encoding") == "chunked" {
1026+			req.TransferEncoding = []string{"chunked"}
1027+		}
1028+
1029+		req.ContentLength = p.Size
1030+	})
1031+	if err != nil {
1032+		return err
1033+	}
1034+	return res.Body.Close()
1035+}
1036+
1037+// Verify calls the verify handler on the LFS server
1038+func (a *BasicTransferAdapter) Verify(ctx context.Context, p Pointer, l *Link) error {
1039+	logger := log.FromContext(ctx).WithPrefix("lfs")
1040+	b, err := json.Marshal(p)
1041+	if err != nil {
1042+		logger.Errorf("Error encoding json: %v", err)
1043+		return err
1044+	}
1045+
1046+	res, err := a.performRequest(ctx, "POST", l, bytes.NewReader(b), func(req *http.Request) {
1047+		req.Header.Set("Content-Type", MediaType)
1048+	})
1049+	if err != nil {
1050+		return err
1051+	}
1052+	return res.Body.Close()
1053+}
1054+
1055+func (a *BasicTransferAdapter) performRequest(ctx context.Context, method string, l *Link, body io.Reader, callback func(*http.Request)) (*http.Response, error) {
1056+	logger := log.FromContext(ctx).WithPrefix("lfs")
1057+	logger.Debugf("Calling: %s %s", method, l.Href)
1058+
1059+	req, err := http.NewRequestWithContext(ctx, method, l.Href, body)
1060+	if err != nil {
1061+		logger.Errorf("Error creating request: %v", err)
1062+		return nil, err
1063+	}
1064+	for key, value := range l.Header {
1065+		req.Header.Set(key, value)
1066+	}
1067+	req.Header.Set("Accept", MediaType)
1068+
1069+	if callback != nil {
1070+		callback(req)
1071+	}
1072+
1073+	res, err := a.client.Do(req)
1074+	if err != nil {
1075+		select {
1076+		case <-ctx.Done():
1077+			return res, ctx.Err()
1078+		default:
1079+		}
1080+		logger.Errorf("Error while processing request: %v", err)
1081+		return res, err
1082+	}
1083+
1084diff --git a/server/lfs/client.go b/server/lfs/client.go
1085new file mode 100644
1086index 0000000000000000000000000000000000000000..9cc9da0a15a115f82373fa8da50e8da2a504b1dc
1087--- /dev/null
1088+++ b/server/lfs/client.go
1089@@ -0,0 +1,27 @@
1090+package lfs
1091+
1092+import (
1093+	"context"
1094+	"io"
1095+)
1096+
1097+// DownloadCallback gets called for every requested LFS object to process its content
1098+type DownloadCallback func(p Pointer, content io.ReadCloser, objectError error) error
1099+
1100+// UploadCallback gets called for every requested LFS object to provide its content
1101+type UploadCallback func(p Pointer, objectError error) (io.ReadCloser, error)
1102+
1103+// Client is a Git LFS client to communicate with a LFS source API.
1104+type Client interface {
1105+	Download(ctx context.Context, objects []Pointer, callback DownloadCallback) error
1106+	Upload(ctx context.Context, objects []Pointer, callback UploadCallback) error
1107+}
1108+
1109+// NewClient returns a new Git LFS client.
1110+func NewClient(e Endpoint) Client {
1111+	if e.Scheme == "http" || e.Scheme == "https" {
1112+		return newHTTPClient(e)
1113+	}
1114+	// TODO: support ssh client
1115+	return nil
1116+}
1117diff --git a/server/lfs/common.go b/server/lfs/common.go
1118new file mode 100644
1119index 0000000000000000000000000000000000000000..1bd2473068ab09f69b829ce5b292c50d9dd08097
1120--- /dev/null
1121+++ b/server/lfs/common.go
1122@@ -0,0 +1,88 @@
1123+package lfs
1124+
1125+import "time"
1126+
1127+const (
1128+	// MediaType contains the media type for LFS server requests.
1129+	MediaType = "application/vnd.git-lfs+json"
1130+
1131+	// OperationDownload is the operation name for a download request.
1132+	OperationDownload = "download"
1133+
1134+	// OperationUpload is the operation name for an upload request.
1135+	OperationUpload = "upload"
1136+
1137+	// ActionDownload is the action name for a download request.
1138+	ActionDownload = OperationDownload
1139+
1140+	// ActionUpload is the action name for an upload request.
1141+	ActionUpload = OperationUpload
1142+
1143+	// ActionVerify is the action name for a verify request.
1144+	ActionVerify = "verify"
1145+)
1146+
1147+// Pointer contains LFS pointer data
1148+type Pointer struct {
1149+	Oid  string `json:"oid"`
1150+	Size int64  `json:"size"`
1151+}
1152+
1153+// PointerBlob associates a Git blob with a Pointer.
1154+type PointerBlob struct {
1155+	Hash string
1156+	Pointer
1157+}
1158+
1159+// ErrorResponse describes the error to the client.
1160+type ErrorResponse struct {
1161+	Message          string `json:"message,omitempty"`
1162+	DocumentationURL string `json:"documentation_url,omitempty"`
1163+	RequestID        string `json:"request_id,omitempty"`
1164+}
1165+
1166+// BatchResponse contains multiple object metadata Representation structures
1167+// for use with the batch API.
1168+// https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md#successful-responses
1169+type BatchResponse struct {
1170+	Transfer string            `json:"transfer,omitempty"`
1171+	Objects  []*ObjectResponse `json:"objects"`
1172+	HashAlgo string            `json:"hash_algo,omitempty"`
1173+}
1174+
1175+// ObjectResponse is object metadata as seen by clients of the LFS server.
1176+type ObjectResponse struct {
1177+	Pointer
1178+	Actions map[string]*Link `json:"actions,omitempty"`
1179+	Error   *ObjectError     `json:"error,omitempty"`
1180+}
1181+
1182+// Link provides a structure with information about how to access a object.
1183+type Link struct {
1184+	Href      string            `json:"href"`
1185+	Header    map[string]string `json:"header,omitempty"`
1186+	ExpiresAt *time.Time        `json:"expires_at,omitempty"`
1187+	ExpiresIn *time.Duration    `json:"expires_in,omitempty"`
1188+}
1189+
1190+// ObjectError defines the JSON structure returned to the client in case of an error.
1191+type ObjectError struct {
1192+	Code    int    `json:"code"`
1193+	Message string `json:"message"`
1194+}
1195+
1196+// BatchRequest contains multiple requests processed in one batch operation.
1197+// https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md#requests
1198+type BatchRequest struct {
1199+	Operation string     `json:"operation"`
1200+	Transfers []string   `json:"transfers,omitempty"`
1201+	Ref       *Reference `json:"ref,omitempty"`
1202+	Objects   []Pointer  `json:"objects"`
1203+	HashAlgo  string     `json:"hash_algo,omitempty"`
1204+}
1205+
1206+// Reference contains a git reference.
1207+// https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md#ref-property
1208+type Reference struct {
1209+	Name string `json:"name"`
1210+}
1211diff --git a/server/lfs/endpoint.go b/server/lfs/endpoint.go
1212new file mode 100644
1213index 0000000000000000000000000000000000000000..53e89e1703649fa137c017c72ddb1273c9b66026
1214--- /dev/null
1215+++ b/server/lfs/endpoint.go
1216@@ -0,0 +1,70 @@
1217+package lfs
1218+
1219+import (
1220+	"fmt"
1221+	"net/url"
1222+	"strings"
1223+)
1224+
1225+// Endpoint is a Git LFS endpoint.
1226+type Endpoint = *url.URL
1227+
1228+// NewEndpoint returns a new Git LFS endpoint.
1229+func NewEndpoint(rawurl string) (Endpoint, error) {
1230+	u, err := url.Parse(rawurl)
1231+	if err != nil {
1232+		e, err := endpointFromBareSSH(rawurl)
1233+		if err != nil {
1234+			return nil, err
1235+		}
1236+		u = e
1237+	}
1238+
1239+	u.Path = strings.TrimSuffix(u.Path, "/")
1240+
1241+	switch u.Scheme {
1242+	case "git":
1243+		// Use https for git:// URLs and strip the port if it exists.
1244+		u.Scheme = "https"
1245+		if u.Port() != "" {
1246+			u.Host = u.Hostname()
1247+		}
1248+		fallthrough
1249+	case "http", "https":
1250+		if strings.HasSuffix(u.Path, ".git") {
1251+			u.Path += "/info/lfs"
1252+		} else {
1253+			u.Path += ".git/info/lfs"
1254+		}
1255+	case "ssh", "git+ssh", "ssh+git":
1256+	default:
1257+		return nil, fmt.Errorf("unknown url: %s", rawurl)
1258+	}
1259+
1260+	return u, nil
1261+}
1262+
1263+// endpointFromBareSSH creates a new endpoint from a bare ssh repo.
1264+//
1265+//	user@host.com:path/to/repo.git or
1266+//	[user@host.com:port]:path/to/repo.git
1267+func endpointFromBareSSH(rawurl string) (*url.URL, error) {
1268+	parts := strings.Split(rawurl, ":")
1269+	partsLen := len(parts)
1270+	if partsLen < 2 {
1271+		return url.Parse(rawurl)
1272+	}
1273+
1274+	// Treat presence of ':' as a bare URL
1275+	var newPath string
1276+	if len(parts) > 2 { // port included; really should only ever be 3 parts
1277+		// Correctly handle [host:port]:path URLs
1278+		parts[0] = strings.TrimPrefix(parts[0], "[")
1279+		parts[1] = strings.TrimSuffix(parts[1], "]")
1280+		newPath = fmt.Sprintf("%v:%v", parts[0], strings.Join(parts[1:], "/"))
1281+	} else {
1282+		newPath = strings.Join(parts, "/")
1283+	}
1284+	newrawurl := fmt.Sprintf("ssh://%v", newPath)
1285+	return url.Parse(newrawurl)
1286+}
1287diff --git a/server/lfs/http_client.go b/server/lfs/http_client.go
1288new file mode 100644
1289index 0000000000000000000000000000000000000000..a8b55031f083d1fd5e4b9aaccd896fb50013f41c
1290--- /dev/null
1291+++ b/server/lfs/http_client.go
1292@@ -0,0 +1,196 @@
1293+package lfs
1294+
1295+import (
1296+	"bytes"
1297+	"context"
1298+	"encoding/json"
1299+	"errors"
1300+	"fmt"
1301+	"net/http"
1302+
1303+	"github.com/charmbracelet/log"
1304+)
1305+
1306+// httpClient is a Git LFS client to communicate with a LFS source API.
1307+type httpClient struct {
1308+	client    *http.Client
1309+	endpoint  Endpoint
1310+	transfers map[string]TransferAdapter
1311+}
1312+
1313+var _ Client = (*httpClient)(nil)
1314+
1315+// newHTTPClient returns a new Git LFS client.
1316+func newHTTPClient(endpoint Endpoint) *httpClient {
1317+	return &httpClient{
1318+		client:   http.DefaultClient,
1319+		endpoint: endpoint,
1320+		transfers: map[string]TransferAdapter{
1321+			TransferBasic: &BasicTransferAdapter{http.DefaultClient},
1322+		},
1323+	}
1324+}
1325+
1326+// Download implements Client.
1327+func (c *httpClient) Download(ctx context.Context, objects []Pointer, callback DownloadCallback) error {
1328+	return c.performOperation(ctx, objects, callback, nil)
1329+}
1330+
1331+// Upload implements Client.
1332+func (c *httpClient) Upload(ctx context.Context, objects []Pointer, callback UploadCallback) error {
1333+	return c.performOperation(ctx, objects, nil, callback)
1334+}
1335+
1336+func (c *httpClient) transferNames() []string {
1337+	names := make([]string, len(c.transfers))
1338+	i := 0
1339+	for name := range c.transfers {
1340+		names[i] = name
1341+		i++
1342+	}
1343+	return names
1344+}
1345+
1346+// batch performs a batch request to the LFS server.
1347+func (c *httpClient) batch(ctx context.Context, operation string, objects []Pointer) (*BatchResponse, error) {
1348+	logger := log.FromContext(ctx).WithPrefix("lfs")
1349+	url := fmt.Sprintf("%s/objects/batch", c.endpoint.String())
1350+
1351+	// TODO: support ref
1352+	request := &BatchRequest{operation, c.transferNames(), nil, objects, HashAlgorithmSHA256}
1353+
1354+	payload := new(bytes.Buffer)
1355+	err := json.NewEncoder(payload).Encode(request)
1356+	if err != nil {
1357+		logger.Errorf("Error encoding json: %v", err)
1358+		return nil, err
1359+	}
1360+
1361+	logger.Debugf("Calling: %s", url)
1362+
1363+	req, err := http.NewRequestWithContext(ctx, "POST", url, payload)
1364+	if err != nil {
1365+		logger.Errorf("Error creating request: %v", err)
1366+		return nil, err
1367+	}
1368+	req.Header.Set("Content-type", MediaType)
1369+	req.Header.Set("Accept", MediaType)
1370+
1371+	res, err := c.client.Do(req)
1372+	if err != nil {
1373+		select {
1374+		case <-ctx.Done():
1375+			return nil, ctx.Err()
1376+		default:
1377+		}
1378+		logger.Errorf("Error while processing request: %v", err)
1379+		return nil, err
1380+	}
1381+	defer res.Body.Close() // nolint: errcheck
1382+
1383+	if res.StatusCode != http.StatusOK {
1384+		return nil, fmt.Errorf("Unexpected server response: %s", res.Status)
1385+	}
1386+
1387+	var response BatchResponse
1388+	err = json.NewDecoder(res.Body).Decode(&response)
1389+	if err != nil {
1390+		logger.Errorf("Error decoding json: %v", err)
1391+		return nil, err
1392diff --git a/server/lfs/pointer.go b/server/lfs/pointer.go
1393new file mode 100644
1394index 0000000000000000000000000000000000000000..b38d04ce59b67ac7e5021a0d8bd33a5cc04b077e
1395--- /dev/null
1396+++ b/server/lfs/pointer.go
1397@@ -0,0 +1,122 @@
1398+package lfs
1399+
1400+import (
1401+	"crypto/sha256"
1402+	"encoding/hex"
1403+	"errors"
1404+	"fmt"
1405+	"io"
1406+	"path"
1407+	"regexp"
1408+	"strconv"
1409+	"strings"
1410+)
1411+
1412+const (
1413+	blobSizeCutoff = 1024
1414+
1415+	// HashAlgorithmSHA256 is the hash algorithm used for Git LFS.
1416+	HashAlgorithmSHA256 = "sha256"
1417+
1418+	// MetaFileIdentifier is the string appearing at the first line of LFS pointer files.
1419+	// https://github.com/git-lfs/git-lfs/blob/master/docs/spec.md
1420+	MetaFileIdentifier = "version https://git-lfs.github.com/spec/v1"
1421+
1422+	// MetaFileOidPrefix appears in LFS pointer files on a line before the sha256 hash.
1423+	MetaFileOidPrefix = "oid " + HashAlgorithmSHA256 + ":"
1424+)
1425+
1426+var (
1427+	// ErrMissingPrefix occurs if the content lacks the LFS prefix
1428+	ErrMissingPrefix = errors.New("Content lacks the LFS prefix")
1429+
1430+	// ErrInvalidStructure occurs if the content has an invalid structure
1431+	ErrInvalidStructure = errors.New("Content has an invalid structure")
1432+
1433+	// ErrInvalidOIDFormat occurs if the oid has an invalid format
1434+	ErrInvalidOIDFormat = errors.New("OID has an invalid format")
1435+)
1436+
1437+// ReadPointer tries to read LFS pointer data from the reader
1438+func ReadPointer(reader io.Reader) (Pointer, error) {
1439+	buf := make([]byte, blobSizeCutoff)
1440+	n, err := io.ReadFull(reader, buf)
1441+	if err != nil && err != io.ErrUnexpectedEOF {
1442+		return Pointer{}, err
1443+	}
1444+	buf = buf[:n]
1445+
1446+	return ReadPointerFromBuffer(buf)
1447+}
1448+
1449+var oidPattern = regexp.MustCompile(`^[a-f\d]{64}$`)
1450+
1451+// ReadPointerFromBuffer will return a pointer if the provided byte slice is a pointer file or an error otherwise.
1452+func ReadPointerFromBuffer(buf []byte) (Pointer, error) {
1453+	var p Pointer
1454+
1455+	headString := string(buf)
1456+	if !strings.HasPrefix(headString, MetaFileIdentifier) {
1457+		return p, ErrMissingPrefix
1458+	}
1459+
1460+	splitLines := strings.Split(headString, "\n")
1461+	if len(splitLines) < 3 {
1462+		return p, ErrInvalidStructure
1463+	}
1464+
1465+	oid := strings.TrimPrefix(splitLines[1], MetaFileOidPrefix)
1466+	if len(oid) != 64 || !oidPattern.MatchString(oid) {
1467+		return p, ErrInvalidOIDFormat
1468+	}
1469+	size, err := strconv.ParseInt(strings.TrimPrefix(splitLines[2], "size "), 10, 64)
1470+	if err != nil {
1471+		return p, err
1472+	}
1473+
1474+	p.Oid = oid
1475+	p.Size = size
1476+
1477+	return p, nil
1478+}
1479+
1480+// IsValid checks if the pointer has a valid structure.
1481+// It doesn't check if the pointed-to-content exists.
1482+func (p Pointer) IsValid() bool {
1483+	if len(p.Oid) != 64 {
1484+		return false
1485+	}
1486+	if !oidPattern.MatchString(p.Oid) {
1487+		return false
1488+	}
1489+	if p.Size < 0 {
1490+		return false
1491+	}
1492+	return true
1493+}
1494+
1495+// String returns the string representation of the pointer
1496+// https://github.com/git-lfs/git-lfs/blob/main/docs/spec.md#the-pointer
1497diff --git a/server/lfs/scanner.go b/server/lfs/scanner.go
1498new file mode 100644
1499index 0000000000000000000000000000000000000000..1eba4a4d31dfc224728c977efa3f7c4533b95f56
1500--- /dev/null
1501+++ b/server/lfs/scanner.go
1502@@ -0,0 +1,210 @@
1503+package lfs
1504+
1505+import (
1506+	"bufio"
1507+	"bytes"
1508+	"context"
1509+	"fmt"
1510+	"io"
1511+	"strconv"
1512+	"strings"
1513+	"sync"
1514+
1515+	"github.com/charmbracelet/soft-serve/git"
1516+	gitm "github.com/gogs/git-module"
1517+)
1518+
1519+// SearchPointerBlobs scans the whole repository for LFS pointer files
1520+func SearchPointerBlobs(ctx context.Context, repo *git.Repository, pointerChan chan<- PointerBlob, errChan chan<- error) {
1521+	basePath := repo.Path
1522+
1523+	catFileCheckReader, catFileCheckWriter := io.Pipe()
1524+	shasToBatchReader, shasToBatchWriter := io.Pipe()
1525+	catFileBatchReader, catFileBatchWriter := io.Pipe()
1526+
1527+	wg := sync.WaitGroup{}
1528+	wg.Add(6)
1529+
1530+	// Create the go-routines in reverse order.
1531+
1532+	// 4. Take the output of cat-file --batch and check if each file in turn
1533+	// to see if they're pointers to files in the LFS store
1534+	go createPointerResultsFromCatFileBatch(ctx, catFileBatchReader, &wg, pointerChan)
1535+
1536+	// 3. Take the shas of the blobs and batch read them
1537+	go catFileBatch(ctx, shasToBatchReader, catFileBatchWriter, &wg, basePath)
1538+
1539+	// 2. From the provided objects restrict to blobs <=1k
1540+	go blobsLessThan1024FromCatFileBatchCheck(catFileCheckReader, shasToBatchWriter, &wg)
1541+
1542+	// 1. Run batch-check on all objects in the repository
1543+	revListReader, revListWriter := io.Pipe()
1544+	shasToCheckReader, shasToCheckWriter := io.Pipe()
1545+	go catFileBatchCheck(ctx, shasToCheckReader, catFileCheckWriter, &wg, basePath)
1546+	go blobsFromRevListObjects(revListReader, shasToCheckWriter, &wg)
1547+	go revListAllObjects(ctx, revListWriter, &wg, basePath, errChan)
1548+	wg.Wait()
1549+
1550+	close(pointerChan)
1551+	close(errChan)
1552+}
1553+
1554+func createPointerResultsFromCatFileBatch(ctx context.Context, catFileBatchReader *io.PipeReader, wg *sync.WaitGroup, pointerChan chan<- PointerBlob) {
1555+	defer wg.Done()
1556+	defer catFileBatchReader.Close() // nolint: errcheck
1557+
1558+	bufferedReader := bufio.NewReader(catFileBatchReader)
1559+	buf := make([]byte, 1025)
1560+
1561+loop:
1562+	for {
1563+		select {
1564+		case <-ctx.Done():
1565+			break loop
1566+		default:
1567+		}
1568+
1569+		// File descriptor line: sha
1570+		sha, err := bufferedReader.ReadString(' ')
1571+		if err != nil {
1572+			_ = catFileBatchReader.CloseWithError(err)
1573+			break
1574+		}
1575+		sha = strings.TrimSpace(sha)
1576+		// Throw away the blob
1577+		if _, err := bufferedReader.ReadString(' '); err != nil {
1578+			_ = catFileBatchReader.CloseWithError(err)
1579+			break
1580+		}
1581+		sizeStr, err := bufferedReader.ReadString('\n')
1582+		if err != nil {
1583+			_ = catFileBatchReader.CloseWithError(err)
1584+			break
1585+		}
1586+		size, err := strconv.Atoi(sizeStr[:len(sizeStr)-1])
1587+		if err != nil {
1588+			_ = catFileBatchReader.CloseWithError(err)
1589+			break
1590+		}
1591+		pointerBuf := buf[:size+1]
1592+		if _, err := io.ReadFull(bufferedReader, pointerBuf); err != nil {
1593+			_ = catFileBatchReader.CloseWithError(err)
1594+			break
1595+		}
1596+		pointerBuf = pointerBuf[:size]
1597+		// Now we need to check if the pointerBuf is an LFS pointer
1598+		pointer, _ := ReadPointerFromBuffer(pointerBuf)
1599+		if !pointer.IsValid() {
1600+			continue
1601+		}
1602diff --git a/server/lfs/ssh_client.go b/server/lfs/ssh_client.go
1603new file mode 100644
1604index 0000000000000000000000000000000000000000..ba3e2471078f11c9a38fecb1d4511b5f754e3e3e
1605--- /dev/null
1606+++ b/server/lfs/ssh_client.go
1607@@ -0,0 +1,3 @@
1608+package lfs
1609+
1610+// TODO: implement Git LFS SSH client.
1611diff --git a/server/lfs/transfer.go b/server/lfs/transfer.go
1612new file mode 100644
1613index 0000000000000000000000000000000000000000..478568836acf352bf5556532ce9b9e95479c1003
1614--- /dev/null
1615+++ b/server/lfs/transfer.go
1616@@ -0,0 +1,17 @@
1617+package lfs
1618+
1619+import (
1620+	"context"
1621+	"io"
1622+)
1623+
1624+// TransferBasic is the name of the Git LFS basic transfer protocol.
1625+const TransferBasic = "basic"
1626+
1627+// TransferAdapter represents an adapter for downloading/uploading LFS objects
1628+type TransferAdapter interface {
1629+	Name() string
1630+	Download(ctx context.Context, p Pointer, l *Link) (io.ReadCloser, error)
1631+	Upload(ctx context.Context, p Pointer, r io.Reader, l *Link) error
1632+	Verify(ctx context.Context, p Pointer, l *Link) error
1633+}
1634diff --git a/server/proto/repo.go b/server/proto/repo.go
1635index 68d88741bce6cadc07ba2c4b685e364c53d03ff6..e721d44a2b115165d13502992a04b8287a1a7b26 100644
1636--- a/server/proto/repo.go
1637+++ b/server/proto/repo.go
1638@@ -6,8 +6,13 @@ import (
1639 	"github.com/charmbracelet/soft-serve/git"
1640 )
1641 
1642+// ContextKeyRepository is the context key for the repository.
1643+var ContextKeyRepository = &struct{ string }{"repository"}
1644+
1645 // Repository is a Git repository interface.
1646 type Repository interface {
1647+	// ID returns the repository's ID.
1648+	ID() int64
1649 	// Name returns the repository's name.
1650 	Name() string
1651 	// ProjectName returns the repository's project name.
1652diff --git a/server/proto/user.go b/server/proto/user.go
1653index 6276a14b7fe13497a3b802dcccca677eebd19c97..f8fd65a1479ccbd9736e9c4af353b303a74402eb 100644
1654--- a/server/proto/user.go
1655+++ b/server/proto/user.go
1656@@ -2,8 +2,13 @@ package proto
1657 
1658 import "golang.org/x/crypto/ssh"
1659 
1660+// ContextKeyUser is the context key for the user.
1661+var ContextKeyUser = &struct{ string }{"user"}
1662+
1663 // User is an interface representing a user.
1664 type User interface {
1665+	// ID returns the user's ID.
1666+	ID() int64
1667 	// Username returns the user's username.
1668 	Username() string
1669 	// IsAdmin returns whether the user is an admin.
1670diff --git a/server/ssh/cmd/delete.go b/server/ssh/cmd/delete.go
1671index 02dff775d469753744f051c89c2887ce3bb3d367..b719ff53721f6cdd4a4cce0d3a4aab17ff8c99d5 100644
1672--- a/server/ssh/cmd/delete.go
1673+++ b/server/ssh/cmd/delete.go
1674@@ -6,6 +6,8 @@ import (
1675 )
1676 
1677 func deleteCommand() *cobra.Command {
1678+	var lfs bool
1679+
1680 	cmd := &cobra.Command{
1681 		Use:               "delete REPOSITORY",
1682 		Aliases:           []string{"del", "remove", "rm"},
1683@@ -17,8 +19,11 @@ func deleteCommand() *cobra.Command {
1684 			be := backend.FromContext(ctx)
1685 			name := args[0]
1686 
1687-			return be.DeleteRepository(ctx, name)
1688+			return be.DeleteRepository(ctx, name, lfs)
1689 		},
1690 	}
1691+
1692+	cmd.Flags().BoolVarP(&lfs, "lfs", "", false, "Delete LFS objects")
1693+
1694 	return cmd
1695 }
1696diff --git a/server/ssh/git.go b/server/ssh/git.go
1697index 051dc25921ad137d89dacdc44501ab8e70637369..d2a030f9ded5d6ef644a363b1c84fcb1e56bd835 100644
1698--- a/server/ssh/git.go
1699+++ b/server/ssh/git.go
1700@@ -10,6 +10,7 @@ import (
1701 	"github.com/charmbracelet/soft-serve/server/backend"
1702 	"github.com/charmbracelet/soft-serve/server/config"
1703 	"github.com/charmbracelet/soft-serve/server/git"
1704+	"github.com/charmbracelet/soft-serve/server/lfs"
1705 	"github.com/charmbracelet/soft-serve/server/proto"
1706 	"github.com/charmbracelet/soft-serve/server/sshutils"
1707 	"github.com/charmbracelet/soft-serve/server/utils"
1708@@ -24,11 +25,17 @@ func handleGit(s ssh.Session) {
1709 	cmdLine := s.Command()
1710 	start := time.Now()
1711 
1712+	var username string
1713+	user := ctx.Value(proto.ContextKeyUser).(proto.User)
1714+	if user != nil {
1715+		username = user.Username()
1716+	}
1717+
1718 	// repo should be in the form of "repo.git"
1719 	name := utils.SanitizeRepo(cmdLine[1])
1720 	pk := s.PublicKey()
1721 	ak := sshutils.MarshalAuthorizedKey(pk)
1722-	accessLevel := be.AccessLevelByPublicKey(ctx, name, pk)
1723+	accessLevel := be.AccessLevelForUser(ctx, name, user)
1724 	// git bare repositories should end in ".git"
1725 	// https://git-scm.com/docs/gitrepository-layout
1726 	repo := name + ".git"
1727@@ -43,7 +50,7 @@ func handleGit(s ssh.Session) {
1728 		"SOFT_SERVE_REPO_NAME=" + name,
1729 		"SOFT_SERVE_REPO_PATH=" + filepath.Join(reposDir, repo),
1730 		"SOFT_SERVE_PUBLIC_KEY=" + ak,
1731-		"SOFT_SERVE_USERNAME=" + s.User(),
1732+		"SOFT_SERVE_USERNAME=" + username,
1733 		"SOFT_SERVE_LOG_PATH=" + filepath.Join(cfg.DataPath, "log", "hooks.log"),
1734 	}
1735 
1736@@ -120,5 +127,27 @@ func handleGit(s ssh.Session) {
1737 			logger.Error("git middleware", "err", err)
1738 			sshFatal(s, git.ErrSystemMalfunction)
1739 		}
1740+	case git.LFSTransferService:
1741+		if accessLevel < access.ReadWriteAccess {
1742+			sshFatal(s, git.ErrNotAuthed)
1743+			return
1744+		}
1745+
1746+		if len(cmdLine) != 3 ||
1747+			(cmdLine[2] != lfs.OperationDownload && cmdLine[2] != lfs.OperationUpload) {
1748+			sshFatal(s, git.ErrInvalidRequest)
1749+			return
1750+		}
1751+
1752+		cmd.Args = []string{
1753+			name,
1754+			cmdLine[2],
1755+		}
1756+
1757+		if err := git.LFSTransfer(ctx, cmd); err != nil {
1758+			logger.Error("git middleware", "err", err)
1759+			sshFatal(s, git.ErrSystemMalfunction)
1760+			return
1761+		}
1762 	}
1763 }
1764diff --git a/server/ssh/middleware.go b/server/ssh/middleware.go
1765index 7fe749185220ab637efd089149ccd7fa78170942..909c499d4ddffa5f2454c10185e7057e9283bb74 100644
1766--- a/server/ssh/middleware.go
1767+++ b/server/ssh/middleware.go
1768@@ -6,14 +6,18 @@ import (
1769 	"github.com/charmbracelet/log"
1770 	"github.com/charmbracelet/soft-serve/server/backend"
1771 	"github.com/charmbracelet/soft-serve/server/config"
1772+	"github.com/charmbracelet/soft-serve/server/db"
1773+	"github.com/charmbracelet/soft-serve/server/store"
1774 	"github.com/charmbracelet/ssh"
1775 )
1776 
1777 // ContextMiddleware adds the config, backend, and logger to the session context.
1778-func ContextMiddleware(cfg *config.Config, be *backend.Backend, logger *log.Logger) func(ssh.Handler) ssh.Handler {
1779+func ContextMiddleware(cfg *config.Config, dbx *db.DB, datastore store.Store, be *backend.Backend, logger *log.Logger) func(ssh.Handler) ssh.Handler {
1780 	return func(sh ssh.Handler) ssh.Handler {
1781 		return func(s ssh.Session) {
1782 			s.Context().SetValue(config.ContextKey, cfg)
1783+			s.Context().SetValue(db.ContextKey, dbx)
1784+			s.Context().SetValue(store.ContextKey, datastore)
1785 			s.Context().SetValue(backend.ContextKey, be)
1786 			s.Context().SetValue(log.ContextKey, logger.WithPrefix("ssh"))
1787 			sh(s)
1788diff --git a/server/ssh/session_test.go b/server/ssh/session_test.go
1789index 104c92ad14095a4b2d851c40e959c05137e2d5f8..17cbc5c08487e89dc3be91c596c68fda06fccf89 100644
1790--- a/server/ssh/session_test.go
1791+++ b/server/ssh/session_test.go
1792@@ -13,6 +13,8 @@ import (
1793 	"github.com/charmbracelet/soft-serve/server/config"
1794 	"github.com/charmbracelet/soft-serve/server/db"
1795 	"github.com/charmbracelet/soft-serve/server/db/migrate"
1796+	"github.com/charmbracelet/soft-serve/server/store"
1797+	"github.com/charmbracelet/soft-serve/server/store/database"
1798 	"github.com/charmbracelet/soft-serve/server/test"
1799 	"github.com/charmbracelet/ssh"
1800 	bm "github.com/charmbracelet/wish/bubbletea"
1801@@ -65,22 +67,24 @@ func setup(tb testing.TB) (*gossh.Session, func() error) {
1802 		log.Fatal(err)
1803 	}
1804 	ctx = config.WithContext(ctx, cfg)
1805-	db, err := db.Open(ctx, cfg.DB.Driver, cfg.DB.DataSource)
1806+	dbx, err := db.Open(ctx, cfg.DB.Driver, cfg.DB.DataSource)
1807 	if err != nil {
1808 		tb.Fatal(err)
1809 	}
1810-	if err := migrate.Migrate(ctx, db); err != nil {
1811+	if err := migrate.Migrate(ctx, dbx); err != nil {
1812 		tb.Fatal(err)
1813 	}
1814-	be := backend.New(ctx, cfg, db)
1815+	dbstore := database.New(ctx, dbx)
1816+	ctx = store.WithContext(ctx, dbstore)
1817+	be := backend.New(ctx, cfg, dbx)
1818 	ctx = backend.WithContext(ctx, be)
1819 	return testsession.New(tb, &ssh.Server{
1820-		Handler: ContextMiddleware(cfg, be, log.Default())(bm.MiddlewareWithProgramHandler(SessionHandler, termenv.ANSI256)(func(s ssh.Session) {
1821+		Handler: ContextMiddleware(cfg, dbx, dbstore, be, log.Default())(bm.MiddlewareWithProgramHandler(SessionHandler, termenv.ANSI256)(func(s ssh.Session) {
1822 			_, _, active := s.Pty()
1823 			if !active {
1824 				os.Exit(1)
1825 			}
1826 			s.Exit(0)
1827 		})),
1828-	}, nil), db.Close
1829+	}, nil), dbx.Close
1830 }
1831diff --git a/server/ssh/ssh.go b/server/ssh/ssh.go
1832index 39f300e58fff5352243fefdb8b85c434582e0d96..fe6854507ac8fe6b53bf3c6a6a08b588c660ec8d 100644
1833--- a/server/ssh/ssh.go
1834+++ b/server/ssh/ssh.go
1835@@ -13,8 +13,11 @@ import (
1836 	"github.com/charmbracelet/soft-serve/server/access"
1837 	"github.com/charmbracelet/soft-serve/server/backend"
1838 	"github.com/charmbracelet/soft-serve/server/config"
1839+	"github.com/charmbracelet/soft-serve/server/db"
1840 	"github.com/charmbracelet/soft-serve/server/git"
1841+	"github.com/charmbracelet/soft-serve/server/proto"
1842 	"github.com/charmbracelet/soft-serve/server/sshutils"
1843+	"github.com/charmbracelet/soft-serve/server/store"
1844 	"github.com/charmbracelet/ssh"
1845 	"github.com/charmbracelet/wish"
1846 	bm "github.com/charmbracelet/wish/bubbletea"
1847@@ -104,6 +107,8 @@ type SSHServer struct { // nolint: revive
1848 func NewSSHServer(ctx context.Context) (*SSHServer, error) {
1849 	cfg := config.FromContext(ctx)
1850 	logger := log.FromContext(ctx).WithPrefix("ssh")
1851+	dbx := db.FromContext(ctx)
1852+	datastore := store.FromContext(ctx)
1853 	be := backend.FromContext(ctx)
1854 
1855 	var err error
1856@@ -122,7 +127,7 @@ func NewSSHServer(ctx context.Context) (*SSHServer, error) {
1857 			// CLI middleware.
1858 			CommandMiddleware,
1859 			// Context middleware.
1860-			ContextMiddleware(cfg, be, logger),
1861+			ContextMiddleware(cfg, dbx, datastore, be, logger),
1862 			// Logging middleware.
1863 			lm.MiddlewareWithLogger(
1864 				&loggerAdapter{logger, log.DebugLevel},
1865@@ -191,7 +196,10 @@ func (s *SSHServer) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) (allowed
1866 		publicKeyCounter.WithLabelValues(strconv.FormatBool(*allowed)).Inc()
1867 	}(&allowed)
1868 
1869-	ac := s.be.AccessLevelByPublicKey(ctx, "", pk)
1870+	user, _ := s.be.UserByPublicKey(ctx, pk)
1871+	ctx.SetValue(proto.ContextKeyUser, user)
1872+
1873+	ac := s.be.AccessLevelForUser(ctx, "", user)
1874 	s.logger.Debugf("access level for %q: %s", ak, ac)
1875 	allowed = ac >= access.ReadWriteAccess
1876 	return
1877diff --git a/server/storage/local.go b/server/storage/local.go
1878new file mode 100644
1879index 0000000000000000000000000000000000000000..8a51157af6e9a796921225f142083a6243aa1720
1880--- /dev/null
1881+++ b/server/storage/local.go
1882@@ -0,0 +1,91 @@
1883+package storage
1884+
1885+import (
1886+	"errors"
1887+	"io"
1888+	"io/fs"
1889+	"os"
1890+	"path/filepath"
1891+	"strings"
1892+)
1893+
1894+// LocalStorage is a storage implementation that stores objects on the local
1895+// filesystem.
1896+type LocalStorage struct {
1897+	root string
1898+}
1899+
1900+var _ Storage = (*LocalStorage)(nil)
1901+
1902+// NewLocalStorage creates a new LocalStorage.
1903+func NewLocalStorage(root string) *LocalStorage {
1904+	return &LocalStorage{root: root}
1905+}
1906+
1907+// Delete implements Storage.
1908+func (l *LocalStorage) Delete(name string) error {
1909+	name = l.fixPath(name)
1910+	return os.Remove(name)
1911+}
1912+
1913+// Open implements Storage.
1914+func (l *LocalStorage) Open(name string) (Object, error) {
1915+	name = l.fixPath(name)
1916+	return os.Open(name)
1917+}
1918+
1919+// Stat implements Storage.
1920+func (l *LocalStorage) Stat(name string) (fs.FileInfo, error) {
1921+	name = l.fixPath(name)
1922+	return os.Stat(name)
1923+}
1924+
1925+// Put implements Storage.
1926+func (l *LocalStorage) Put(name string, r io.Reader) error {
1927+	name = l.fixPath(name)
1928+	if err := os.MkdirAll(filepath.Dir(name), os.ModePerm); err != nil {
1929+		return err
1930+	}
1931+
1932+	f, err := os.Create(name)
1933+	if err != nil {
1934+		return err
1935+	}
1936+	defer f.Close() // nolint: errcheck
1937+	_, err = io.Copy(f, r)
1938+	return err
1939+}
1940+
1941+// Exists implements Storage.
1942+func (l *LocalStorage) Exists(name string) (bool, error) {
1943+	name = l.fixPath(name)
1944+	_, err := os.Stat(name)
1945+	if err == nil {
1946+		return true, nil
1947+	}
1948+	if errors.Is(err, fs.ErrNotExist) {
1949+		return false, nil
1950+	}
1951+	return false, err
1952+}
1953+
1954+// Rename implements Storage.
1955+func (l *LocalStorage) Rename(oldName, newName string) error {
1956+	oldName = l.fixPath(oldName)
1957+	newName = l.fixPath(newName)
1958+	if err := os.MkdirAll(filepath.Dir(newName), os.ModePerm); err != nil {
1959+		return err
1960+	}
1961+
1962+	return os.Rename(oldName, newName)
1963+}
1964+
1965+// Replace all slashes with the OS-specific separator
1966+func (l LocalStorage) fixPath(path string) string {
1967+	path = strings.ReplaceAll(path, "/", string(os.PathSeparator))
1968+	if !filepath.IsAbs(path) {
1969+		return filepath.Join(l.root, path)
1970+	}
1971+
1972+	return path
1973+}
1974diff --git a/server/storage/storage.go b/server/storage/storage.go
1975new file mode 100644
1976index 0000000000000000000000000000000000000000..dc435dbbbd8b39b6a1e0ac69cc1269276fab32e6
1977--- /dev/null
1978+++ b/server/storage/storage.go
1979@@ -0,0 +1,23 @@
1980+package storage
1981+
1982+import (
1983+	"io"
1984+	"io/fs"
1985+)
1986+
1987+// Object is an interface for objects that can be stored.
1988+type Object interface {
1989+	io.Seeker
1990+	fs.File
1991+	Name() string
1992+}
1993+
1994+// Storage is an interface for storing and retrieving objects.
1995+type Storage interface {
1996+	Open(name string) (Object, error)
1997+	Stat(name string) (fs.FileInfo, error)
1998+	Put(name string, r io.Reader) error
1999+	Delete(name string) error
2000+	Exists(name string) (bool, error)
2001+	Rename(oldName, newName string) error
2002+}
2003diff --git a/server/store/context.go b/server/store/context.go
2004new file mode 100644
2005index 0000000000000000000000000000000000000000..938c7dca07457ce15448c9d513234b92b2c19082
2006--- /dev/null
2007+++ b/server/store/context.go
2008@@ -0,0 +1,20 @@
2009+package store
2010+
2011+import "context"
2012+
2013+// ContextKey is the store context key.
2014+var ContextKey = &struct{ string }{"store"}
2015+
2016+// FromContext returns the store from the given context.
2017+func FromContext(ctx context.Context) Store {
2018+	if s, ok := ctx.Value(ContextKey).(Store); ok {
2019+		return s
2020+	}
2021+
2022+	return nil
2023+}
2024+
2025+// WithContext returns a new context with the given store.
2026+func WithContext(ctx context.Context, s Store) context.Context {
2027+	return context.WithValue(ctx, ContextKey, s)
2028+}
2029diff --git a/server/store/database/collab.go b/server/store/database/collab.go
2030index 50424445e13ede0006e81d7e25aca255ff139ff9..e290593c2d6f5dbef4461daf147a72b9eaafb924 100644
2031--- a/server/store/database/collab.go
2032+++ b/server/store/database/collab.go
2033@@ -15,7 +15,7 @@ type collabStore struct{}
2034 var _ store.CollaboratorStore = (*collabStore)(nil)
2035 
2036 // AddCollabByUsernameAndRepo implements store.CollaboratorStore.
2037-func (*collabStore) AddCollabByUsernameAndRepo(ctx context.Context, tx *db.Tx, username string, repo string) error {
2038+func (*collabStore) AddCollabByUsernameAndRepo(ctx context.Context, tx db.Handler, username string, repo string) error {
2039 	username = strings.ToLower(username)
2040 	if err := utils.ValidateUsername(username); err != nil {
2041 		return err
2042@@ -38,7 +38,7 @@ func (*collabStore) AddCollabByUsernameAndRepo(ctx context.Context, tx *db.Tx, u
2043 }
2044 
2045 // GetCollabByUsernameAndRepo implements store.CollaboratorStore.
2046-func (*collabStore) GetCollabByUsernameAndRepo(ctx context.Context, tx *db.Tx, username string, repo string) (models.Collab, error) {
2047+func (*collabStore) GetCollabByUsernameAndRepo(ctx context.Context, tx db.Handler, username string, repo string) (models.Collab, error) {
2048 	var m models.Collab
2049 
2050 	username = strings.ToLower(username)
2051@@ -63,7 +63,7 @@ func (*collabStore) GetCollabByUsernameAndRepo(ctx context.Context, tx *db.Tx, u
2052 }
2053 
2054 // ListCollabsByRepo implements store.CollaboratorStore.
2055-func (*collabStore) ListCollabsByRepo(ctx context.Context, tx *db.Tx, repo string) ([]models.Collab, error) {
2056+func (*collabStore) ListCollabsByRepo(ctx context.Context, tx db.Handler, repo string) ([]models.Collab, error) {
2057 	var m []models.Collab
2058 
2059 	repo = utils.SanitizeRepo(repo)
2060@@ -82,7 +82,7 @@ func (*collabStore) ListCollabsByRepo(ctx context.Context, tx *db.Tx, repo strin
2061 }
2062 
2063 // ListCollabsByRepoAsUsers implements store.CollaboratorStore.
2064-func (*collabStore) ListCollabsByRepoAsUsers(ctx context.Context, tx *db.Tx, repo string) ([]models.User, error) {
2065+func (*collabStore) ListCollabsByRepoAsUsers(ctx context.Context, tx db.Handler, repo string) ([]models.User, error) {
2066 	var m []models.User
2067 
2068 	repo = utils.SanitizeRepo(repo)
2069@@ -102,7 +102,7 @@ func (*collabStore) ListCollabsByRepoAsUsers(ctx context.Context, tx *db.Tx, rep
2070 }
2071 
2072 // RemoveCollabByUsernameAndRepo implements store.CollaboratorStore.
2073-func (*collabStore) RemoveCollabByUsernameAndRepo(ctx context.Context, tx *db.Tx, username string, repo string) error {
2074+func (*collabStore) RemoveCollabByUsernameAndRepo(ctx context.Context, tx db.Handler, username string, repo string) error {
2075 	username = strings.ToLower(username)
2076 	if err := utils.ValidateUsername(username); err != nil {
2077 		return err
2078diff --git a/server/store/database/database.go b/server/store/database/database.go
2079index 4960f68537f5d18b6c2e56d636e5d2f634cd8bae..c63d70992eb4665e8dd1ecf6bf007c5eb4653c41 100644
2080--- a/server/store/database/database.go
2081+++ b/server/store/database/database.go
2082@@ -19,6 +19,7 @@ type datastore struct {
2083 	*repoStore
2084 	*userStore
2085 	*collabStore
2086+	*lfsStore
2087 }
2088 
2089 // New returns a new store.Store database.
2090@@ -36,6 +37,7 @@ func New(ctx context.Context, db *db.DB) store.Store {
2091 		repoStore:     &repoStore{},
2092 		userStore:     &userStore{},
2093 		collabStore:   &collabStore{},
2094+		lfsStore:      &lfsStore{},
2095 	}
2096 
2097 	return s
2098diff --git a/server/store/database/lfs.go b/server/store/database/lfs.go
2099new file mode 100644
2100index 0000000000000000000000000000000000000000..0233dec7a0ffc48da9bca2004938d4bccb987560
2101--- /dev/null
2102+++ b/server/store/database/lfs.go
2103@@ -0,0 +1,179 @@
2104+package database
2105+
2106+import (
2107+	"context"
2108+	"strconv"
2109+	"strings"
2110+
2111+	"github.com/charmbracelet/soft-serve/server/db"
2112+	"github.com/charmbracelet/soft-serve/server/db/models"
2113+	"github.com/charmbracelet/soft-serve/server/store"
2114+)
2115+
2116+type lfsStore struct{}
2117+
2118+var _ store.LFSStore = (*lfsStore)(nil)
2119+
2120+func sanitizePath(path string) string {
2121+	path = strings.TrimSpace(path)
2122+	path = strings.TrimPrefix(path, "/")
2123+	return path
2124+}
2125+
2126+// CreateLFSLockForUser implements store.LFSStore.
2127+func (*lfsStore) CreateLFSLockForUser(ctx context.Context, tx db.Handler, repoID int64, userID int64, path string, refname string) error {
2128+	path = sanitizePath(path)
2129+	query := tx.Rebind(`INSERT INTO lfs_locks (repo_id, user_id, path, refname, updated_at)
2130+		VALUES (
2131+			?,
2132+			?,
2133+			?,
2134+			?,
2135+			CURRENT_TIMESTAMP
2136+		);
2137+	`)
2138+	_, err := tx.ExecContext(ctx, query, repoID, userID, path, refname)
2139+	return db.WrapError(err)
2140+}
2141+
2142+// GetLFSLocks implements store.LFSStore.
2143+func (*lfsStore) GetLFSLocks(ctx context.Context, tx db.Handler, repoID int64) ([]models.LFSLock, error) {
2144+	var locks []models.LFSLock
2145+	query := tx.Rebind(`
2146+		SELECT *
2147+		FROM lfs_locks
2148+		WHERE repo_id = ?;
2149+	`)
2150+	err := tx.SelectContext(ctx, &locks, query, repoID)
2151+	return locks, db.WrapError(err)
2152+}
2153+
2154+// GetLFSLocksForUser implements store.LFSStore.
2155+func (*lfsStore) GetLFSLocksForUser(ctx context.Context, tx db.Handler, repoID int64, userID int64) ([]models.LFSLock, error) {
2156+	var locks []models.LFSLock
2157+	query := tx.Rebind(`
2158+		SELECT *
2159+		FROM lfs_locks
2160+		WHERE repo_id = ? AND user_id = ?;
2161+	`)
2162+	err := tx.SelectContext(ctx, &locks, query, repoID, userID)
2163+	return locks, db.WrapError(err)
2164+}
2165+
2166+// GetLFSLocksForPath implements store.LFSStore.
2167+func (*lfsStore) GetLFSLocksForPath(ctx context.Context, tx db.Handler, repoID int64, path string) ([]models.LFSLock, error) {
2168+	path = sanitizePath(path)
2169+	var locks []models.LFSLock
2170+	query := tx.Rebind(`
2171+		SELECT *
2172+		FROM lfs_locks
2173+		WHERE repo_id = ? AND path = ?;
2174+	`)
2175+	err := tx.SelectContext(ctx, &locks, query, repoID, path)
2176+	return locks, db.WrapError(err)
2177+}
2178+
2179+// GetLFSLockForUserPath implements store.LFSStore.
2180+func (*lfsStore) GetLFSLockForUserPath(ctx context.Context, tx db.Handler, repoID int64, userID int64, path string) (models.LFSLock, error) {
2181+	path = sanitizePath(path)
2182+	var lock models.LFSLock
2183+	query := tx.Rebind(`
2184+		SELECT *
2185+		FROM lfs_locks
2186+		WHERE repo_id = ? AND user_id = ? AND path = ?;
2187+	`)
2188+	err := tx.GetContext(ctx, &lock, query, repoID, userID, path)
2189+	return lock, db.WrapError(err)
2190+}
2191+
2192+// GetLFSLockByID implements store.LFSStore.
2193+func (*lfsStore) GetLFSLockByID(ctx context.Context, tx db.Handler, id string) (models.LFSLock, error) {
2194+	iid, err := strconv.Atoi(id)
2195+	if err != nil {
2196+		return models.LFSLock{}, err
2197+	}
2198+
2199+	var lock models.LFSLock
2200+	query := tx.Rebind(`
2201+		SELECT *
2202+		FROM lfs_locks
2203diff --git a/server/store/database/repo.go b/server/store/database/repo.go
2204index 76436b9bc4393cf4a146559a9efe43417add6feb..06ea92b492c32cfaf4c1020611daebf5ea7a8bbb 100644
2205--- a/server/store/database/repo.go
2206+++ b/server/store/database/repo.go
2207@@ -14,7 +14,7 @@ type repoStore struct{}
2208 var _ store.RepositoryStore = (*repoStore)(nil)
2209 
2210 // CreateRepo implements store.RepositoryStore.
2211-func (*repoStore) CreateRepo(ctx context.Context, tx *db.Tx, name string, projectName string, description string, isPrivate bool, isHidden bool, isMirror bool) error {
2212+func (*repoStore) CreateRepo(ctx context.Context, tx db.Handler, name string, projectName string, description string, isPrivate bool, isHidden bool, isMirror bool) error {
2213 	name = utils.SanitizeRepo(name)
2214 	query := tx.Rebind(`INSERT INTO repos (name, project_name, description, private, mirror, hidden, updated_at)
2215 			VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP);`)
2216@@ -24,7 +24,7 @@ func (*repoStore) CreateRepo(ctx context.Context, tx *db.Tx, name string, projec
2217 }
2218 
2219 // DeleteRepoByName implements store.RepositoryStore.
2220-func (*repoStore) DeleteRepoByName(ctx context.Context, tx *db.Tx, name string) error {
2221+func (*repoStore) DeleteRepoByName(ctx context.Context, tx db.Handler, name string) error {
2222 	name = utils.SanitizeRepo(name)
2223 	query := tx.Rebind("DELETE FROM repos WHERE name = ?;")
2224 	_, err := tx.ExecContext(ctx, query, name)
2225@@ -32,7 +32,7 @@ func (*repoStore) DeleteRepoByName(ctx context.Context, tx *db.Tx, name string)
2226 }
2227 
2228 // GetAllRepos implements store.RepositoryStore.
2229-func (*repoStore) GetAllRepos(ctx context.Context, tx *db.Tx) ([]models.Repo, error) {
2230+func (*repoStore) GetAllRepos(ctx context.Context, tx db.Handler) ([]models.Repo, error) {
2231 	var repos []models.Repo
2232 	query := tx.Rebind("SELECT * FROM repos;")
2233 	err := tx.SelectContext(ctx, &repos, query)
2234@@ -40,7 +40,7 @@ func (*repoStore) GetAllRepos(ctx context.Context, tx *db.Tx) ([]models.Repo, er
2235 }
2236 
2237 // GetRepoByName implements store.RepositoryStore.
2238-func (*repoStore) GetRepoByName(ctx context.Context, tx *db.Tx, name string) (models.Repo, error) {
2239+func (*repoStore) GetRepoByName(ctx context.Context, tx db.Handler, name string) (models.Repo, error) {
2240 	var repo models.Repo
2241 	name = utils.SanitizeRepo(name)
2242 	query := tx.Rebind("SELECT * FROM repos WHERE name = ?;")
2243@@ -49,7 +49,7 @@ func (*repoStore) GetRepoByName(ctx context.Context, tx *db.Tx, name string) (mo
2244 }
2245 
2246 // GetRepoDescriptionByName implements store.RepositoryStore.
2247-func (*repoStore) GetRepoDescriptionByName(ctx context.Context, tx *db.Tx, name string) (string, error) {
2248+func (*repoStore) GetRepoDescriptionByName(ctx context.Context, tx db.Handler, name string) (string, error) {
2249 	var description string
2250 	name = utils.SanitizeRepo(name)
2251 	query := tx.Rebind("SELECT description FROM repos WHERE name = ?;")
2252@@ -58,7 +58,7 @@ func (*repoStore) GetRepoDescriptionByName(ctx context.Context, tx *db.Tx, name
2253 }
2254 
2255 // GetRepoIsHiddenByName implements store.RepositoryStore.
2256-func (*repoStore) GetRepoIsHiddenByName(ctx context.Context, tx *db.Tx, name string) (bool, error) {
2257+func (*repoStore) GetRepoIsHiddenByName(ctx context.Context, tx db.Handler, name string) (bool, error) {
2258 	var isHidden bool
2259 	name = utils.SanitizeRepo(name)
2260 	query := tx.Rebind("SELECT hidden FROM repos WHERE name = ?;")
2261@@ -67,7 +67,7 @@ func (*repoStore) GetRepoIsHiddenByName(ctx context.Context, tx *db.Tx, name str
2262 }
2263 
2264 // GetRepoIsMirrorByName implements store.RepositoryStore.
2265-func (*repoStore) GetRepoIsMirrorByName(ctx context.Context, tx *db.Tx, name string) (bool, error) {
2266+func (*repoStore) GetRepoIsMirrorByName(ctx context.Context, tx db.Handler, name string) (bool, error) {
2267 	var isMirror bool
2268 	name = utils.SanitizeRepo(name)
2269 	query := tx.Rebind("SELECT mirror FROM repos WHERE name = ?;")
2270@@ -76,7 +76,7 @@ func (*repoStore) GetRepoIsMirrorByName(ctx context.Context, tx *db.Tx, name str
2271 }
2272 
2273 // GetRepoIsPrivateByName implements store.RepositoryStore.
2274-func (*repoStore) GetRepoIsPrivateByName(ctx context.Context, tx *db.Tx, name string) (bool, error) {
2275+func (*repoStore) GetRepoIsPrivateByName(ctx context.Context, tx db.Handler, name string) (bool, error) {
2276 	var isPrivate bool
2277 	name = utils.SanitizeRepo(name)
2278 	query := tx.Rebind("SELECT private FROM repos WHERE name = ?;")
2279@@ -85,7 +85,7 @@ func (*repoStore) GetRepoIsPrivateByName(ctx context.Context, tx *db.Tx, name st
2280 }
2281 
2282 // GetRepoProjectNameByName implements store.RepositoryStore.
2283-func (*repoStore) GetRepoProjectNameByName(ctx context.Context, tx *db.Tx, name string) (string, error) {
2284+func (*repoStore) GetRepoProjectNameByName(ctx context.Context, tx db.Handler, name string) (string, error) {
2285 	var pname string
2286 	name = utils.SanitizeRepo(name)
2287 	query := tx.Rebind("SELECT project_name FROM repos WHERE name = ?;")
2288@@ -94,7 +94,7 @@ func (*repoStore) GetRepoProjectNameByName(ctx context.Context, tx *db.Tx, name
2289 }
2290 
2291 // SetRepoDescriptionByName implements store.RepositoryStore.
2292-func (*repoStore) SetRepoDescriptionByName(ctx context.Context, tx *db.Tx, name string, description string) error {
2293+func (*repoStore) SetRepoDescriptionByName(ctx context.Context, tx db.Handler, name string, description string) error {
2294 	name = utils.SanitizeRepo(name)
2295 	query := tx.Rebind("UPDATE repos SET description = ? WHERE name = ?;")
2296 	_, err := tx.ExecContext(ctx, query, description, name)
2297@@ -102,7 +102,7 @@ func (*repoStore) SetRepoDescriptionByName(ctx context.Context, tx *db.Tx, name
2298 }
2299 
2300 // SetRepoIsHiddenByName implements store.RepositoryStore.
2301-func (*repoStore) SetRepoIsHiddenByName(ctx context.Context, tx *db.Tx, name string, isHidden bool) error {
2302+func (*repoStore) SetRepoIsHiddenByName(ctx context.Context, tx db.Handler, name string, isHidden bool) error {
2303 	name = utils.SanitizeRepo(name)
2304 	query := tx.Rebind("UPDATE repos SET hidden = ? WHERE name = ?;")
2305 	_, err := tx.ExecContext(ctx, query, isHidden, name)
2306@@ -110,7 +110,7 @@ func (*repoStore) SetRepoIsHiddenByName(ctx context.Context, tx *db.Tx, name str
2307diff --git a/server/store/database/settings.go b/server/store/database/settings.go
2308index bb653a7ff188f2812e4003c846a3f16b5bab7d22..ec63eae01f9a8a2743a83da4c817b0e03c3771af 100644
2309--- a/server/store/database/settings.go
2310+++ b/server/store/database/settings.go
2311@@ -13,7 +13,7 @@ type settingsStore struct{}
2312 var _ store.SettingStore = (*settingsStore)(nil)
2313 
2314 // GetAllowKeylessAccess implements store.SettingStore.
2315-func (*settingsStore) GetAllowKeylessAccess(ctx context.Context, tx *db.Tx) (bool, error) {
2316+func (*settingsStore) GetAllowKeylessAccess(ctx context.Context, tx db.Handler) (bool, error) {
2317 	var allow bool
2318 	query := tx.Rebind(`SELECT value FROM settings WHERE key = "allow_keyless"`)
2319 	if err := tx.GetContext(ctx, &allow, query); err != nil {
2320@@ -23,7 +23,7 @@ func (*settingsStore) GetAllowKeylessAccess(ctx context.Context, tx *db.Tx) (boo
2321 }
2322 
2323 // GetAnonAccess implements store.SettingStore.
2324-func (*settingsStore) GetAnonAccess(ctx context.Context, tx *db.Tx) (access.AccessLevel, error) {
2325+func (*settingsStore) GetAnonAccess(ctx context.Context, tx db.Handler) (access.AccessLevel, error) {
2326 	var level string
2327 	query := tx.Rebind(`SELECT value FROM settings WHERE key = "anon_access"`)
2328 	if err := tx.GetContext(ctx, &level, query); err != nil {
2329@@ -33,14 +33,14 @@ func (*settingsStore) GetAnonAccess(ctx context.Context, tx *db.Tx) (access.Acce
2330 }
2331 
2332 // SetAllowKeylessAccess implements store.SettingStore.
2333-func (*settingsStore) SetAllowKeylessAccess(ctx context.Context, tx *db.Tx, allow bool) error {
2334+func (*settingsStore) SetAllowKeylessAccess(ctx context.Context, tx db.Handler, allow bool) error {
2335 	query := tx.Rebind(`UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = "allow_keyless"`)
2336 	_, err := tx.ExecContext(ctx, query, allow)
2337 	return db.WrapError(err)
2338 }
2339 
2340 // SetAnonAccess implements store.SettingStore.
2341-func (*settingsStore) SetAnonAccess(ctx context.Context, tx *db.Tx, level access.AccessLevel) error {
2342+func (*settingsStore) SetAnonAccess(ctx context.Context, tx db.Handler, level access.AccessLevel) error {
2343 	query := tx.Rebind(`UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = "anon_access"`)
2344 	_, err := tx.ExecContext(ctx, query, level.String())
2345 	return db.WrapError(err)
2346diff --git a/server/store/database/user.go b/server/store/database/user.go
2347index 2e3a70beb45b9fb3625f98bbcf0d78604e190863..9ca824e62bfde8cdb9a2c2d77c6eecd442c59183 100644
2348--- a/server/store/database/user.go
2349+++ b/server/store/database/user.go
2350@@ -17,7 +17,7 @@ type userStore struct{}
2351 var _ store.UserStore = (*userStore)(nil)
2352 
2353 // AddPublicKeyByUsername implements store.UserStore.
2354-func (*userStore) AddPublicKeyByUsername(ctx context.Context, tx *db.Tx, username string, pk ssh.PublicKey) error {
2355+func (*userStore) AddPublicKeyByUsername(ctx context.Context, tx db.Handler, username string, pk ssh.PublicKey) error {
2356 	username = strings.ToLower(username)
2357 	if err := utils.ValidateUsername(username); err != nil {
2358 		return err
2359@@ -37,7 +37,7 @@ func (*userStore) AddPublicKeyByUsername(ctx context.Context, tx *db.Tx, usernam
2360 }
2361 
2362 // CreateUser implements store.UserStore.
2363-func (*userStore) CreateUser(ctx context.Context, tx *db.Tx, username string, isAdmin bool, pks []ssh.PublicKey) error {
2364+func (*userStore) CreateUser(ctx context.Context, tx db.Handler, username string, isAdmin bool, pks []ssh.PublicKey) error {
2365 	username = strings.ToLower(username)
2366 	if err := utils.ValidateUsername(username); err != nil {
2367 		return err
2368@@ -69,7 +69,7 @@ func (*userStore) CreateUser(ctx context.Context, tx *db.Tx, username string, is
2369 }
2370 
2371 // DeleteUserByUsername implements store.UserStore.
2372-func (*userStore) DeleteUserByUsername(ctx context.Context, tx *db.Tx, username string) error {
2373+func (*userStore) DeleteUserByUsername(ctx context.Context, tx db.Handler, username string) error {
2374 	username = strings.ToLower(username)
2375 	if err := utils.ValidateUsername(username); err != nil {
2376 		return err
2377@@ -80,8 +80,16 @@ func (*userStore) DeleteUserByUsername(ctx context.Context, tx *db.Tx, username
2378 	return err
2379 }
2380 
2381+// GetUserByID implements store.UserStore.
2382+func (*userStore) GetUserByID(ctx context.Context, tx db.Handler, id int64) (models.User, error) {
2383+	var m models.User
2384+	query := tx.Rebind(`SELECT * FROM users WHERE id = ?;`)
2385+	err := tx.GetContext(ctx, &m, query, id)
2386+	return m, err
2387+}
2388+
2389 // FindUserByPublicKey implements store.UserStore.
2390-func (*userStore) FindUserByPublicKey(ctx context.Context, tx *db.Tx, pk ssh.PublicKey) (models.User, error) {
2391+func (*userStore) FindUserByPublicKey(ctx context.Context, tx db.Handler, pk ssh.PublicKey) (models.User, error) {
2392 	var m models.User
2393 	query := tx.Rebind(`SELECT users.*
2394 			FROM users
2395@@ -92,7 +100,7 @@ func (*userStore) FindUserByPublicKey(ctx context.Context, tx *db.Tx, pk ssh.Pub
2396 }
2397 
2398 // FindUserByUsername implements store.UserStore.
2399-func (*userStore) FindUserByUsername(ctx context.Context, tx *db.Tx, username string) (models.User, error) {
2400+func (*userStore) FindUserByUsername(ctx context.Context, tx db.Handler, username string) (models.User, error) {
2401 	username = strings.ToLower(username)
2402 	if err := utils.ValidateUsername(username); err != nil {
2403 		return models.User{}, err
2404@@ -105,7 +113,7 @@ func (*userStore) FindUserByUsername(ctx context.Context, tx *db.Tx, username st
2405 }
2406 
2407 // GetAllUsers implements store.UserStore.
2408-func (*userStore) GetAllUsers(ctx context.Context, tx *db.Tx) ([]models.User, error) {
2409+func (*userStore) GetAllUsers(ctx context.Context, tx db.Handler) ([]models.User, error) {
2410 	var ms []models.User
2411 	query := tx.Rebind(`SELECT * FROM users;`)
2412 	err := tx.SelectContext(ctx, &ms, query)
2413@@ -113,7 +121,7 @@ func (*userStore) GetAllUsers(ctx context.Context, tx *db.Tx) ([]models.User, er
2414 }
2415 
2416 // ListPublicKeysByUserID implements store.UserStore..
2417-func (*userStore) ListPublicKeysByUserID(ctx context.Context, tx *db.Tx, id int64) ([]ssh.PublicKey, error) {
2418+func (*userStore) ListPublicKeysByUserID(ctx context.Context, tx db.Handler, id int64) ([]ssh.PublicKey, error) {
2419 	var aks []string
2420 	query := tx.Rebind(`SELECT public_key FROM public_keys
2421 			WHERE user_id = ?
2422@@ -136,7 +144,7 @@ func (*userStore) ListPublicKeysByUserID(ctx context.Context, tx *db.Tx, id int6
2423 }
2424 
2425 // ListPublicKeysByUsername implements store.UserStore.
2426-func (*userStore) ListPublicKeysByUsername(ctx context.Context, tx *db.Tx, username string) ([]ssh.PublicKey, error) {
2427+func (*userStore) ListPublicKeysByUsername(ctx context.Context, tx db.Handler, username string) ([]ssh.PublicKey, error) {
2428 	username = strings.ToLower(username)
2429 	if err := utils.ValidateUsername(username); err != nil {
2430 		return nil, err
2431@@ -165,7 +173,7 @@ func (*userStore) ListPublicKeysByUsername(ctx context.Context, tx *db.Tx, usern
2432 }
2433 
2434 // RemovePublicKeyByUsername implements store.UserStore.
2435-func (*userStore) RemovePublicKeyByUsername(ctx context.Context, tx *db.Tx, username string, pk ssh.PublicKey) error {
2436+func (*userStore) RemovePublicKeyByUsername(ctx context.Context, tx db.Handler, username string, pk ssh.PublicKey) error {
2437 	username = strings.ToLower(username)
2438 	if err := utils.ValidateUsername(username); err != nil {
2439 		return err
2440@@ -179,7 +187,7 @@ func (*userStore) RemovePublicKeyByUsername(ctx context.Context, tx *db.Tx, user
2441 }
2442 
2443 // SetAdminByUsername implements store.UserStore.
2444-func (*userStore) SetAdminByUsername(ctx context.Context, tx *db.Tx, username string, isAdmin bool) error {
2445+func (*userStore) SetAdminByUsername(ctx context.Context, tx db.Handler, username string, isAdmin bool) error {
2446 	username = strings.ToLower(username)
2447 	if err := utils.ValidateUsername(username); err != nil {
2448 		return err
2449@@ -191,7 +199,7 @@ func (*userStore) SetAdminByUsername(ctx context.Context, tx *db.Tx, username st
2450diff --git a/server/store/lfs.go b/server/store/lfs.go
2451new file mode 100644
2452index 0000000000000000000000000000000000000000..7632d2472bd7b65c43141e46dab7d49eb0741735
2453--- /dev/null
2454+++ b/server/store/lfs.go
2455@@ -0,0 +1,26 @@
2456+package store
2457+
2458+import (
2459+	"context"
2460+
2461+	"github.com/charmbracelet/soft-serve/server/db"
2462+	"github.com/charmbracelet/soft-serve/server/db/models"
2463+)
2464+
2465+// LFSStore is the interface for the LFS store.
2466+type LFSStore interface {
2467+	CreateLFSObject(ctx context.Context, h db.Handler, repoID int64, oid string, size int64) error
2468+	GetLFSObjectByOid(ctx context.Context, h db.Handler, repoID int64, oid string) (models.LFSObject, error)
2469+	GetLFSObjects(ctx context.Context, h db.Handler, repoID int64) ([]models.LFSObject, error)
2470+	GetLFSObjectsByName(ctx context.Context, h db.Handler, name string) ([]models.LFSObject, error)
2471+	DeleteLFSObjectByOid(ctx context.Context, h db.Handler, repoID int64, oid string) error
2472+
2473+	CreateLFSLockForUser(ctx context.Context, h db.Handler, repoID int64, userID int64, path string, refname string) error
2474+	GetLFSLocks(ctx context.Context, h db.Handler, repoID int64) ([]models.LFSLock, error)
2475+	GetLFSLocksForUser(ctx context.Context, h db.Handler, repoID int64, userID int64) ([]models.LFSLock, error)
2476+	GetLFSLocksForPath(ctx context.Context, h db.Handler, repoID int64, path string) ([]models.LFSLock, error)
2477+	GetLFSLockForUserPath(ctx context.Context, h db.Handler, repoID int64, userID int64, path string) (models.LFSLock, error)
2478+	GetLFSLockByID(ctx context.Context, h db.Handler, id string) (models.LFSLock, error)
2479+	GetLFSLockForUserByID(ctx context.Context, h db.Handler, userID int64, id string) (models.LFSLock, error)
2480+	DeleteLFSLockForUserByID(ctx context.Context, h db.Handler, userID int64, id string) error
2481+}
2482diff --git a/server/store/store.go b/server/store/store.go
2483index d933dfb7d38ad643d472e72808a8aba438ab241b..dcaa3165e4cd1821dbf402612388ef4fb4821c27 100644
2484--- a/server/store/store.go
2485+++ b/server/store/store.go
2486@@ -11,53 +11,54 @@ import (
2487 
2488 // SettingStore is an interface for managing settings.
2489 type SettingStore interface {
2490-	GetAnonAccess(ctx context.Context, tx *db.Tx) (access.AccessLevel, error)
2491-	SetAnonAccess(ctx context.Context, tx *db.Tx, level access.AccessLevel) error
2492-	GetAllowKeylessAccess(ctx context.Context, tx *db.Tx) (bool, error)
2493-	SetAllowKeylessAccess(ctx context.Context, tx *db.Tx, allow bool) error
2494+	GetAnonAccess(ctx context.Context, h db.Handler) (access.AccessLevel, error)
2495+	SetAnonAccess(ctx context.Context, h db.Handler, level access.AccessLevel) error
2496+	GetAllowKeylessAccess(ctx context.Context, h db.Handler) (bool, error)
2497+	SetAllowKeylessAccess(ctx context.Context, h db.Handler, allow bool) error
2498 }
2499 
2500 // RepositoryStore is an interface for managing repositories.
2501 type RepositoryStore interface {
2502-	GetRepoByName(ctx context.Context, tx *db.Tx, name string) (models.Repo, error)
2503-	GetAllRepos(ctx context.Context, tx *db.Tx) ([]models.Repo, error)
2504-	CreateRepo(ctx context.Context, tx *db.Tx, name string, projectName string, description string, isPrivate bool, isHidden bool, isMirror bool) error
2505-	DeleteRepoByName(ctx context.Context, tx *db.Tx, name string) error
2506-	SetRepoNameByName(ctx context.Context, tx *db.Tx, name string, newName string) error
2507+	GetRepoByName(ctx context.Context, h db.Handler, name string) (models.Repo, error)
2508+	GetAllRepos(ctx context.Context, h db.Handler) ([]models.Repo, error)
2509+	CreateRepo(ctx context.Context, h db.Handler, name string, projectName string, description string, isPrivate bool, isHidden bool, isMirror bool) error
2510+	DeleteRepoByName(ctx context.Context, h db.Handler, name string) error
2511+	SetRepoNameByName(ctx context.Context, h db.Handler, name string, newName string) error
2512 
2513-	GetRepoProjectNameByName(ctx context.Context, tx *db.Tx, name string) (string, error)
2514-	SetRepoProjectNameByName(ctx context.Context, tx *db.Tx, name string, projectName string) error
2515-	GetRepoDescriptionByName(ctx context.Context, tx *db.Tx, name string) (string, error)
2516-	SetRepoDescriptionByName(ctx context.Context, tx *db.Tx, name string, description string) error
2517-	GetRepoIsPrivateByName(ctx context.Context, tx *db.Tx, name string) (bool, error)
2518-	SetRepoIsPrivateByName(ctx context.Context, tx *db.Tx, name string, isPrivate bool) error
2519-	GetRepoIsHiddenByName(ctx context.Context, tx *db.Tx, name string) (bool, error)
2520-	SetRepoIsHiddenByName(ctx context.Context, tx *db.Tx, name string, isHidden bool) error
2521-	GetRepoIsMirrorByName(ctx context.Context, tx *db.Tx, name string) (bool, error)
2522+	GetRepoProjectNameByName(ctx context.Context, h db.Handler, name string) (string, error)
2523+	SetRepoProjectNameByName(ctx context.Context, h db.Handler, name string, projectName string) error
2524+	GetRepoDescriptionByName(ctx context.Context, h db.Handler, name string) (string, error)
2525+	SetRepoDescriptionByName(ctx context.Context, h db.Handler, name string, description string) error
2526+	GetRepoIsPrivateByName(ctx context.Context, h db.Handler, name string) (bool, error)
2527+	SetRepoIsPrivateByName(ctx context.Context, h db.Handler, name string, isPrivate bool) error
2528+	GetRepoIsHiddenByName(ctx context.Context, h db.Handler, name string) (bool, error)
2529+	SetRepoIsHiddenByName(ctx context.Context, h db.Handler, name string, isHidden bool) error
2530+	GetRepoIsMirrorByName(ctx context.Context, h db.Handler, name string) (bool, error)
2531 }
2532 
2533 // UserStore is an interface for managing users.
2534 type UserStore interface {
2535-	FindUserByUsername(ctx context.Context, tx *db.Tx, username string) (models.User, error)
2536-	FindUserByPublicKey(ctx context.Context, tx *db.Tx, pk ssh.PublicKey) (models.User, error)
2537-	GetAllUsers(ctx context.Context, tx *db.Tx) ([]models.User, error)
2538-	CreateUser(ctx context.Context, tx *db.Tx, username string, isAdmin bool, pks []ssh.PublicKey) error
2539-	DeleteUserByUsername(ctx context.Context, tx *db.Tx, username string) error
2540-	SetUsernameByUsername(ctx context.Context, tx *db.Tx, username string, newUsername string) error
2541-	SetAdminByUsername(ctx context.Context, tx *db.Tx, username string, isAdmin bool) error
2542-	AddPublicKeyByUsername(ctx context.Context, tx *db.Tx, username string, pk ssh.PublicKey) error
2543-	RemovePublicKeyByUsername(ctx context.Context, tx *db.Tx, username string, pk ssh.PublicKey) error
2544-	ListPublicKeysByUserID(ctx context.Context, tx *db.Tx, id int64) ([]ssh.PublicKey, error)
2545-	ListPublicKeysByUsername(ctx context.Context, tx *db.Tx, username string) ([]ssh.PublicKey, error)
2546+	GetUserByID(ctx context.Context, h db.Handler, id int64) (models.User, error)
2547+	FindUserByUsername(ctx context.Context, h db.Handler, username string) (models.User, error)
2548+	FindUserByPublicKey(ctx context.Context, h db.Handler, pk ssh.PublicKey) (models.User, error)
2549+	GetAllUsers(ctx context.Context, h db.Handler) ([]models.User, error)
2550+	CreateUser(ctx context.Context, h db.Handler, username string, isAdmin bool, pks []ssh.PublicKey) error
2551+	DeleteUserByUsername(ctx context.Context, h db.Handler, username string) error
2552+	SetUsernameByUsername(ctx context.Context, h db.Handler, username string, newUsername string) error
2553+	SetAdminByUsername(ctx context.Context, h db.Handler, username string, isAdmin bool) error
2554+	AddPublicKeyByUsername(ctx context.Context, h db.Handler, username string, pk ssh.PublicKey) error
2555+	RemovePublicKeyByUsername(ctx context.Context, h db.Handler, username string, pk ssh.PublicKey) error
2556+	ListPublicKeysByUserID(ctx context.Context, h db.Handler, id int64) ([]ssh.PublicKey, error)
2557+	ListPublicKeysByUsername(ctx context.Context, h db.Handler, username string) ([]ssh.PublicKey, error)
2558 }
2559 
2560 // CollaboratorStore is an interface for managing collaborators.
2561 type CollaboratorStore interface {
2562-	GetCollabByUsernameAndRepo(ctx context.Context, tx *db.Tx, username string, repo string) (models.Collab, error)
2563-	AddCollabByUsernameAndRepo(ctx context.Context, tx *db.Tx, username string, repo string) error
2564-	RemoveCollabByUsernameAndRepo(ctx context.Context, tx *db.Tx, username string, repo string) error
2565-	ListCollabsByRepo(ctx context.Context, tx *db.Tx, repo string) ([]models.Collab, error)
2566-	ListCollabsByRepoAsUsers(ctx context.Context, tx *db.Tx, repo string) ([]models.User, error)
2567+	GetCollabByUsernameAndRepo(ctx context.Context, h db.Handler, username string, repo string) (models.Collab, error)
2568+	AddCollabByUsernameAndRepo(ctx context.Context, h db.Handler, username string, repo string) error
2569+	RemoveCollabByUsernameAndRepo(ctx context.Context, h db.Handler, username string, repo string) error
2570+	ListCollabsByRepo(ctx context.Context, h db.Handler, repo string) ([]models.Collab, error)
2571+	ListCollabsByRepoAsUsers(ctx context.Context, h db.Handler, repo string) ([]models.User, error)
2572 }
2573 
2574 // Store is an interface for managing repositories, users, and settings.
2575@@ -66,4 +67,5 @@ type Store interface {
2576 	UserStore
2577 	CollaboratorStore
2578 	SettingStore
2579+	LFSStore
2580 }
2581diff --git a/testscript/script_test.go b/testscript/script_test.go
2582index dbb8b956f53b173c38df509acfb379bb600bca23..d16c19f3fe304bc64febe5c42fe8008ef7ce8d0a 100644
2583--- a/testscript/script_test.go
2584+++ b/testscript/script_test.go
2585@@ -19,6 +19,8 @@ import (
2586 	"github.com/charmbracelet/soft-serve/server/config"
2587 	"github.com/charmbracelet/soft-serve/server/db"
2588 	"github.com/charmbracelet/soft-serve/server/db/migrate"
2589+	"github.com/charmbracelet/soft-serve/server/store"
2590+	"github.com/charmbracelet/soft-serve/server/store/database"
2591 	"github.com/charmbracelet/soft-serve/server/test"
2592 	"github.com/rogpeppe/go-internal/testscript"
2593 	"golang.org/x/crypto/ssh"
2594@@ -105,6 +107,8 @@ func TestScript(t *testing.T) {
2595 			}
2596 
2597 			ctx = db.WithContext(ctx, dbx)
2598+			datastore := database.New(ctx, dbx)
2599+			ctx = store.WithContext(ctx, datastore)
2600 			be := backend.New(ctx, cfg, dbx)
2601 			ctx = backend.WithContext(ctx, be)
2602