Home

ugit @main - refs - log -
-
https://git.jolheiser.com/ugit.git
The code powering this h*ckin' site
tree log patch
initial commit Signed-off-by: jolheiser <john.olheiser@gmail.com>
Signature
-----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEgqEQpE3xoo1QwJO/uFOtpdp7v3oFAmWlsTIACgkQuFOtpdp7 v3rTMg//WrBuhgANYIm8aqZQuKz5GQuezGgHsW4OY5ColwMmu0fLLVyvrwB2MsuT M2zcwy5mcYhluAf96dMBhck/IorHy0xiBDorfK5XQKEtVr2Yfb0N/ZQ3v78e5CkW kpwzGxP0FUnF8hvbCHa/qOFRbVq4kQhfiFqUeGJ233aKZnQ5MF7o57lum/euoaxY NuKD912i2u6xCUND36juM3I8A/VdXxGOjUOQDwWVJf16Wz3zck+geg7iJ0fQwPyV 7hYVH9MoljnEZE5CXApvdfyAHWV444sGDFdF4MXF9HfWKOqEW5TgVAPP/71oSfpe oAsjf1tKv33YVh/ngLle87jJ931SuA3hdtvEu40JDxK+4Cz+y9y6zAb5jzRzdOr4 eAB8FTm7sATyRbMYXzepEs2zc5euSnTlFeA2FOFNl/KDRc8MSPxW+UXmx87+C1aO qn+BWHDSDJ9LTfao2adGLN9xxzVh5NKd8t3ckTv3XPPiYeLKozyzIN9B8lRcWGpJ 5OBaICatNVzN/gCfk8Z112P0zTUg6UHUaggHFzJ3m4LhJa7RUd2CUjIIoNPJ9s/1 6psKaR02KHa+QEkz4dGnf+jajnBQO/SRXob0Jkl8b/G8+PnDbZxy3WluztIZrtdT 3CGKPp54gRhENts4ZeD5oTW8L8HfnUwAwc1o/jpnobm/3Mmt24w= =wic7 -----END PGP SIGNATURE-----
jolheiser <john.olheiser@gmail.com>
9 months ago
43 changed files, 3031 additions(+), 122 deletions(-)
M .gitignore -> .gitignore
diff --git a/.gitignore b/.gitignore
index f53c2fd9c85934fc3516359d2798b5e86ca53ac7..9b0fc31dcb49d4802a3c1164053076335555c217 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,3 @@
 /ugit*
+.ssh/
+.ugit/
I .helix/languages.toml
diff --git a/.helix/languages.toml b/.helix/languages.toml
new file mode 100644
index 0000000000000000000000000000000000000000..adcc7b20b55bb9bddc08aa35dd35060044280875
--- /dev/null
+++ b/.helix/languages.toml
@@ -0,0 +1,5 @@
+[[language]]
+name = "templ"
+language-id = "html"
+language-servers = ["templ", "vscode-html-language-server", "tailwindcss-ls"]
+
I README.md
diff --git a/README.md b/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..b526756dff25a1db2927a6e2ec653de0d97dcff9
--- /dev/null
+++ b/README.md
@@ -0,0 +1,32 @@
+# ugit
+
+<img style="width: 50px;" alt="ugit logo" src="/ugit/tree/main/assets/ugit.svg?raw&pretty"/>
+
+Minimal git server
+
+ugit allows cloning via HTTPS/SSH, but can only be pushed to via SSH.
+
+There are no plans to directly support issues or PR workflows, although webhooks are planned and auxillary software may be created to facilitate these things.
+For now, if you wish to collaborate, please send me patches at [john+ugit@jolheiser.com](mailto:john+ugit@jolheiser.com).
+
+Currently all HTML is allowed in markdown, ugit is intended to be run by/for a trusted user.
+
+## Getting your public SSH keys from another forge
+
+Using GitHub as an example (although Gitea/GitLab should have the same URL scheme)
+
+Ba/sh
+```sh
+curl https://github.com/<username>.keys > path/to/authorized_keys
+```
+
+Nushell
+```sh
+http get https://github.com/<username>.keys | save --force path/to/authorized_keys
+```
+
+## License
+
+[MIT](LICENSE)
+
+Lots of inspiration and some starting code used from [wish](https://github.com/charmbracelet/wish) [(MIT)](https://github.com/charmbracelet/wish/blob/3e6f92a166118390484ce4a0904114b375b9e485/LICENSE) and [legit](https://github.com/icyphox/legit) [(MIT)](https://github.com/icyphox/legit/blob/bdfc973207a67a3b217c130520d53373d088763c/license).
I assets/assets.go
diff --git a/assets/assets.go b/assets/assets.go
new file mode 100644
index 0000000000000000000000000000000000000000..a69ac02d5491fc7bc3f9c47a281c6784ca222345
--- /dev/null
+++ b/assets/assets.go
@@ -0,0 +1,19 @@
+package assets
+
+import "embed"
+
+var (
+	//go:embed *.svg
+	Icons     embed.FS
+	LinkIcon  = must("link.svg")
+	EmailIcon = must("email.svg")
+	LogoIcon  = must("ugit.svg")
+)
+
+func must(path string) []byte {
+	content, err := Icons.ReadFile(path)
+	if err != nil {
+		panic(err)
+	}
+	return content
+}
I assets/email.svg
diff --git a/assets/email.svg b/assets/email.svg
new file mode 100644
index 0000000000000000000000000000000000000000..9703d2b72940ba8ffda26bc11273d7b39e070cac
--- /dev/null
+++ b/assets/email.svg
@@ -0,0 +1 @@
+<?xml version="1.0" encoding="UTF-8"?><svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><title>email icon</title><path d="M3 8L8.44992 11.6333C9.73295 12.4886 10.3745 12.9163 11.0678 13.0825C11.6806 13.2293 12.3194 13.2293 12.9322 13.0825C13.6255 12.9163 14.2671 12.4886 15.5501 11.6333L21 8M6.2 19H17.8C18.9201 19 19.4802 19 19.908 18.782C20.2843 18.5903 20.5903 18.2843 20.782 17.908C21 17.4802 21 16.9201 21 15.8V8.2C21 7.0799 21 6.51984 20.782 6.09202C20.5903 5.71569 20.2843 5.40973 19.908 5.21799C19.4802 5 18.9201 5 17.8 5H6.2C5.0799 5 4.51984 5 4.09202 5.21799C3.71569 5.40973 3.40973 5.71569 3.21799 6.09202C3 6.51984 3 7.07989 3 8.2V15.8C3 16.9201 3 17.4802 3.21799 17.908C3.40973 18.2843 3.71569 18.5903 4.09202 18.782C4.51984 19 5.07989 19 6.2 19Z" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path></svg>
I assets/link.svg
diff --git a/assets/link.svg b/assets/link.svg
new file mode 100644
index 0000000000000000000000000000000000000000..f0a10d1a4723096e08d3ae4918221f12dd88d063
--- /dev/null
+++ b/assets/link.svg
@@ -0,0 +1 @@
+<?xml version="1.0" encoding="UTF-8"?><svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><title>link icon</title><path d="M9.16488 17.6505C8.92513 17.8743 8.73958 18.0241 8.54996 18.1336C7.62175 18.6695 6.47816 18.6695 5.54996 18.1336C5.20791 17.9361 4.87912 17.6073 4.22153 16.9498C3.56394 16.2922 3.23514 15.9634 3.03767 15.6213C2.50177 14.6931 2.50177 13.5495 3.03767 12.6213C3.23514 12.2793 3.56394 11.9505 4.22153 11.2929L7.04996 8.46448C7.70755 7.80689 8.03634 7.47809 8.37838 7.28062C9.30659 6.74472 10.4502 6.74472 11.3784 7.28061C11.7204 7.47809 12.0492 7.80689 12.7068 8.46448C13.3644 9.12207 13.6932 9.45086 13.8907 9.7929C14.4266 10.7211 14.4266 11.8647 13.8907 12.7929C13.7812 12.9825 13.6314 13.1681 13.4075 13.4078M10.5919 10.5922C10.368 10.8319 10.2182 11.0175 10.1087 11.2071C9.57284 12.1353 9.57284 13.2789 10.1087 14.2071C10.3062 14.5492 10.635 14.878 11.2926 15.5355C11.9502 16.1931 12.279 16.5219 12.621 16.7194C13.5492 17.2553 14.6928 17.2553 15.621 16.7194C15.9631 16.5219 16.2919 16.1931 16.9495 15.5355L19.7779 12.7071C20.4355 12.0495 20.7643 11.7207 20.9617 11.3787C21.4976 10.4505 21.4976 9.30689 20.9617 8.37869C20.7643 8.03665 20.4355 7.70785 19.7779 7.05026C19.1203 6.39267 18.7915 6.06388 18.4495 5.8664C17.5212 5.3305 16.3777 5.3305 15.4495 5.8664C15.2598 5.97588 15.0743 6.12571 14.8345 6.34955" stroke-width="2" stroke-linecap="round"></path></svg>
I assets/ugit.svg
diff --git a/assets/ugit.svg b/assets/ugit.svg
new file mode 100644
index 0000000000000000000000000000000000000000..18146d538504407a79f49a044e105ec919deb2d6
--- /dev/null
+++ b/assets/ugit.svg
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg viewBox="0 0 24 24" stroke="#de4c36" fill="#de4c36" stroke-width="1" xmlns="http://www.w3.org/2000/svg">
+  <title>ugit icon</title>
+  <rect fill="none" x="1" y="1" rx="1" ry="1" width="22" height="22"></rect>
+  <ellipse cx="6" cy="6" rx="2" ry="2"></ellipse>
+  <ellipse cx="18" cy="6" rx="2" ry="2"></ellipse>
+  <ellipse cx="18" cy="18" rx="2" ry="2"></ellipse>
+  <line stroke-width="1.5" x1="18" y1="5" x2="18" y2="18"></line>
+  <path stroke-width="1.5" d="M6 6 q 0 12 12 12" fill="none" />
+</svg>
M cmd/ugitd/args.go -> cmd/ugitd/args.go
diff --git a/cmd/ugitd/args.go b/cmd/ugitd/args.go
index 6b4d999c3abced3697291f6d9de4406bf6bab7f5..a1a0e46ffecc4167880a9d9a77c26d8338d56d4f 100644
--- a/cmd/ugitd/args.go
+++ b/cmd/ugitd/args.go
@@ -3,20 +3,100 @@
 import (
 	"flag"
 
+	"strings"
+
 	"github.com/peterbourgon/ff/v3"
 	"github.com/peterbourgon/ff/v3/ffyaml"
 )
 
+type cliArgs struct {
+	Debug   bool
+	RepoDir string
+	SSH     sshArgs
+	HTTP    httpArgs
+	Meta    metaArgs
+
 type args struct {
+}
+
+
 	db string
+	AuthorizedKeys string
+	CloneURL       string
+	Port           int
+	HostKey        string
 }
 
+type httpArgs struct {
+	CloneURL string
+	Port     int
 package main
+
+type metaArgs struct {
+	Title       string
+	Description string
 package main
+
+type profileArgs struct {
+	Username string
+	Email    string
+	Links    []profileLink
+}
+
+type profileLink struct {
+	Name string
+	URL  string
+}
+
+func parseArgs(args []string) (c cliArgs, e error) {
 	fs := flag.NewFlagSet("ugitd", flag.ContinueOnError)
 	fs.String("config", "ugit.yaml", "Path to config file")
+
+	c = cliArgs{
+		RepoDir: ".ugit",
+		SSH: sshArgs{
+	"github.com/peterbourgon/ff/v3"
 package main
+			CloneURL:       "ssh://localhost:8448",
+			Port:           8448,
+	"github.com/peterbourgon/ff/v3"
 	"flag"
+		},
+		HTTP: httpArgs{
+			CloneURL: "http://localhost:8449",
+			Port:     8449,
+		},
+		Meta: metaArgs{
+			Title:       "ugit",
+			Description: "Minimal git server",
+		},
+	}
+
+	fs.BoolVar(&c.Debug, "debug", c.Debug, "Debug logging")
+	fs.StringVar(&c.RepoDir, "repo-dir", c.RepoDir, "Path to directory containing repositories")
+	fs.StringVar(&c.SSH.AuthorizedKeys, "ssh.authorized-keys", c.SSH.AuthorizedKeys, "Path to authorized_keys")
+	fs.StringVar(&c.SSH.CloneURL, "ssh.clone-url", c.SSH.CloneURL, "SSH clone URL base")
+	fs.IntVar(&c.SSH.Port, "ssh.port", c.SSH.Port, "SSH port")
+	fs.StringVar(&c.SSH.HostKey, "ssh.host-key", c.SSH.HostKey, "SSH host key (created if it doesn't exist)")
+	fs.StringVar(&c.HTTP.CloneURL, "http.clone-url", c.HTTP.CloneURL, "HTTP clone URL base")
+	fs.IntVar(&c.HTTP.Port, "http.port", c.HTTP.Port, "HTTP port")
+	fs.StringVar(&c.Meta.Title, "meta.title", c.Meta.Title, "App title")
+	fs.StringVar(&c.Meta.Description, "meta.description", c.Meta.Description, "App description")
+	fs.StringVar(&c.Profile.Username, "profile.username", c.Profile.Username, "Username for index page")
+	fs.StringVar(&c.Profile.Email, "profile.email", c.Profile.Email, "Email for index page")
+	fs.Func("profile.links", "Link(s) for index page", func(s string) error {
+		parts := strings.SplitN(s, ",", 2)
+		if len(parts) != 2 {
+			return fmt.Errorf("invalid profile link %q", s)
+		}
+		c.Profile.Links = append(c.Profile.Links, profileLink{
+			Name: parts[0],
+			URL:  parts[1],
+		})
+		return nil
+	})
+
+	return c, ff.Parse(fs, args,
 		ff.WithEnvVarPrefix("UGIT"),
 		ff.WithConfigFileFlag("config"),
 		ff.WithAllowMissingConfigFile(true),
M cmd/ugitd/main.go -> cmd/ugitd/main.go
diff --git a/cmd/ugitd/main.go b/cmd/ugitd/main.go
index fc9a7bf721de9da0c45b41d5e058a5691a749d13..3286e60ad9d56cec90db13ed2193ed617f68ea86 100644
--- a/cmd/ugitd/main.go
+++ b/cmd/ugitd/main.go
@@ -1,15 +1,90 @@
 package main
 
 import (
+	"errors"
+	"flag"
 	"fmt"
 	"os"
+package main
 )
 
+package main
 func main() {
+package main
 	args, err := parseArgs(os.Args[1:])
+
+package main
 	if err != nil {
+	"github.com/go-chi/chi/v5/middleware"
+	"github.com/go-git/go-git/v5/utils/trace"
+)
+
+func main() {
+	args, err := parseArgs(os.Args[1:])
+	if err != nil {
+		if errors.Is(err, flag.ErrHelp) {
+			return
+		}
 		panic(err)
 	}
+
+	if args.Debug {
+		trace.SetTarget(trace.Packet)
+		log.SetLevel(log.DebugLevel)
+	} else {
+		middleware.DefaultLogger = http.NoopLogger
+		ssh.DefaultLogger = ssh.NoopLogger
 package main
+package main
 
+	if err := os.MkdirAll(args.RepoDir, os.ModePerm); err != nil {
+		panic(err)
+	}
+
+	sshSettings := ssh.Settings{
+		AuthorizedKeys: args.SSH.AuthorizedKeys,
+		CloneURL:       args.SSH.CloneURL,
+		Port:           args.SSH.Port,
+		HostKey:        args.SSH.HostKey,
+		RepoDir:        args.RepoDir,
+	}
+	sshSrv, err := ssh.New(sshSettings)
+	if err != nil {
+		panic(err)
+	}
+	go func() {
+		fmt.Printf("SSH listening on ssh://localhost:%d\n", sshSettings.Port)
+		if err := sshSrv.ListenAndServe(); err != nil {
+			panic(err)
+		}
+	}()
+
+	httpSettings := http.Settings{
+		Title:       args.Meta.Title,
+		Description: args.Meta.Description,
+		CloneURL:    args.HTTP.CloneURL,
+		Port:        args.HTTP.Port,
+		RepoDir:     args.RepoDir,
+		Profile: http.Profile{
+			Username: args.Profile.Username,
+			Email:    args.Profile.Email,
+		},
+	}
+	for _, link := range args.Profile.Links {
+		httpSettings.Profile.Links = append(httpSettings.Profile.Links, http.Link{
+			Name: link.Name,
+			URL:  link.URL,
+		})
+	}
+	httpSrv := http.New(httpSettings)
+	go func() {
+		fmt.Printf("HTTP listening on http://localhost:%d\n", httpSettings.Port)
+		if err := httpSrv.ListenAndServe(); err != nil {
+			panic(err)
+		}
+	}()
+
+	ch := make(chan os.Signal, 1)
+	signal.Notify(ch, os.Kill, os.Interrupt)
+	<-ch
 }
M flake.lock -> flake.lock
diff --git a/flake.lock b/flake.lock
index 43e6a8fb6322feb0996e91639f05bb8e85f73b7f..ac832b83c21fb3f2fae2a7a1c0326d192a066c42 100644
--- a/flake.lock
+++ b/flake.lock
@@ -1,114 +1,83 @@
 {
   "nodes": {
-    "flake-utils": {
-      "inputs": {
-        "systems": "systems"
-      },
-      "locked": {
-        "lastModified": 1694529238,
-        "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
 {
-        "repo": "flake-utils",
-        "rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
-        "type": "github"
-      },
-      "original": {
-        "id": "flake-utils",
-        "type": "indirect"
+        "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
-{
       "locked": {
-{
+        "systems": "systems"
         "lastModified": 1694529238,
-{
+        "systems": "systems"
         "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
-      "locked": {
-        "lastModified": 1701020769,
-        "narHash": "sha256-4YzCo7xMzkG/t/VlTHqOg9hvXCvqdWYDX/jpF0h+Wr8=",
         "owner": "nixos",
         "repo": "nixpkgs",
-        "rev": "b608fc233c0592210250974d1bb3c11dfaf95e58",
+        "rev": "63143ac2c9186be6d9da6035fa22620018c85932",
         "type": "github"
       },
       "original": {
         "owner": "nixos",
+        "ref": "nixpkgs-unstable",
         "repo": "nixpkgs",
         "type": "github"
       }
     },
-    "nur": {
+    "root": {
       "inputs": {
-        "nixpkgs": [
-          "nixpkgs"
-  "nodes": {
+    "flake-utils": {
         "lastModified": 1694529238,
       },
-      "locked": {
   "nodes": {
-        "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
-        "narHash": "sha256-h72i6afGKreU+DjpZ6+qersarYYp4YjX+DBQ+MQkOG4=",
-        "ref": "refs/heads/main",
-        "rev": "a68a81cbc743e84aaee331ae7e58699398dd732d",
-        "revCount": 167,
-        "type": "git",
-        "url": "https://git.jojodev.com/jolheiser/nur"
       },
-      "original": {
     "flake-utils": {
-      "inputs": {
-        "url": "https://git.jojodev.com/jolheiser/nur"
       }
     },
-    "root": {
       "inputs": {
-    "flake-utils": {
       "locked": {
-        "nixpkgs": "nixpkgs",
-        "nur": "nur",
       "inputs": {
-{
+        "nixpkgs": [
+  "nodes": {
       "locked": {
-{
+  "nodes": {
         "lastModified": 1694529238,
-    "systems": {
+      },
       "locked": {
       "inputs": {
-  "nodes": {
+        "lastModified": 1694529238,
       "inputs": {
+        "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
     "flake-utils": {
+{
-        "owner": "nix-systems",
+        "rev": "afca060674b20e0ccecde2d6fe88c887790219a5",
-      "inputs": {
         "systems": "systems"
+{
+    "flake-utils": {
       "inputs": {
-      },
-        "type": "github"
+        "url": "https://git.jojodev.com/jolheiser/tailwind-ctp"
       },
       "original": {
-      "inputs": {
+    "flake-utils": {
       "inputs": {
-      "inputs": {
         "systems": "systems"
-        "type": "github"
+  "nodes": {
       }
     },
+      },
       "inputs": {
-      "locked": {
       "inputs": {
         "nixpkgs": [
           "nixpkgs"
         ]
       },
       "locked": {
-        "lastModified": 1695841587,
+        "lastModified": 1699401590,
-        "narHash": "sha256-fgiZd5AV+hi8Ne0bJ8SyAx5nppseW4aXJQEIDSr0VNA=",
+        "narHash": "sha256-nx8ExuBRUux9eXSUgkWp1LJMvA3dmA76+2xggZjHTU0=",
-        "ref": "refs/heads/main",
+        "ref": "refs/heads/master",
-        "rev": "afca060674b20e0ccecde2d6fe88c887790219a5",
+        "rev": "b321333ad08bf21db242f246b10ad4a50b8fc8a0",
-        "revCount": 1,
+        "revCount": 848,
         "type": "git",
-        "url": "https://git.jojodev.com/jolheiser/tailwind-ctp"
+        "url": "https://git.jojodev.com/jolheiser/tailwind-ctp-intellisense"
       },
       "original": {
         "type": "git",
-        "url": "https://git.jojodev.com/jolheiser/tailwind-ctp"
+        "url": "https://git.jojodev.com/jolheiser/tailwind-ctp-intellisense"
       }
     }
   },
M flake.nix -> flake.nix
diff --git a/flake.nix b/flake.nix
index e2c64454e7053bb255b1bd369ffad2a81877c774..cdcff1f7b1fcd2efa8f0122da5998941ba0aee32 100644
--- a/flake.nix
+++ b/flake.nix
@@ -1,45 +1,199 @@
+{
+  description = "Minimal git server";
+{
 {
   inputs = {
     nixpkgs.url = "github:nixos/nixpkgs";
+    nur = {
+    tailwind-ctp = {
+      url = "git+https://git.jojodev.com/jolheiser/tailwind-ctp";
+      inputs.nixpkgs.follows = "nixpkgs";
+    };
+    tailwind-ctp-lsp = {
+      url = "git+https://git.jojodev.com/jolheiser/tailwind-ctp-intellisense";
+      inputs.nixpkgs.follows = "nixpkgs";
+    };
+  };
+
+  outputs = {
+    self,
+    nixpkgs,
+    tailwind-ctp,
+    tailwind-ctp-lsp,
+  } @ inputs: let
+    system = "x86_64-linux";
+    pkgs = nixpkgs.legacyPackages.${system};
+    tailwind-ctp = inputs.tailwind-ctp.packages.${system}.default;
+    tailwind-ctp-lsp = inputs.tailwind-ctp-lsp.packages.${system}.default;
+    ugit = pkgs.buildGoModule rec {
     nur = {
+      version = "0.0.1";
+      src = pkgs.nix-gitignore.gitignoreSource [] (builtins.path {
+        name = pname;
+        path = ./.;
+    nur = {
       url = "git+https://git.jojodev.com/jolheiser/nur";
+    nur = {
       inputs.nixpkgs.follows = "nixpkgs";
+    nur = {
     };
+    nur = {
     tailwind-ctp = {
+    nur = {
       url = "git+https://git.jojodev.com/jolheiser/tailwind-ctp";
+        homepage = "https://git.jolheiser.com/ugit";
+        maintainers = with maintainers; [jolheiser];
+        mainProgram = "ugitd";
+      };
+    };
+  in {
+    packages.${system}.default = ugit;
+    devShells.${system}.default = pkgs.mkShell {
+      nativeBuildInputs = with pkgs; [
+        go
+      url = "git+https://git.jojodev.com/jolheiser/nur";
       inputs.nixpkgs.follows = "nixpkgs";
+      url = "git+https://git.jojodev.com/jolheiser/nur";
     };
+        tailwind-ctp
+        tailwind-ctp-lsp
+        vscode-langservers-extracted
+    nixpkgs.url = "github:nixos/nixpkgs";
 {
+    };
+      inputs.nixpkgs.follows = "nixpkgs";
+      inputs.nixpkgs.follows = "nixpkgs";
 {
+      lib,
+      config,
+      ...
+    }: let
+      cfg = config.services.ugit;
+      configFile = pkgs.writeText "ugit.yaml" cfg.configFile;
+      authorizedKeysFile = pkgs.writeText "ugit_keys" (builtins.concatStringsSep "\n" cfg.authorizedKeys);
+    in {
+      options = with lib; {
+    };
 {
+          enable = mkEnableOption "Enable ugit";
 {
+{
+          package = mkOption {
+            type = types.package;
+            description = "ugit package to use";
+            default = ugit;
+          };
+
+          repoDir = mkOption {
+            type = types.str;
+            description = "where ugit stores repositories";
+            default = "/var/lib/ugit/repos";
+          };
+
+    tailwind-ctp = {
   inputs = {
+            type = types.listOf types.str;
+            description = "list of keys to use for authorized_keys";
+            default = [];
+          };
 {
+{
+          authorizedKeysFile = mkOption {
+            type = types.str;
+            description = "path to authorized_keys file ugit uses for auth";
+            default = "/var/lib/ugit/authorized_keys";
+          };
+
+          hostKeyFile = mkOption {
+            type = types.str;
+            description = "path to host key file (will be created if it doesn't exist)";
+            default = "/var/lib/ugit/ugit_ed25519";
+          };
+
+          configFile = mkOption {
+            type = types.str;
+      url = "git+https://git.jojodev.com/jolheiser/tailwind-ctp";
     nixpkgs.url = "github:nixos/nixpkgs";
+            description = "config.yaml contents";
+          };
+{
 {
+          user = mkOption {
+            type = types.str;
+            default = "ugit";
+            description = "User account under which ugit runs";
+          };
+
+          group = mkOption {
+            type = types.str;
+            default = "ugit";
+            description = "Group account under which ugit runs";
+          };
+
+          debug = mkOption {
+            type = types.bool;
+            default = false;
+          };
+
+          openFirewall = mkOption {
+            type = types.bool;
+            default = false;
+          };
+  };
     nur = {
+      };
 {
       url = "git+https://git.jojodev.com/jolheiser/nur";
 {
       inputs.nixpkgs.follows = "nixpkgs";
 {
     };
 {
     tailwind-ctp = {
 {
       url = "git+https://git.jojodev.com/jolheiser/tailwind-ctp";
+          isSystemUser = true;
+          isNormalUser = false;
+
   inputs = {
+{
+    nur = {
+        users.groups."${cfg.group}" = {};
+        networking.firewall = lib.mkIf cfg.openFirewall {
+          allowedTCPPorts = [8448 8449];
+        };
+
+        systemd.services.ugit = {
+          enable = true;
+          script = let
+            authorizedKeysPath =
+              if (builtins.length cfg.authorizedKeys) > 0
+{
     nur = inputs.nur.packages.${system};
+{
     tailwind-ctp = inputs.tailwind-ctp.packages.${system}.default;
+{
   in {
+{
     devShells.${system}.default = pkgs.mkShell {
+{
       nativeBuildInputs = with pkgs; [
+{
         # go
+{
         # gopls
+{
         nur.templ
+{
         tailwind-ctp
+{
         sqlc
+{
       ];
+            WorkingDirectory = "/var/lib/ugit";
+          };
+        };
+      };
     };
   };
 }
M go.mod -> go.mod
diff --git a/go.mod b/go.mod
index 6b64d7e9c3a5dbe02a58bbf93c0f69c9e3d31c01..3af97fb05ebeb5960238f3b62b6af33eee6ebb08 100644
--- a/go.mod
+++ b/go.mod
@@ -3,60 +3,70 @@
 go 1.20
 
 require (
+require (
 	github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103
+require (
 	github.com/charmbracelet/wish v1.2.0
+require (
 )
-
+	github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103
-require (
+	github.com/charmbracelet/wish v1.2.0
+require (
 	dario.cat/mergo v1.0.0 // indirect
+require (
 	github.com/Microsoft/go-winio v0.6.1 // indirect
-module go.jolheiser.com/ugit
+	github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103
+	github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103
 module go.jolheiser.com/ugit
+require (
 module go.jolheiser.com/ugit
+	github.com/yuin/goldmark v1.6.0
+	github.com/yuin/goldmark-emoji v1.0.2
+	github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
+)
+
+require (
+	dario.cat/mergo v1.0.0 // indirect
+	github.com/Microsoft/go-winio v0.6.1 // indirect
+	github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c // indirect
 	github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
 	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
 	github.com/charmbracelet/keygen v0.5.0 // indirect
 	github.com/charmbracelet/lipgloss v0.9.1 // indirect
-module go.jolheiser.com/ugit
+	github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103
 	github.com/charmbracelet/wish v1.2.0
 module go.jolheiser.com/ugit
+	dario.cat/mergo v1.0.0 // indirect
+	github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103
 )
 module go.jolheiser.com/ugit
-	dario.cat/mergo v1.0.0 // indirect
-module go.jolheiser.com/ugit
 	github.com/Microsoft/go-winio v0.6.1 // indirect
 	github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
 
-module go.jolheiser.com/ugit
-	github.com/go-git/go-git/v5 v5.10.0 // indirect
-
 go 1.20
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
 	github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
 	github.com/kevinburke/ssh_config v1.2.0 // indirect
 	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
+	github.com/mattn/go-isatty v0.0.20 // indirect
 	github.com/mattn/go-runewidth v0.0.15 // indirect
 	github.com/muesli/reflow v0.3.0 // indirect
 	github.com/muesli/termenv v0.15.2 // indirect
 	github.com/pjbgf/sha1cd v0.3.0 // indirect
 	github.com/rivo/uniseg v0.2.0 // indirect
-	github.com/sergi/go-diff v1.1.0 // indirect
-	github.com/skeema/knownhosts v1.2.0 // indirect
-go 1.20
 	github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103
+	github.com/Microsoft/go-winio v0.6.1 // indirect
-go 1.20
 	github.com/charmbracelet/wish v1.2.0
 go 1.20
-)
-	gopkg.in/warnings.v0 v0.1.2 // indirect
-	gopkg.in/yaml.v2 v2.4.0 // indirect
+	github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103
-)
+	golang.org/x/crypto v0.17.0 // indirect
+	github.com/charmbracelet/wish v1.2.0
 
-require (
+	golang.org/x/net v0.19.0 // indirect
+	github.com/charmbracelet/wish v1.2.0
 require (
-	github.com/peterbourgon/ff/v3 v3.4.0
+	golang.org/x/tools v0.16.1 // indirect
-	golang.org/x/mod v0.12.0 // indirect
+	gopkg.in/warnings.v0 v0.1.2 // indirect
-require (
 go 1.20
-	golang.org/x/tools v0.13.0 // indirect
+	github.com/Microsoft/go-winio v0.6.1 // indirect
 )
M go.sum -> go.sum
diff --git a/go.sum b/go.sum
index fe3c6a39859fa096422ff78f57f8cc5450d7f43d..3e36031585f6c948cf921175cd5e582c621c3fea 100644
--- a/go.sum
+++ b/go.sum
@@ -3,9 +3,19 @@ dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
 github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
 github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
 github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
+github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c h1:kMFnB0vCcX7IL/m9Y5LO+KQYv+t1CQOiFe6+SV2J7bE=
+github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
+github.com/a-h/templ v0.2.513 h1:ZmwGAOx4NYllnHy+FTpusc4+c5msoMpPIYX0Oy3dNqw=
+github.com/a-h/templ v0.2.513/go.mod h1:9gZxTLtRzM3gQxO8jr09Na0v8/jfliS97S9W5SScanM=
+github.com/alecthomas/assert/v2 v2.2.1 h1:XivOgYcduV98QCahG8T5XTezV5bylXe+lBxLG2K2ink=
+github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
+github.com/charmbracelet/keygen v0.5.0/go.mod h1:DfvCgLHxZ9rJxdK0DGw3C/LkV4SgdGbnliHcObV3L+8=
 github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg=
+github.com/charmbracelet/keygen v0.5.0/go.mod h1:DfvCgLHxZ9rJxdK0DGw3C/LkV4SgdGbnliHcObV3L+8=
 github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
+github.com/charmbracelet/keygen v0.5.0/go.mod h1:DfvCgLHxZ9rJxdK0DGw3C/LkV4SgdGbnliHcObV3L+8=
 github.com/acomagu/bufpipe v1.0.4 h1:e3H4WUzM3npvo5uv95QuJM3cQspFNtFBzvJ2oNjKIDQ=
+github.com/charmbracelet/keygen v0.5.0/go.mod h1:DfvCgLHxZ9rJxdK0DGw3C/LkV4SgdGbnliHcObV3L+8=
 github.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
@@ -24,29 +34,39 @@ github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103/go.mod h1:0Vm2/8yBljiLDnGJHU8ehswfawrEybGk33j5ssqKQVM=
 github.com/charmbracelet/wish v1.2.0 h1:h5Wj9pr97IQz/l4gM5Xep2lXcY/YM+6O2RC2o3x0JIQ=
 github.com/charmbracelet/wish v1.2.0/go.mod h1:JX3fC+178xadJYAhPu6qWtVDpJTwpnFvpdjz9RKJlUE=
 dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
-github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg=
+github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
+github.com/cloudflare/circl v1.3.6 h1:/xbKIqSHbZXHwkhbrhrt2YOHIwYJlXH94E3tI/gDlUg=
-github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
+github.com/cloudflare/circl v1.3.6/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
 github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg=
 github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
+github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0=
+github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
 github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU=
 github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
 github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
 github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY=
+github.com/go-chi/chi/v5 v5.0.11 h1:BnpYbFZ3T3S1WMpD79r7R5ThWX40TaFB7L31Y8xqSwA=
+github.com/go-chi/chi/v5 v5.0.11/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
 github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU=
 github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow=
 github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
-github.com/go-git/go-git/v5 v5.10.0 h1:F0x3xXrAWmhwtzoCokU4IMPcBdncG+HAAqi9FcOOjbQ=
+github.com/go-git/go-git/v5 v5.11.0 h1:XIZc1p+8YzypNr34itUfSvYJcv+eYdTnTvOZ2vD3cA4=
-github.com/go-git/go-git/v5 v5.10.0/go.mod h1:1FOZ/pQnqw24ghP2n7cunVl0ON55BsjPYvhWHvZGhoo=
+github.com/go-git/go-git/v5 v5.11.0/go.mod h1:6GFcX2P3NM7FPBfpePbpLd21XxsgdAt+lKqXmCUiUCY=
 github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
 github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
 github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
 github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
 github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
@@ -58,12 +78,10 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
-github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA=
-github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg=
 dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
-github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
-github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg=
+github.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
 github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
 github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
 github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
 github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
@@ -84,36 +102,45 @@ github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
-github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
+github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
-github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
+github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
 github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
-github.com/acomagu/bufpipe v1.0.4 h1:e3H4WUzM3npvo5uv95QuJM3cQspFNtFBzvJ2oNjKIDQ=
+github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I=
 github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
-github.com/acomagu/bufpipe v1.0.4 h1:e3H4WUzM3npvo5uv95QuJM3cQspFNtFBzvJ2oNjKIDQ=
+github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I=
 github.com/acomagu/bufpipe v1.0.4 h1:e3H4WUzM3npvo5uv95QuJM3cQspFNtFBzvJ2oNjKIDQ=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
 github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
 github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
+github.com/yuin/goldmark v1.3.7/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+github.com/yuin/goldmark v1.6.0 h1:boZcn2GTjpsynOsC0iJHnBWa4Bi0qzfJjthwauItG68=
+github.com/yuin/goldmark v1.6.0/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+github.com/yuin/goldmark-emoji v1.0.2 h1:c/RgTShNgHTtc6xdz2KKI74jJr6rWi7FPgnP9GAsO5s=
+github.com/yuin/goldmark-emoji v1.0.2/go.mod h1:RhP/RWpexdp+KHs7ghKnifRoIs/Bq4nDS7tRbCkOwKY=
+github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
+github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
 golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
-dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
+dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
+github.com/acomagu/bufpipe v1.0.4 h1:e3H4WUzM3npvo5uv95QuJM3cQspFNtFBzvJ2oNjKIDQ=
 dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
-dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
-github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
+github.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
-github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg=
+github.com/charmbracelet/log v0.2.5/go.mod h1:nQGK8tvc4pS9cvVEH/pWJiZ50eUq1aoXUOjGpXvdD0k=
 dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
-github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
@@ -121,16 +148,17 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
-dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
+dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
 dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
-github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
+dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
+dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
 dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
-github.com/charmbracelet/keygen v0.5.0 h1:XY0fsoYiCSM9axkrU+2ziE6u6YjJulo/b9Dghnw6MZc=
+github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
 dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -144,11 +173,11 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
 dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
-github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
 github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
+dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
 dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
-github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
 github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -156,8 +185,9 @@ golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
 golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
+dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
 dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
-github.com/go-git/go-git/v5 v5.10.0/go.mod h1:1FOZ/pQnqw24ghP2n7cunVl0ON55BsjPYvhWHvZGhoo=
+github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -165,15 +195,17 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
-github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
+dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
 dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
+github.com/acomagu/bufpipe v1.0.4 h1:e3H4WUzM3npvo5uv95QuJM3cQspFNtFBzvJ2oNjKIDQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
+dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
 dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
-github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
-golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
+golang.org/x/tools v0.16.1/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -182,9 +214,8 @@ gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
 gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 github.com/charmbracelet/keygen v0.5.0 h1:XY0fsoYiCSM9axkrU+2ziE6u6YjJulo/b9Dghnw6MZc=
-github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
-github.com/charmbracelet/keygen v0.5.0 h1:XY0fsoYiCSM9axkrU+2ziE6u6YjJulo/b9Dghnw6MZc=
 github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg=
 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
I internal/git/git.go
diff --git a/internal/git/git.go b/internal/git/git.go
new file mode 100644
index 0000000000000000000000000000000000000000..141ced54eafa7de63a9c257624aefb7651ed57af
--- /dev/null
+++ b/internal/git/git.go
@@ -0,0 +1,139 @@
+package git
+
+import (
+	"errors"
+	"io/fs"
+	"os"
+	"path/filepath"
+	"sort"
+
+	"github.com/dustin/go-humanize"
+	"github.com/go-git/go-git/v5"
+	"github.com/go-git/go-git/v5/plumbing"
+	"github.com/go-git/go-git/v5/plumbing/object"
+)
+
+// EnsureRepo ensures that the repo exists in the given directory
+func EnsureRepo(dir string, repo string) error {
+	exists, err := PathExists(dir)
+	if err != nil {
+		return err
+	}
+	if !exists {
+		err = os.MkdirAll(dir, os.ModeDir|os.FileMode(0o700))
+		if err != nil {
+			return err
+		}
+	}
+	rp := filepath.Join(dir, repo)
+	exists, err = PathExists(rp)
+	if err != nil {
+		return err
+	}
+	if !exists {
+		_, err := git.PlainInit(rp, true)
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// PathExists checks if a path exists and returns true if it does
+func PathExists(path string) (bool, error) {
+	_, err := os.Stat(path)
+	if err == nil {
+		return true, nil
+	}
+	if errors.Is(err, fs.ErrNotExist) {
+		return false, nil
+	}
+	return true, err
+}
+
+func (r Repo) Tree(ref string) (*object.Tree, error) {
+	g, err := r.Git()
+	if err != nil {
+		return nil, err
+	}
+
+	hash, err := g.ResolveRevision(plumbing.Revision(ref))
+	if err != nil {
+		return nil, err
+	}
+
+	c, err := g.CommitObject(*hash)
+	if err != nil {
+		return nil, err
+	}
+
+	return c.Tree()
+}
+
+type FileInfo struct {
+	Path  string
+	IsDir bool
+	Mode  string
+	Size  string
+}
+
+func (f FileInfo) Name() string {
+	return filepath.Base(f.Path)
+}
+
+func (r Repo) Dir(ref, path string) ([]FileInfo, error) {
+	t, err := r.Tree(ref)
+	if err != nil {
+		return nil, err
+	}
+	if path != "" {
+		t, err = t.Tree(path)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	fis := make([]FileInfo, 0)
+	for _, entry := range t.Entries {
+		fm, err := entry.Mode.ToOSFileMode()
+		if err != nil {
+			return nil, err
+		}
+		size, err := t.Size(entry.Name)
+		if err != nil {
+			return nil, err
+		}
+		fis = append(fis, FileInfo{
+			Path:  filepath.Join(path, entry.Name),
+			IsDir: fm.IsDir(),
+			Mode:  fm.String(),
+			Size:  humanize.Bytes(uint64(size)),
+		})
+	}
+	sort.Slice(fis, func(i, j int) bool {
+		fi1 := fis[i]
+		fi2 := fis[j]
+		return (fi1.IsDir && !fi2.IsDir) || fi1.Name() < fi2.Name()
+	})
+
+	return fis, nil
+}
+
+func (r Repo) FileContent(ref, file string) (string, error) {
+	t, err := r.Tree(ref)
+	if err != nil {
+		return "", err
+	}
+
+	f, err := t.File(file)
+	if err != nil {
+		return "", err
+	}
+
+	content, err := f.Contents()
+	if err != nil {
+		return "", err
+	}
+
+	return content, nil
+}
I internal/git/meta.go
diff --git a/internal/git/meta.go b/internal/git/meta.go
new file mode 100644
index 0000000000000000000000000000000000000000..04f7f1b21ded58c765f75bb6db3ab0d23898de18
--- /dev/null
+++ b/internal/git/meta.go
@@ -0,0 +1,62 @@
+package git
+
+import (
+	"encoding/json"
+	"errors"
+	"io/fs"
+	"os"
+	"path/filepath"
+)
+
+type RepoMeta struct {
+	Description string `json:"description"`
+	Private     bool   `json:"private"`
+}
+
+func (m *RepoMeta) Update(meta RepoMeta) error {
+	data, err := json.Marshal(meta)
+	if err != nil {
+		return err
+	}
+	return json.Unmarshal(data, m)
+}
+
+func (r Repo) metaPath() string {
+	return filepath.Join(r.path, "ugit.json")
+}
+
+func (r Repo) SaveMeta() error {
+	// Compatibility with gitweb, because why not
+	// Ignoring the error because it's not technically detrimental to ugit
+	desc, err := os.Create(filepath.Join(r.path, "description"))
+	if err == nil {
+		defer desc.Close()
+		desc.WriteString(r.Meta.Description)
+	}
+
+	fi, err := os.Create(r.metaPath())
+	if err != nil {
+		return err
+	}
+	defer fi.Close()
+	return json.NewEncoder(fi).Encode(r.Meta)
+}
+
+func ensureJSONFile(path string) error {
+	_, err := os.Stat(path)
+	if err == nil {
+		return nil
+	}
+	if !errors.Is(err, fs.ErrNotExist) {
+		return err
+	}
+	fi, err := os.Create(path)
+	if err != nil {
+		return err
+	}
+	defer fi.Close()
+	if _, err := fi.WriteString(`{"private":true}`); err != nil {
+		return err
+	}
+	return nil
+}
I internal/git/protocol.go
diff --git a/internal/git/protocol.go b/internal/git/protocol.go
new file mode 100644
index 0000000000000000000000000000000000000000..60b927dd25e02d5590034310892558834e761c78
--- /dev/null
+++ b/internal/git/protocol.go
@@ -0,0 +1,223 @@
+package git
+
+import (
+	"bufio"
+	"context"
+	"fmt"
+	"io"
+	"strconv"
+	"strings"
+
+	"github.com/go-git/go-billy/v5/osfs"
+	"github.com/go-git/go-git/v5"
+	"github.com/go-git/go-git/v5/plumbing/format/pktline"
+	"github.com/go-git/go-git/v5/plumbing/protocol/packp"
+	"github.com/go-git/go-git/v5/plumbing/protocol/packp/capability"
+	"github.com/go-git/go-git/v5/plumbing/serverinfo"
+	"github.com/go-git/go-git/v5/plumbing/transport"
+	"github.com/go-git/go-git/v5/plumbing/transport/server"
+	"github.com/go-git/go-git/v5/storage/filesystem"
+	"github.com/go-git/go-git/v5/utils/ioutil"
+)
+
+type ReadWriteContexter interface {
+	io.ReadWriteCloser
+	Context() context.Context
+}
+
+type Protocol struct {
+	endpoint *transport.Endpoint
+	server   transport.Transport
+}
+
+func NewProtocol(repoPath string) (Protocol, error) {
+	endpoint, err := transport.NewEndpoint("/")
+	if err != nil {
+		return Protocol{}, err
+	}
+	fs := osfs.New(repoPath)
+	loader := server.NewFilesystemLoader(fs)
+	gitServer := server.NewServer(loader)
+	return Protocol{
+		endpoint: endpoint,
+		server:   gitServer,
+	}, nil
+}
+
+func (p Protocol) HTTPInfoRefs(rwc ReadWriteContexter) error {
+	session, err := p.server.NewUploadPackSession(p.endpoint, nil)
+	if err != nil {
+		return err
+	}
+	defer ioutil.CheckClose(rwc, &err)
+	return p.infoRefs(rwc, session, "# service=git-upload-pack")
+}
+
+func (p Protocol) infoRefs(rwc ReadWriteContexter, session transport.UploadPackSession, prefix string) error {
+	ar, err := session.AdvertisedReferencesContext(rwc.Context())
+	if err != nil {
+		return err
+	}
+
+	if prefix != "" {
+		ar.Prefix = [][]byte{
+			[]byte(prefix),
+			pktline.Flush,
+		}
+	}
+
+	if err := ar.Encode(rwc); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (p Protocol) HTTPUploadPack(rwc ReadWriteContexter) error {
+	return p.uploadPack(rwc, false)
+}
+
+func (p Protocol) SSHUploadPack(rwc ReadWriteContexter) error {
+	return p.uploadPack(rwc, true)
+}
+
+func (p Protocol) uploadPack(rwc ReadWriteContexter, ssh bool) error {
+	session, err := p.server.NewUploadPackSession(p.endpoint, nil)
+	if err != nil {
+		return err
+	}
+	defer ioutil.CheckClose(rwc, &err)
+
+	if ssh {
+		if err := p.infoRefs(rwc, session, ""); err != nil {
+			return err
+		}
+	}
+
+	req := packp.NewUploadPackRequest()
+	if err := req.Decode(rwc); err != nil {
+		return err
+	}
+
+	var resp *packp.UploadPackResponse
+	resp, err = session.UploadPack(rwc.Context(), req)
+	if err != nil {
+		return err
+	}
+
+	if err := resp.Encode(rwc); err != nil {
+		return fmt.Errorf("could not encode upload pack: %w", err)
+	}
+
+	return nil
+}
+
+func (p Protocol) SSHReceivePack(rwc ReadWriteContexter, repo *Repo) error {
+	buf := bufio.NewReader(rwc)
+
+	session, err := p.server.NewReceivePackSession(p.endpoint, nil)
+	if err != nil {
+		return err
+	}
+
+	ar, err := session.AdvertisedReferencesContext(rwc.Context())
+	if err != nil {
+		return fmt.Errorf("internal error in advertised references: %w", err)
+	}
+	_ = ar.Capabilities.Set(capability.PushOptions)
+	_ = ar.Capabilities.Set("no-thin")
+
+	if err := ar.Encode(rwc); err != nil {
+		return fmt.Errorf("error in advertised references encoding: %w", err)
+	}
+
+	req := packp.NewReferenceUpdateRequest()
+	_ = req.Capabilities.Set(capability.ReportStatus)
+	if err := req.Decode(buf); err != nil {
+		// FIXME this is a hack, but go-git doesn't accept a 0000 if there are no refs to update
+		if !strings.EqualFold(err.Error(), "capabilities delimiter not found") {
+			return fmt.Errorf("error decoding: %w", err)
+		}
+	}
+
+	// FIXME also a hack, if the next bytes are PACK then we have a packfile, otherwise assume it's push options
+	peek, err := buf.Peek(4)
+	if err != nil {
+		return err
+	}
+	if string(peek) != "PACK" {
+		s := pktline.NewScanner(buf)
+		for s.Scan() {
+			val := string(s.Bytes())
+			if val == "" {
+				break
+			}
+			if s.Err() != nil {
+				return s.Err()
+			}
+			parts := strings.SplitN(val, "=", 2)
+			req.Options = append(req.Options, &packp.Option{
+				Key:   parts[0],
+				Value: parts[1],
+			})
+		}
+	}
+
+	if err := handlePushOptions(repo, req.Options); err != nil {
+		return fmt.Errorf("could not handle push options: %w", err)
+	}
+
+	// FIXME if there are only delete commands, there is no packfile and ReceivePack will block forever
+	noPack := true
+	for _, c := range req.Commands {
+		if c.Action() != packp.Delete {
+			noPack = false
+			break
+		}
+	}
+	if noPack {
+		req.Packfile = nil
+	}
+
+	rs, err := session.ReceivePack(rwc.Context(), req)
+	if err != nil {
+		return fmt.Errorf("error in receive pack: %w", err)
+	}
+
+	if err := rs.Encode(rwc); err != nil {
+		return fmt.Errorf("could not encode receive pack: %w", err)
+	}
+
+	return nil
+}
+
+func handlePushOptions(repo *Repo, opts []*packp.Option) error {
+	var changed bool
+	for _, opt := range opts {
+		switch strings.ToLower(opt.Key) {
+		case "desc", "description":
+			changed = repo.Meta.Description != opt.Value
+			repo.Meta.Description = opt.Value
+		case "private":
+			private, err := strconv.ParseBool(opt.Value)
+			if err != nil {
+				continue
+			}
+			changed = repo.Meta.Private != private
+			repo.Meta.Private = private
+		}
+	}
+	if changed {
+		return repo.SaveMeta()
+	}
+	return nil
+}
+
+func UpdateServerInfo(repo string) error {
+	r, err := git.PlainOpen(repo)
+	if err != nil {
+		return err
+	}
+	fs := r.Storer.(*filesystem.Storage).Filesystem()
+	return serverinfo.UpdateServerInfo(r.Storer, fs)
+}
I internal/git/repo.go
diff --git a/internal/git/repo.go b/internal/git/repo.go
new file mode 100644
index 0000000000000000000000000000000000000000..6b7c997b668328f2b5ae3c70f949cbaeae2883b8
--- /dev/null
+++ b/internal/git/repo.go
@@ -0,0 +1,103 @@
+package git
+
+import (
+	"encoding/json"
+	"errors"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/go-git/go-git/v5"
+	"github.com/go-git/go-git/v5/plumbing"
+	"github.com/go-git/go-git/v5/plumbing/object"
+)
+
+type Repo struct {
+	path string
+	Meta RepoMeta
+}
+
+func (r Repo) Name() string {
+	return strings.TrimSuffix(filepath.Base(r.path), ".git")
+}
+
+func NewRepo(dir, name string) (*Repo, error) {
+	if !strings.HasSuffix(name, ".git") {
+		name += ".git"
+	}
+	r := &Repo{
+		path: filepath.Join(dir, name),
+	}
+
+	_, err := os.Stat(r.path)
+	if err != nil {
+		return nil, err
+	}
+
+	if err := ensureJSONFile(r.metaPath()); err != nil {
+		return nil, err
+	}
+	fi, err := os.Open(r.metaPath())
+	if err != nil {
+		return nil, err
+	}
+	defer fi.Close()
+
+	if err := json.NewDecoder(fi).Decode(&r.Meta); err != nil {
+		return nil, err
+	}
+
+	return r, nil
+}
+
+// DefaultBranch returns the branch referenced by HEAD, setting it if needed
+func (r Repo) DefaultBranch() (string, error) {
+	repo, err := r.Git()
+	if err != nil {
+		return "", err
+	}
+
+	ref, err := repo.Head()
+	if err != nil {
+		if !errors.Is(err, plumbing.ErrReferenceNotFound) {
+			return "", err
+		}
+		brs, err := repo.Branches()
+		if err != nil {
+			return "", err
+		}
+		defer brs.Close()
+		fb, err := brs.Next()
+		if err != nil {
+			return "", err
+		}
+		// Rename the default branch to the first branch available
+		ref = fb
+		sym := plumbing.NewSymbolicReference(plumbing.HEAD, fb.Name())
+		if err := repo.Storer.SetReference(sym); err != nil {
+			return "", err
+		}
+	}
+
+	return strings.TrimPrefix(ref.Name().String(), "refs/heads/"), nil
+}
+
+// Git allows access to the git repository
+func (r Repo) Git() (*git.Repository, error) {
+	return git.PlainOpen(r.path)
+}
+
+// LastCommit returns the last commit of the repo
+func (r Repo) LastCommit() (*object.Commit, error) {
+	repo, err := r.Git()
+	if err != nil {
+		return nil, err
+	}
+
+	head, err := repo.Head()
+	if err != nil {
+		return nil, err
+	}
+
+	return repo.CommitObject(head.Hash())
+}
I internal/html/base.templ
diff --git a/internal/html/base.templ b/internal/html/base.templ
new file mode 100644
index 0000000000000000000000000000000000000000..5d2e3b99083bb603a8b0e534aed2dbf95b5a2444
--- /dev/null
+++ b/internal/html/base.templ
@@ -0,0 +1,24 @@
+package html
+
+type BaseContext struct {
+	Title string
+	Description string
+}
+
+templ base(bc BaseContext) {
+	<!DOCTYPE html>
+	<html>
+		<head>
+			<title>{ bc.Title }</title>
+			<link rel="icon" href="/_/favicon.svg"/>
+			<link rel="stylesheet" href="/_/tailwind.css"/>
+			<meta property="og:title" content={ bc.Title }/>
+			<meta property="og:description" content={ bc.Description }/>
+		</head>
+		<body class="latte dark:mocha bg-base/50 dark:bg-base/95 max-w-7xl mx-auto my-10">
+			<h2 class="text-text text-xl mb-3"><a class="underline decoration-text/50 decoration-dashed hover:decoration-solid" href="/">Home</a></h2>
+			{ children... }
+		</body>
+	</html>
+}
+
I internal/html/base_templ.go
diff --git a/internal/html/base_templ.go b/internal/html/base_templ.go
new file mode 100644
index 0000000000000000000000000000000000000000..d5f04c2c21f2edcb34afc5fc6fd467d4f58d8d9f
--- /dev/null
+++ b/internal/html/base_templ.go
@@ -0,0 +1,86 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.2.501
+package html
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import "context"
+import "io"
+import "bytes"
+
+type BaseContext struct {
+	Title       string
+	Description string
+}
+
+func base(bc BaseContext) templ.Component {
+	return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
+		templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
+		if !templ_7745c5c3_IsBuffer {
+			templ_7745c5c3_Buffer = templ.GetBuffer()
+			defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
+		}
+		ctx = templ.InitializeContext(ctx)
+		templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+		if templ_7745c5c3_Var1 == nil {
+			templ_7745c5c3_Var1 = templ.NopComponent
+		}
+		ctx = templ.ClearChildren(ctx)
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<!doctype html><html><head><title>")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		var templ_7745c5c3_Var2 string
+		templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(bc.Title)
+		if templ_7745c5c3_Err != nil {
+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `base.templ`, Line: 11, Col: 20}
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</title><link rel=\"icon\" href=\"/_/favicon.svg\"><link rel=\"stylesheet\" href=\"/_/tailwind.css\"><meta property=\"og:title\" content=\"")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(bc.Title))
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"><meta property=\"og:description\" content=\"")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(bc.Description))
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"></head><body class=\"latte dark:mocha bg-base/50 dark:bg-base/95 max-w-7xl mx-auto my-10\"><h2 class=\"text-text text-xl mb-3\"><a class=\"underline decoration-text/50 decoration-dashed hover:decoration-solid\" href=\"/\">")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		templ_7745c5c3_Var3 := `Home`
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</a></h2>")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer)
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</body></html>")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		if !templ_7745c5c3_IsBuffer {
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
+		}
+		return templ_7745c5c3_Err
+	})
+}
I internal/html/chroma.go
diff --git a/internal/html/chroma.go b/internal/html/chroma.go
new file mode 100644
index 0000000000000000000000000000000000000000..1ea0601d93977cad0f9414c5d3794465b620f191
--- /dev/null
+++ b/internal/html/chroma.go
@@ -0,0 +1,42 @@
+package html
+
+import (
+	"io"
+
+	"github.com/alecthomas/chroma/v2"
+	"github.com/alecthomas/chroma/v2/formatters/html"
+	"github.com/alecthomas/chroma/v2/lexers"
+	"github.com/alecthomas/chroma/v2/styles"
+)
+
+var (
+	Formatter = html.New(
+		html.WithLineNumbers(true),
+		html.WithLinkableLineNumbers(true, "L"),
+		html.WithClasses(true),
+		html.LineNumbersInTable(true),
+	)
+	Code = code{}
+)
+
+type code struct{}
+
+func (c code) Convert(source []byte, fileName string, writer io.Writer) error {
+	lexer := lexers.Match(fileName)
+	if lexer == nil {
+		lexer = lexers.Fallback
+	}
+	lexer = chroma.Coalesce(lexer)
+
+	style := styles.Get("catppuccin-mocha")
+	if style == nil {
+		style = styles.Fallback
+	}
+
+	iter, err := lexer.Tokenise(nil, string(source))
+	if err != nil {
+		return err
+	}
+
+	return Formatter.Format(writer, style, iter)
+}
I internal/html/generate.css
diff --git a/internal/html/generate.css b/internal/html/generate.css
new file mode 100644
index 0000000000000000000000000000000000000000..34d0aefbe66512d4dc681fa85c49fd965d10370f
--- /dev/null
+++ b/internal/html/generate.css
@@ -0,0 +1,44 @@
+.markdown * {
+  all: revert;
+  color: rgb(var(--ctp-text));
+}
+
+.markdown a {
+  color: rgb(var(--ctp-blue));
+  text-decoration-line: underline;
+  text-decoration-style: dashed;
+}
+
+.markdown a:hover {
+  text-decoration-style: solid;
+}
+
+.chroma {
+  font-size: small;
+}
+
+.chroma * {
+  background-color: rgb(var(--ctp-base)) !important;
+}
+
+.chroma table {
+  border-spacing: 5px 0 !important;
+}
+
+.chroma .lnt {
+  color: rgb(var(--ctp-subtext1)) !important;
+}
+
+.chroma .lnt:target,
+.chroma .lnt:focus {
+  color: rgb(var(--ctp-subtext0)) !important;
+}
+
+.chroma .line {
+  white-space: break-spaces;
+}
+
+.chroma .line.active,
+.chroma .line.active * {
+  background: rgb(var(--ctp-surface0)) !important;
+}
\ No newline at end of file
I internal/html/generate.go
diff --git a/internal/html/generate.go b/internal/html/generate.go
new file mode 100644
index 0000000000000000000000000000000000000000..b4c1a441ecbcc15257f9b2f3bc05fac5e7afd1d4
--- /dev/null
+++ b/internal/html/generate.go
@@ -0,0 +1,96 @@
+//go:build generate
+
+package main
+
+import (
+	"bytes"
+	_ "embed"
+	"fmt"
+	"go/format"
+	"os"
+	"os/exec"
+
+	"go.jolheiser.com/ugit/internal/html"
+
+	"github.com/alecthomas/chroma/v2/styles"
+)
+
+var (
+	tailwindCSS = `
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+`
+	//go:embed generate.css
+	otherCSS string
+)
+
+//go:generate templ generate
+//go:generate go run generate.go
+func main() {
+	if err := tailwind(); err != nil {
+		panic(err)
+	}
+}
+
+func tailwind() error {
+	fmt.Println("generating tailwind...")
+
+	tmp, err := os.CreateTemp(os.TempDir(), "ugit-tailwind*")
+	if err != nil {
+		return err
+	}
+	defer os.Remove(tmp.Name())
+	if _, err := tmp.WriteString(tailwindCSS + otherCSS); err != nil {
+		return err
+	}
+
+	fmt.Println("generating chroma styles...")
+
+	latte := styles.Get("catppuccin-latte")
+	if err := html.Formatter.WriteCSS(tmp, latte); err != nil {
+		return err
+	}
+
+	tmp.WriteString("@media (prefers-color-scheme: dark) {")
+	mocha := styles.Get("catppuccin-mocha")
+	if err := html.Formatter.WriteCSS(tmp, mocha); err != nil {
+		return err
+	}
+	tmp.WriteString("}")
+
+	fmt.Println("finished generating chroma styles")
+
+	tmp.Close()
+
+	styles, err := os.Create("tailwind.go")
+	if err != nil {
+		return err
+	}
+	defer styles.Close()
+
+	var buf bytes.Buffer
+	cmd := exec.Command("tailwind-ctp", "-i", tmp.Name(), "--minify")
+	cmd.Stdout = &buf
+	if err := cmd.Run(); err != nil {
+		return err
+	}
+
+	code := fmt.Sprintf(`// Code generated by generate.go - DO NOT EDIT.
+package html
+
+		import "net/http"
+		
+		func TailwindHandler(w http.ResponseWriter, r *http.Request) {
+			w.Header().Set("Content-Type", "text/css")
+			w.Write([]byte(%q))
+		}`, buf.String())
+	formatted, err := format.Source([]byte(code))
+	if err != nil {
+		return err
+	}
+	styles.Write(formatted)
+
+	fmt.Println("finished generating tailwind")
+	return nil
+}
I internal/html/index.templ
diff --git a/internal/html/index.templ b/internal/html/index.templ
new file mode 100644
index 0000000000000000000000000000000000000000..96d204d1b66b31b1def91fae5b3233b30740de91
--- /dev/null
+++ b/internal/html/index.templ
@@ -0,0 +1,72 @@
+package html
+
+import "go.jolheiser.com/ugit/internal/git"
+import "github.com/dustin/go-humanize"
+import "go.jolheiser.com/ugit/assets"
+
+type IndexContext struct {
+	BaseContext
+	Profile IndexProfile
+    CloneURL string
+    Repos    []*git.Repo
+}
+
+type IndexProfile struct {
+	Username string
+	Email string
+	Links []IndexLink
+}
+
+type IndexLink struct {
+	Name string
+	URL string
+}
+
+func lastCommit(repo *git.Repo, human bool) string {
+	c, err := repo.LastCommit()
+	if err != nil {
+		return ""
+	}
+	if human {
+		return humanize.Time(c.Author.When)
+	}
+	return c.Author.When.Format("01/02/2006 03:04:05 PM")
+}
+
+templ Index(ic IndexContext) {
+	@base(ic.BaseContext) {
+		<header>
+			<h1 class="text-text text-xl font-bold">{ ic.Title }</h1>
+			<h2 class="text-subtext1 text-lg">{ ic.Description }</h2>
+		</header>
+		<main class="mt-5">
+			<div class="grid grid-cols-1 sm:grid-cols-8">
+				if ic.Profile.Username != "" {
+					<div class="text-mauve">{ `@` + ic.Profile.Username }</div>
+				}
+				if ic.Profile.Email != "" {
+					<div class="text-mauve col-span-2">
+						<div class="w-5 h-5 stroke-mauve inline-block mr-1 align-middle">@templ.Raw(string(assets.EmailIcon))</div>
+						<a class="underline decoration-mauve/50 decoration-dashed hover:decoration-solid" href="mailto:john.olheiser@gmail.com">john.olheiser@gmail.com</a>
+					</div>
+				}
+			</div>
+			<div class="grid grid-cols-1 sm:grid-cols-8">
+				for _, link := range ic.Profile.Links {
+					<div class="text-mauve">
+						<div class="w-5 h-5 stroke-mauve inline-block mr-1 align-middle">@templ.Raw(string(assets.LinkIcon))</div>
+						<a class="underline decoration-mauve/50 decoration-dashed hover:decoration-solid" rel="me" href={ templ.SafeURL(link.URL) }>{ link.Name }</a>
+					</div>
+				}
+			</div>
+			<div class="grid grid-cols-8 gap-1 mt-5">
+				for _, repo := range ic.Repos {
+					<div class="col-span-1 text-blue dark:text-lavender"><a class="underline decoration-blue/50 dark:decoration-lavender/50 decoration-dashed hover:decoration-solid" href={ templ.URL("/" + repo.Name()) }>{ repo.Name() }</a></div>
+					<div class="col-span-5 text-subtext0">{ repo.Meta.Description }</div>
+					<div class="col-span-2 text-text/80" title={ lastCommit(repo, false) }>{ lastCommit(repo, true) }</div>
+				}
+			</div>
+		</main>
+	}
+}
+
I internal/html/index_templ.go
diff --git a/internal/html/index_templ.go b/internal/html/index_templ.go
new file mode 100644
index 0000000000000000000000000000000000000000..15f200494b1434b8c9a070a0de0badae6b10252b
--- /dev/null
+++ b/internal/html/index_templ.go
@@ -0,0 +1,261 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.2.501
+package html
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import "context"
+import "io"
+import "bytes"
+
+import "go.jolheiser.com/ugit/internal/git"
+import "github.com/dustin/go-humanize"
+import "go.jolheiser.com/ugit/assets"
+
+type IndexContext struct {
+	BaseContext
+	Profile  IndexProfile
+	CloneURL string
+	Repos    []*git.Repo
+}
+
+type IndexProfile struct {
+	Username string
+	Email    string
+	Links    []IndexLink
+}
+
+type IndexLink struct {
+	Name string
+	URL  string
+}
+
+func lastCommit(repo *git.Repo, human bool) string {
+	c, err := repo.LastCommit()
+	if err != nil {
+		return ""
+	}
+	if human {
+		return humanize.Time(c.Author.When)
+	}
+	return c.Author.When.Format("01/02/2006 03:04:05 PM")
+}
+
+func Index(ic IndexContext) templ.Component {
+	return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
+		templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
+		if !templ_7745c5c3_IsBuffer {
+			templ_7745c5c3_Buffer = templ.GetBuffer()
+			defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
+		}
+		ctx = templ.InitializeContext(ctx)
+		templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+		if templ_7745c5c3_Var1 == nil {
+			templ_7745c5c3_Var1 = templ.NopComponent
+		}
+		ctx = templ.ClearChildren(ctx)
+		templ_7745c5c3_Var2 := templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
+			templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
+			if !templ_7745c5c3_IsBuffer {
+				templ_7745c5c3_Buffer = templ.GetBuffer()
+				defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<header><h1 class=\"text-text text-xl font-bold\">")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			var templ_7745c5c3_Var3 string
+			templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(ic.Title)
+			if templ_7745c5c3_Err != nil {
+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 38, Col: 53}
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</h1><h2 class=\"text-subtext1 text-lg\">")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			var templ_7745c5c3_Var4 string
+			templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(ic.Description)
+			if templ_7745c5c3_Err != nil {
+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 39, Col: 53}
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</h2></header><main class=\"mt-5\"><div class=\"grid grid-cols-1 sm:grid-cols-8\">")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			if ic.Profile.Username != "" {
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"text-mauve\">")
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				var templ_7745c5c3_Var5 string
+				templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(`@` + ic.Profile.Username)
+				if templ_7745c5c3_Err != nil {
+					return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 44, Col: 56}
+				}
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div>")
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+			}
+			if ic.Profile.Email != "" {
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"text-mauve col-span-2\"><div class=\"w-5 h-5 stroke-mauve inline-block mr-1 align-middle\">")
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				templ_7745c5c3_Err = templ.Raw(string(assets.EmailIcon)).Render(ctx, templ_7745c5c3_Buffer)
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div><a class=\"underline decoration-mauve/50 decoration-dashed hover:decoration-solid\" href=\"mailto:john.olheiser@gmail.com\">")
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				templ_7745c5c3_Var6 := `john.olheiser@gmail.com`
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</a></div>")
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div><div class=\"grid grid-cols-1 sm:grid-cols-8\">")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			for _, link := range ic.Profile.Links {
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"text-mauve\"><div class=\"w-5 h-5 stroke-mauve inline-block mr-1 align-middle\">")
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				templ_7745c5c3_Err = templ.Raw(string(assets.LinkIcon)).Render(ctx, templ_7745c5c3_Buffer)
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div><a class=\"underline decoration-mauve/50 decoration-dashed hover:decoration-solid\" rel=\"me\" href=\"")
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				var templ_7745c5c3_Var7 templ.SafeURL = templ.SafeURL(link.URL)
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var7)))
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">")
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				var templ_7745c5c3_Var8 string
+				templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(link.Name)
+				if templ_7745c5c3_Err != nil {
+					return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 57, Col: 141}
+				}
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</a></div>")
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div><div class=\"grid grid-cols-8 gap-1 mt-5\">")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			for _, repo := range ic.Repos {
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"col-span-1 text-blue dark:text-lavender\"><a class=\"underline decoration-blue/50 dark:decoration-lavender/50 decoration-dashed hover:decoration-solid\" href=\"")
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				var templ_7745c5c3_Var9 templ.SafeURL = templ.URL("/" + repo.Name())
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var9)))
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">")
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				var templ_7745c5c3_Var10 string
+				templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(repo.Name())
+				if templ_7745c5c3_Err != nil {
+					return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 63, Col: 218}
+				}
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</a></div><div class=\"col-span-5 text-subtext0\">")
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				var templ_7745c5c3_Var11 string
+				templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(repo.Meta.Description)
+				if templ_7745c5c3_Err != nil {
+					return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 64, Col: 66}
+				}
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div><div class=\"col-span-2 text-text/80\" title=\"")
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(lastCommit(repo, false)))
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">")
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				var templ_7745c5c3_Var12 string
+				templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(lastCommit(repo, true))
+				if templ_7745c5c3_Err != nil {
+					return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 65, Col: 100}
+				}
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div>")
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div></main>")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			if !templ_7745c5c3_IsBuffer {
+				_, templ_7745c5c3_Err = io.Copy(templ_7745c5c3_W, templ_7745c5c3_Buffer)
+			}
+			return templ_7745c5c3_Err
+		})
+		templ_7745c5c3_Err = base(ic.BaseContext).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		if !templ_7745c5c3_IsBuffer {
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
+		}
+		return templ_7745c5c3_Err
+	})
+}
I internal/html/markdown.go
diff --git a/internal/html/markdown.go b/internal/html/markdown.go
new file mode 100644
index 0000000000000000000000000000000000000000..e109162de66398d42dc38e2d8442c37f28b3d7e4
--- /dev/null
+++ b/internal/html/markdown.go
@@ -0,0 +1,66 @@
+package html
+
+import (
+	"bytes"
+	"path/filepath"
+
+	"go.jolheiser.com/ugit/internal/git"
+
+	chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
+	"github.com/yuin/goldmark"
+	emoji "github.com/yuin/goldmark-emoji"
+	highlighting "github.com/yuin/goldmark-highlighting/v2"
+	"github.com/yuin/goldmark/extension"
+	"github.com/yuin/goldmark/parser"
+	goldmarkhtml "github.com/yuin/goldmark/renderer/html"
+)
+
+var Markdown = goldmark.New(
+	goldmark.WithRendererOptions(
+		goldmarkhtml.WithUnsafe(),
+	),
+	goldmark.WithParserOptions(
+		parser.WithAutoHeadingID(),
+	),
+	goldmark.WithExtensions(
+		extension.GFM,
+		emoji.Emoji,
+		highlighting.NewHighlighting(
+			highlighting.WithStyle("catppuccin-mocha"),
+			highlighting.WithFormatOptions(
+				chromahtml.WithClasses(true),
+				chromahtml.WithLineNumbers(true),
+				chromahtml.WithLinkableLineNumbers(true, "md-"),
+				chromahtml.LineNumbersInTable(true),
+			),
+		),
+	),
+)
+
+func Readme(repo *git.Repo, ref, path string) (string, error) {
+	var readme string
+	var err error
+	for _, md := range []string{"README.md", "readme.md"} {
+		readme, err = repo.FileContent(ref, filepath.Join(path, md))
+		if err == nil {
+			break
+		}
+	}
+
+	if readme != "" {
+		var buf bytes.Buffer
+		if err := Markdown.Convert([]byte(readme), &buf); err != nil {
+			return "", err
+		}
+		return buf.String(), nil
+	}
+
+	for _, md := range []string{"README.txt", "README", "readme.txt", "readme"} {
+		readme, err = repo.FileContent(ref, filepath.Join(path, md))
+		if err == nil {
+			return readme, nil
+		}
+	}
+
+	return "", nil
+}
I internal/html/readme.templ
diff --git a/internal/html/readme.templ b/internal/html/readme.templ
new file mode 100644
index 0000000000000000000000000000000000000000..cdcc3814d800f94f227178690cdc564f737d5afb
--- /dev/null
+++ b/internal/html/readme.templ
@@ -0,0 +1,10 @@
+package html
+
+type ReadmeComponentContext struct {
+    Markdown string
+}
+
+templ readmeComponent(rcc ReadmeComponentContext) {
+	<div class="bg-base/50 px-5 rounded markdown">@templ.Raw(rcc.Markdown)</div>
+}
+
I internal/html/readme_templ.go
diff --git a/internal/html/readme_templ.go b/internal/html/readme_templ.go
new file mode 100644
index 0000000000000000000000000000000000000000..812b724ecb6b3a666d0914fab8d601257eaf8d13
--- /dev/null
+++ b/internal/html/readme_templ.go
@@ -0,0 +1,47 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.2.501
+package html
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import "context"
+import "io"
+import "bytes"
+
+type ReadmeComponentContext struct {
+	Markdown string
+}
+
+func readmeComponent(rcc ReadmeComponentContext) templ.Component {
+	return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
+		templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
+		if !templ_7745c5c3_IsBuffer {
+			templ_7745c5c3_Buffer = templ.GetBuffer()
+			defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
+		}
+		ctx = templ.InitializeContext(ctx)
+		templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+		if templ_7745c5c3_Var1 == nil {
+			templ_7745c5c3_Var1 = templ.NopComponent
+		}
+		ctx = templ.ClearChildren(ctx)
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"bg-base/50 px-5 rounded markdown\">")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		templ_7745c5c3_Err = templ.Raw(rcc.Markdown).Render(ctx, templ_7745c5c3_Buffer)
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div>")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		if !templ_7745c5c3_IsBuffer {
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
+		}
+		return templ_7745c5c3_Err
+	})
+}
I internal/html/repo.templ
diff --git a/internal/html/repo.templ b/internal/html/repo.templ
new file mode 100644
index 0000000000000000000000000000000000000000..6bca6011774bef42cf2d01c3e344d3cecafce2bf
--- /dev/null
+++ b/internal/html/repo.templ
@@ -0,0 +1,23 @@
+package html
+
+import "fmt"
+
+type RepoHeaderComponentContext struct {
+  Name string
+  Ref string
+  Description string
+}
+
+templ repoHeaderComponent(rhcc RepoHeaderComponentContext) {
+	if rhcc.Name != "" {
+		<div class="mb-1">
+			<a class="text-text text-lg underline decoration-text/50 decoration-dashed hover:decoration-solid" href={ templ.SafeURL("/" + rhcc.Name) }>{ rhcc.Name }</a>
+			if rhcc.Ref != "" {
+				{ " " }
+				<a class="text-text/70 text-sm underline decoration-text/50 decoration-dashed hover:decoration-solid" href={ templ.SafeURL(fmt.Sprintf("/%s/tree/%s/", rhcc.Name, rhcc.Ref)) }>{ "@" + rhcc.Ref }</a>
+			}
+		</div>
+	}
+	<div class="text-text/80 mb-1">{ rhcc.Description }</div>
+}
+
I internal/html/repo_file.templ
diff --git a/internal/html/repo_file.templ b/internal/html/repo_file.templ
new file mode 100644
index 0000000000000000000000000000000000000000..c589066f84df572afb2fb9702cabcdd33d96c786
--- /dev/null
+++ b/internal/html/repo_file.templ
@@ -0,0 +1,68 @@
+package html
+
+type RepoFileContext struct {
+  BaseContext
+	RepoHeaderComponentContext
+  Code string
+	Path string
+}
+
+templ RepoFile(rfc RepoFileContext) {
+	@base(rfc.BaseContext) {
+		@repoHeaderComponent(rfc.RepoHeaderComponentContext)
+		<div class="mt-2 text-text"><a class="text-text underline decoration-text/50 decoration-dashed hover:decoration-solid" href="?raw">Raw</a><span>{ " - " }{ rfc.Path }</span>@templ.Raw(rfc.Code)</div>
+	}
+	<script>
+		const lineRe = /#L(\d+)(?:-L(\d+))?/g
+		const $lineLines = document.querySelectorAll(".chroma .lntable .lnt");
+		const $codeLines = document.querySelectorAll(".chroma .lntable .line");
+		let start = 0;
+		let end = 0;
+
+		const results = [...location.hash.matchAll(lineRe)];		
+		if (0 in results) {
+			start = results[0][1] !== undefined ? parseInt(results[0][1]) : 0;
+			end = results[0][2] !== undefined ? parseInt(results[0][2]) : 0;
+		}
+		if (start != 0) {
+			deactivateLines();
+			activateLines(start, end);
+		}
+
+		for (let line of $lineLines) {
+			line.addEventListener("click", (event) => {
+				event.preventDefault();
+				deactivateLines();
+				const n = parseInt(line.id.substring(1));
+				let anchor = "";
+				if (event.shiftKey) {
+					end = n;
+					anchor = `#L${start}-L${end}`;
+				} else {
+					start = n;
+					end = 0;
+					anchor = `#L${start}`;
+				}
+				history.pushState(null, null, anchor);
+				activateLines(start, end);
+			});
+		}
+
+		function activateLines(start, end) {
+			if (end < start) end = start;
+			for (let idx = start - 1; idx < end; idx++) {
+				$codeLines[idx].classList.add("active");
+			}
+		}
+
+		function deactivateLines() {
+			for (let code of $codeLines) {
+				code.classList.remove("active");
+			}
+		}
+
+		
+		
+	</script>
+}
+
I internal/html/repo_file_templ.go
diff --git a/internal/html/repo_file_templ.go b/internal/html/repo_file_templ.go
new file mode 100644
index 0000000000000000000000000000000000000000..2616d1f1b3f3ac15d107d5f29106db4d11908410
--- /dev/null
+++ b/internal/html/repo_file_templ.go
@@ -0,0 +1,164 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.2.501
+package html
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import "context"
+import "io"
+import "bytes"
+
+type RepoFileContext struct {
+	BaseContext
+	RepoHeaderComponentContext
+	Code string
+	Path string
+}
+
+func RepoFile(rfc RepoFileContext) templ.Component {
+	return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
+		templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
+		if !templ_7745c5c3_IsBuffer {
+			templ_7745c5c3_Buffer = templ.GetBuffer()
+			defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
+		}
+		ctx = templ.InitializeContext(ctx)
+		templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+		if templ_7745c5c3_Var1 == nil {
+			templ_7745c5c3_Var1 = templ.NopComponent
+		}
+		ctx = templ.ClearChildren(ctx)
+		templ_7745c5c3_Var2 := templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
+			templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
+			if !templ_7745c5c3_IsBuffer {
+				templ_7745c5c3_Buffer = templ.GetBuffer()
+				defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
+			}
+			templ_7745c5c3_Err = repoHeaderComponent(rfc.RepoHeaderComponentContext).Render(ctx, templ_7745c5c3_Buffer)
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" <div class=\"mt-2 text-text\"><a class=\"text-text underline decoration-text/50 decoration-dashed hover:decoration-solid\" href=\"?raw\">")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			templ_7745c5c3_Var3 := `Raw`
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</a><span>")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			var templ_7745c5c3_Var4 string
+			templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(" - ")
+			if templ_7745c5c3_Err != nil {
+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `repo_file.templ`, Line: 12, Col: 153}
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			var templ_7745c5c3_Var5 string
+			templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(rfc.Path)
+			if templ_7745c5c3_Err != nil {
+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `repo_file.templ`, Line: 12, Col: 165}
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</span>")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			templ_7745c5c3_Err = templ.Raw(rfc.Code).Render(ctx, templ_7745c5c3_Buffer)
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div>")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			if !templ_7745c5c3_IsBuffer {
+				_, templ_7745c5c3_Err = io.Copy(templ_7745c5c3_W, templ_7745c5c3_Buffer)
+			}
+			return templ_7745c5c3_Err
+		})
+		templ_7745c5c3_Err = base(rfc.BaseContext).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<script>")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		templ_7745c5c3_Var6 := `
+		const lineRe = /#L(\d+)(?:-L(\d+))?/g
+		const $lineLines = document.querySelectorAll(".chroma .lntable .lnt");
+		const $codeLines = document.querySelectorAll(".chroma .lntable .line");
+		let start = 0;
+		let end = 0;
+
+		const results = [...location.hash.matchAll(lineRe)];		
+		if (0 in results) {
+			start = results[0][1] !== undefined ? parseInt(results[0][1]) : 0;
+			end = results[0][2] !== undefined ? parseInt(results[0][2]) : 0;
+		}
+		if (start != 0) {
+			deactivateLines();
+			activateLines(start, end);
+		}
+
+		for (let line of $lineLines) {
+			line.addEventListener("click", (event) => {
+				event.preventDefault();
+				deactivateLines();
+				const n = parseInt(line.id.substring(1));
+				let anchor = "";
+				if (event.shiftKey) {
+					end = n;
+					anchor = ` + "`" + `#L${start}-L${end}` + "`" + `;
+				} else {
+					start = n;
+					end = 0;
+					anchor = ` + "`" + `#L${start}` + "`" + `;
+				}
+				history.pushState(null, null, anchor);
+				activateLines(start, end);
+			});
+		}
+
+		function activateLines(start, end) {
+			if (end < start) end = start;
+			for (let idx = start - 1; idx < end; idx++) {
+				$codeLines[idx].classList.add("active");
+			}
+		}
+
+		function deactivateLines() {
+			for (let code of $codeLines) {
+				code.classList.remove("active");
+			}
+		}
+
+		
+		
+	`
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</script>")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		if !templ_7745c5c3_IsBuffer {
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
+		}
+		return templ_7745c5c3_Err
+	})
+}
I internal/html/repo_templ.go
diff --git a/internal/html/repo_templ.go b/internal/html/repo_templ.go
new file mode 100644
index 0000000000000000000000000000000000000000..3ee5f408417a1d1badba62e07261a748cfd44a4e
--- /dev/null
+++ b/internal/html/repo_templ.go
@@ -0,0 +1,125 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.2.501
+package html
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import "context"
+import "io"
+import "bytes"
+
+import "fmt"
+
+type RepoHeaderComponentContext struct {
+	Name        string
+	Ref         string
+	Description string
+}
+
+func repoHeaderComponent(rhcc RepoHeaderComponentContext) templ.Component {
+	return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
+		templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
+		if !templ_7745c5c3_IsBuffer {
+			templ_7745c5c3_Buffer = templ.GetBuffer()
+			defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
+		}
+		ctx = templ.InitializeContext(ctx)
+		templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+		if templ_7745c5c3_Var1 == nil {
+			templ_7745c5c3_Var1 = templ.NopComponent
+		}
+		ctx = templ.ClearChildren(ctx)
+		if rhcc.Name != "" {
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"mb-1\"><a class=\"text-text text-lg underline decoration-text/50 decoration-dashed hover:decoration-solid\" href=\"")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			var templ_7745c5c3_Var2 templ.SafeURL = templ.SafeURL("/" + rhcc.Name)
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var2)))
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			var templ_7745c5c3_Var3 string
+			templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(rhcc.Name)
+			if templ_7745c5c3_Err != nil {
+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `repo.templ`, Line: 13, Col: 153}
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</a> ")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			if rhcc.Ref != "" {
+				var templ_7745c5c3_Var4 string
+				templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
+				if templ_7745c5c3_Err != nil {
+					return templ.Error{Err: templ_7745c5c3_Err, FileName: `repo.templ`, Line: 15, Col: 9}
+				}
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" <a class=\"text-text/70 text-sm underline decoration-text/50 decoration-dashed hover:decoration-solid\" href=\"")
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				var templ_7745c5c3_Var5 templ.SafeURL = templ.SafeURL(fmt.Sprintf("/%s/tree/%s/", rhcc.Name, rhcc.Ref))
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var5)))
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">")
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				var templ_7745c5c3_Var6 string
+				templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs("@" + rhcc.Ref)
+				if templ_7745c5c3_Err != nil {
+					return templ.Error{Err: templ_7745c5c3_Err, FileName: `repo.templ`, Line: 16, Col: 195}
+				}
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</a>")
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div>")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"text-text/80 mb-1\">")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		var templ_7745c5c3_Var7 string
+		templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(rhcc.Description)
+		if templ_7745c5c3_Err != nil {
+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `repo.templ`, Line: 20, Col: 50}
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div>")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		if !templ_7745c5c3_IsBuffer {
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
+		}
+		return templ_7745c5c3_Err
+	})
+}
I internal/html/repo_tree.templ
diff --git a/internal/html/repo_tree.templ b/internal/html/repo_tree.templ
new file mode 100644
index 0000000000000000000000000000000000000000..204a14d58af81b3c54ce19aa2b5b73c5406eba22
--- /dev/null
+++ b/internal/html/repo_tree.templ
@@ -0,0 +1,51 @@
+package html
+
+import (
+    "fmt"
+    "go.jolheiser.com/ugit/internal/git"
+)
+
+type RepoTreeContext struct {
+  BaseContext
+	RepoHeaderComponentContext
+  RepoTreeComponentContext
+  ReadmeComponentContext
+  Description string
+}
+
+templ RepoTree(rtc RepoTreeContext) {
+	@base(rtc.BaseContext) {
+		@repoHeaderComponent(rtc.RepoHeaderComponentContext)
+		@repoTreeComponent(rtc.RepoTreeComponentContext)
+		@readmeComponent(rtc.ReadmeComponentContext)
+	}
+}
+
+type RepoTreeComponentContext struct {
+    Repo string
+    Ref string
+    Tree []git.FileInfo
+    Back string
+}
+
+func slashDir(name string, isDir bool) string {
+    if isDir {
+        return name + "/"
+    }
+    return name
+}
+
+templ repoTreeComponent(rtcc RepoTreeComponentContext) {
+	<div class="grid grid-cols-8 text-text py-5 rounded px-5 bg-base/50">
+		if rtcc.Back != "" {
+			<div class="col-span-2"></div>
+			<div class="col-span-6"><a class="underline decoration-text/50 decoration-dashed hover:decoration-solid" href={ templ.SafeURL(fmt.Sprintf("/%s/tree/%s/%s", rtcc.Repo, rtcc.Ref, rtcc.Back)) }>..</a></div>
+		}
+		for _, fi := range rtcc.Tree {
+			<div class="col-span-1">{ fi.Mode }</div>
+			<div class="col-span-1">{ fi.Size }</div>
+			<div class="col-span-6"><a class="underline decoration-text/50 decoration-dashed hover:decoration-solid" href={ templ.SafeURL(fmt.Sprintf("/%s/tree/%s/%s", rtcc.Repo, rtcc.Ref, fi.Path)) }>{ slashDir(fi.Name(), fi.IsDir) }</a></div>
+		}
+	</div>
+}
+
I internal/html/repo_tree_templ.go
diff --git a/internal/html/repo_tree_templ.go b/internal/html/repo_tree_templ.go
new file mode 100644
index 0000000000000000000000000000000000000000..ecbf44cac165bfdf02b0a4fa9380990c08194d98
--- /dev/null
+++ b/internal/html/repo_tree_templ.go
@@ -0,0 +1,199 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.2.501
+package html
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import "context"
+import "io"
+import "bytes"
+
+import (
+	"fmt"
+	"go.jolheiser.com/ugit/internal/git"
+)
+
+type RepoTreeContext struct {
+	BaseContext
+	RepoHeaderComponentContext
+	RepoTreeComponentContext
+	ReadmeComponentContext
+	Description string
+}
+
+func RepoTree(rtc RepoTreeContext) templ.Component {
+	return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
+		templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
+		if !templ_7745c5c3_IsBuffer {
+			templ_7745c5c3_Buffer = templ.GetBuffer()
+			defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
+		}
+		ctx = templ.InitializeContext(ctx)
+		templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+		if templ_7745c5c3_Var1 == nil {
+			templ_7745c5c3_Var1 = templ.NopComponent
+		}
+		ctx = templ.ClearChildren(ctx)
+		templ_7745c5c3_Var2 := templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
+			templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
+			if !templ_7745c5c3_IsBuffer {
+				templ_7745c5c3_Buffer = templ.GetBuffer()
+				defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
+			}
+			templ_7745c5c3_Err = repoHeaderComponent(rtc.RepoHeaderComponentContext).Render(ctx, templ_7745c5c3_Buffer)
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			templ_7745c5c3_Err = repoTreeComponent(rtc.RepoTreeComponentContext).Render(ctx, templ_7745c5c3_Buffer)
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			templ_7745c5c3_Err = readmeComponent(rtc.ReadmeComponentContext).Render(ctx, templ_7745c5c3_Buffer)
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			if !templ_7745c5c3_IsBuffer {
+				_, templ_7745c5c3_Err = io.Copy(templ_7745c5c3_W, templ_7745c5c3_Buffer)
+			}
+			return templ_7745c5c3_Err
+		})
+		templ_7745c5c3_Err = base(rtc.BaseContext).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		if !templ_7745c5c3_IsBuffer {
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
+		}
+		return templ_7745c5c3_Err
+	})
+}
+
+type RepoTreeComponentContext struct {
+	Repo string
+	Ref  string
+	Tree []git.FileInfo
+	Back string
+}
+
+func slashDir(name string, isDir bool) string {
+	if isDir {
+		return name + "/"
+	}
+	return name
+}
+
+func repoTreeComponent(rtcc RepoTreeComponentContext) templ.Component {
+	return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
+		templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
+		if !templ_7745c5c3_IsBuffer {
+			templ_7745c5c3_Buffer = templ.GetBuffer()
+			defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
+		}
+		ctx = templ.InitializeContext(ctx)
+		templ_7745c5c3_Var3 := templ.GetChildren(ctx)
+		if templ_7745c5c3_Var3 == nil {
+			templ_7745c5c3_Var3 = templ.NopComponent
+		}
+		ctx = templ.ClearChildren(ctx)
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"grid grid-cols-8 text-text py-5 rounded px-5 bg-base/50\">")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		if rtcc.Back != "" {
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"col-span-2\"></div><div class=\"col-span-6\"><a class=\"underline decoration-text/50 decoration-dashed hover:decoration-solid\" href=\"")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			var templ_7745c5c3_Var4 templ.SafeURL = templ.SafeURL(fmt.Sprintf("/%s/tree/%s/%s", rtcc.Repo, rtcc.Ref, rtcc.Back))
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var4)))
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			templ_7745c5c3_Var5 := `..`
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</a></div>")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+		}
+		for _, fi := range rtcc.Tree {
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"col-span-1\">")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			var templ_7745c5c3_Var6 string
+			templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fi.Mode)
+			if templ_7745c5c3_Err != nil {
+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `repo_tree.templ`, Line: 44, Col: 36}
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div><div class=\"col-span-1\">")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			var templ_7745c5c3_Var7 string
+			templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fi.Size)
+			if templ_7745c5c3_Err != nil {
+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `repo_tree.templ`, Line: 45, Col: 36}
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div><div class=\"col-span-6\"><a class=\"underline decoration-text/50 decoration-dashed hover:decoration-solid\" href=\"")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			var templ_7745c5c3_Var8 templ.SafeURL = templ.SafeURL(fmt.Sprintf("/%s/tree/%s/%s", rtcc.Repo, rtcc.Ref, fi.Path))
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var8)))
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			var templ_7745c5c3_Var9 string
+			templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(slashDir(fi.Name(), fi.IsDir))
+			if templ_7745c5c3_Err != nil {
+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `repo_tree.templ`, Line: 46, Col: 223}
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</a></div>")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div>")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		if !templ_7745c5c3_IsBuffer {
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
+		}
+		return templ_7745c5c3_Err
+	})
+}
I internal/html/tailwind.config.js
diff --git a/internal/html/tailwind.config.js b/internal/html/tailwind.config.js
new file mode 100644
index 0000000000000000000000000000000000000000..e0a1834b2cff8c49b8712115aaf8a7ec060831c8
--- /dev/null
+++ b/internal/html/tailwind.config.js
@@ -0,0 +1,7 @@
+/** @type {import('tailwindcss').Config} */
+module.exports = {
+  content: ["./**/*.templ"],
+  plugins: [require("@catppuccin/tailwindcss")],
+}
+
+
I internal/html/tailwind.go
diff --git a/internal/html/tailwind.go b/internal/html/tailwind.go
new file mode 100644
index 0000000000000000000000000000000000000000..8bd8a40894b2e434ee2f64c38be9f8cc5e8336ba
--- /dev/null
+++ b/internal/html/tailwind.go
@@ -0,0 +1,9 @@
+// Code generated by generate.go - DO NOT EDIT.
+package html
+
+import "net/http"
+
+func TailwindHandler(w http.ResponseWriter, r *http.Request) {
+	w.Header().Set("Content-Type", "text/css")
+	w.Write([]byte("/*! tailwindcss v3.3.3 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:\"\"}html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:initial}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:initial;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:initial}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}.latte{--ctp-rosewater:220,138,120;--ctp-flamingo:221,120,120;--ctp-pink:234,118,203;--ctp-mauve:136,57,239;--ctp-red:210,15,57;--ctp-maroon:230,69,83;--ctp-peach:254,100,11;--ctp-yellow:223,142,29;--ctp-green:64,160,43;--ctp-teal:23,146,153;--ctp-sky:4,165,229;--ctp-sapphire:32,159,181;--ctp-blue:30,102,245;--ctp-lavender:114,135,253;--ctp-text:76,79,105;--ctp-subtext1:92,95,119;--ctp-subtext0:108,111,133;--ctp-overlay2:124,127,147;--ctp-overlay1:140,143,161;--ctp-overlay0:156,160,176;--ctp-surface2:172,176,190;--ctp-surface1:188,192,204;--ctp-surface0:204,208,218;--ctp-base:239,241,245;--ctp-mantle:230,233,239;--ctp-crust:220,224,232}*,::backdrop,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.col-span-1{grid-column:span 1/span 1}.col-span-2{grid-column:span 2/span 2}.col-span-5{grid-column:span 5/span 5}.col-span-6{grid-column:span 6/span 6}.mx-auto{margin-left:auto;margin-right:auto}.my-10{margin-top:2.5rem;margin-bottom:2.5rem}.mb-1{margin-bottom:.25rem}.mb-3{margin-bottom:.75rem}.mr-1{margin-right:.25rem}.mt-2{margin-top:.5rem}.mt-5{margin-top:1.25rem}.inline-block{display:inline-block}.grid{display:grid}.h-5{height:1.25rem}.w-5{width:1.25rem}.max-w-7xl{max-width:80rem}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-8{grid-template-columns:repeat(8,minmax(0,1fr))}.gap-1{gap:.25rem}.rounded{border-radius:.25rem}.bg-base\\/50{background-color:rgba(var(--ctp-base),.5)}.stroke-mauve{stroke:rgb(var(--ctp-mauve))}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.align-middle{vertical-align:middle}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.font-bold{font-weight:700}.text-blue{--tw-text-opacity:1;color:rgba(var(--ctp-blue),var(--tw-text-opacity))}.text-mauve{--tw-text-opacity:1;color:rgba(var(--ctp-mauve),var(--tw-text-opacity))}.text-subtext0{--tw-text-opacity:1;color:rgba(var(--ctp-subtext0),var(--tw-text-opacity))}.text-subtext1{--tw-text-opacity:1;color:rgba(var(--ctp-subtext1),var(--tw-text-opacity))}.text-text{--tw-text-opacity:1;color:rgba(var(--ctp-text),var(--tw-text-opacity))}.text-text\\/70{color:rgba(var(--ctp-text),.7)}.text-text\\/80{color:rgba(var(--ctp-text),.8)}.underline{text-decoration-line:underline}.decoration-blue\\/50{text-decoration-color:rgba(var(--ctp-blue),.5)}.decoration-mauve\\/50{text-decoration-color:rgba(var(--ctp-mauve),.5)}.decoration-text\\/50{text-decoration-color:rgba(var(--ctp-text),.5)}.decoration-dashed{text-decoration-style:dashed}.markdown *{all:revert;color:rgb(var(--ctp-text))}.markdown a{color:rgb(var(--ctp-blue));text-decoration-line:underline;text-decoration-style:dashed}.markdown a:hover{text-decoration-style:solid}.chroma{font-size:small}.chroma *{background-color:rgb(var(--ctp-base))!important}.chroma table{border-spacing:5px 0!important}.chroma .lnt{color:rgb(var(--ctp-subtext1))!important}.chroma .lnt:focus,.chroma .lnt:target{color:rgb(var(--ctp-subtext0))!important}.chroma .line{white-space:break-spaces}.chroma .line.active,.chroma .line.active *{background:rgb(var(--ctp-surface0))!important}.bg,.chroma{color:#4c4f69;background-color:#eff1f5}.chroma .lntd:last-child{width:100%}.chroma .ln:target,.chroma .lnt:target{color:#bcc0cc;background-color:#eff1f5}.chroma .err{color:#d20f39}.chroma .lnlinks{outline:none;text-decoration:none;color:inherit}.chroma .lntd{vertical-align:top;padding:0;margin:0;border:0}.chroma .lntable{border-spacing:0;padding:0;margin:0;border:0}.chroma .hl{color:#bcc0cc}.chroma .ln,.chroma .lnt{white-space:pre;-webkit-user-select:none;-moz-user-select:none;user-select:none;margin-right:.4em;padding:0 .4em;color:#8c8fa1}.chroma .line{display:flex}.chroma .k{color:#8839ef}.chroma .kc{color:#fe640b}.chroma .kd{color:#d20f39}.chroma .kn{color:#179299}.chroma .kp,.chroma .kr{color:#8839ef}.chroma .kt{color:#d20f39}.chroma .na{color:#1e66f5}.chroma .bp,.chroma .nb{color:#04a5e5}.chroma .nc,.chroma .no{color:#df8e1d}.chroma .nd{color:#1e66f5;font-weight:700}.chroma .ni{color:#179299}.chroma .ne{color:#fe640b}.chroma .fm,.chroma .nf{color:#1e66f5}.chroma .nl{color:#04a5e5}.chroma .nn,.chroma .py{color:#fe640b}.chroma .nt{color:#8839ef}.chroma .nv,.chroma .vc,.chroma .vg,.chroma .vi,.chroma .vm{color:#dc8a78}.chroma .s{color:#40a02b}.chroma .sa{color:#d20f39}.chroma .sb,.chroma .sc{color:#40a02b}.chroma .dl{color:#1e66f5}.chroma .sd{color:#9ca0b0}.chroma .s2{color:#40a02b}.chroma .se{color:#1e66f5}.chroma .sh{color:#9ca0b0}.chroma .si,.chroma .sx{color:#40a02b}.chroma .sr{color:#179299}.chroma .s1,.chroma .ss{color:#40a02b}.chroma .il,.chroma .m,.chroma .mb,.chroma .mf,.chroma .mh,.chroma .mi,.chroma .mo{color:#fe640b}.chroma .o,.chroma .ow{color:#04a5e5;font-weight:700}.chroma .c,.chroma .c1,.chroma .ch,.chroma .cm,.chroma .cp,.chroma .cpf,.chroma .cs{color:#9ca0b0;font-style:italic}.chroma .cpf{font-weight:700}.chroma .gd{color:#d20f39;background-color:#ccd0da}.chroma .ge{font-style:italic}.chroma .gr{color:#d20f39}.chroma .gh{color:#fe640b;font-weight:700}.chroma .gi{color:#40a02b;background-color:#ccd0da}.chroma .gs,.chroma .gu{font-weight:700}.chroma .gu{color:#fe640b}.chroma .gt{color:#d20f39}.chroma .gl{text-decoration:underline}@media (prefers-color-scheme:dark){.bg,.chroma{color:#cdd6f4;background-color:#1e1e2e}.chroma .lntd:last-child{width:100%}.chroma .ln:target,.chroma .lnt:target{color:#45475a;background-color:#1e1e2e}.chroma .err{color:#f38ba8}.chroma .lnlinks{outline:none;text-decoration:none;color:inherit}.chroma .lntd{vertical-align:top;padding:0;margin:0;border:0}.chroma .lntable{border-spacing:0;padding:0;margin:0;border:0}.chroma .hl{color:#45475a}.chroma .ln,.chroma .lnt{white-space:pre;-webkit-user-select:none;-moz-user-select:none;user-select:none;margin-right:.4em;padding:0 .4em;color:#7f849c}.chroma .line{display:flex}.chroma .k{color:#cba6f7}.chroma .kc{color:#fab387}.chroma .kd{color:#f38ba8}.chroma .kn{color:#94e2d5}.chroma .kp,.chroma .kr{color:#cba6f7}.chroma .kt{color:#f38ba8}.chroma .na{color:#89b4fa}.chroma .bp,.chroma .nb{color:#89dceb}.chroma .nc,.chroma .no{color:#f9e2af}.chroma .nd{color:#89b4fa;font-weight:700}.chroma .ni{color:#94e2d5}.chroma .ne{color:#fab387}.chroma .fm,.chroma .nf{color:#89b4fa}.chroma .nl{color:#89dceb}.chroma .nn,.chroma .py{color:#fab387}.chroma .nt{color:#cba6f7}.chroma .nv,.chroma .vc,.chroma .vg,.chroma .vi,.chroma .vm{color:#f5e0dc}.chroma .s{color:#a6e3a1}.chroma .sa{color:#f38ba8}.chroma .sb,.chroma .sc{color:#a6e3a1}.chroma .dl{color:#89b4fa}.chroma .sd{color:#6c7086}.chroma .s2{color:#a6e3a1}.chroma .se{color:#89b4fa}.chroma .sh{color:#6c7086}.chroma .si,.chroma .sx{color:#a6e3a1}.chroma .sr{color:#94e2d5}.chroma .s1,.chroma .ss{color:#a6e3a1}.chroma .il,.chroma .m,.chroma .mb,.chroma .mf,.chroma .mh,.chroma .mi,.chroma .mo{color:#fab387}.chroma .o,.chroma .ow{color:#89dceb;font-weight:700}.chroma .c,.chroma .c1,.chroma .ch,.chroma .cm,.chroma .cp,.chroma .cpf,.chroma .cs{color:#6c7086;font-style:italic}.chroma .cpf{font-weight:700}.chroma .gd{color:#f38ba8;background-color:#313244}.chroma .ge{font-style:italic}.chroma .gr{color:#f38ba8}.chroma .gh{color:#fab387;font-weight:700}.chroma .gi{color:#a6e3a1;background-color:#313244}.chroma .gs,.chroma .gu{font-weight:700}.chroma .gu{color:#fab387}.chroma .gt{color:#f38ba8}.chroma .gl{text-decoration:underline}.dark\\:mocha{--ctp-rosewater:245,224,220;--ctp-flamingo:242,205,205;--ctp-pink:245,194,231;--ctp-mauve:203,166,247;--ctp-red:243,139,168;--ctp-maroon:235,160,172;--ctp-peach:250,179,135;--ctp-yellow:249,226,175;--ctp-green:166,227,161;--ctp-teal:148,226,213;--ctp-sky:137,220,235;--ctp-sapphire:116,199,236;--ctp-blue:137,180,250;--ctp-lavender:180,190,254;--ctp-text:205,214,244;--ctp-subtext1:186,194,222;--ctp-subtext0:166,173,200;--ctp-overlay2:147,153,178;--ctp-overlay1:127,132,156;--ctp-overlay0:108,112,134;--ctp-surface2:88,91,112;--ctp-surface1:69,71,90;--ctp-surface0:49,50,68;--ctp-base:30,30,46;--ctp-mantle:24,24,37;--ctp-crust:17,17,27}}.hover\\:decoration-solid:hover{text-decoration-style:solid}@media (prefers-color-scheme:dark){.dark\\:bg-base\\/95{background-color:rgba(var(--ctp-base),.95)}.dark\\:text-lavender{--tw-text-opacity:1;color:rgba(var(--ctp-lavender),var(--tw-text-opacity))}.dark\\:decoration-lavender\\/50{text-decoration-color:rgba(var(--ctp-lavender),.5)}}@media (min-width:640px){.sm\\:grid-cols-8{grid-template-columns:repeat(8,minmax(0,1fr))}}"))
+}
I internal/http/git.go
diff --git a/internal/http/git.go b/internal/http/git.go
new file mode 100644
index 0000000000000000000000000000000000000000..56d7e28eae08bad2cdc63716cf285ee84afc2ea3
--- /dev/null
+++ b/internal/http/git.go
@@ -0,0 +1,50 @@
+package http
+
+import (
+	"errors"
+	"net/http"
+	"path/filepath"
+
+	"go.jolheiser.com/ugit/internal/git"
+	"go.jolheiser.com/ugit/internal/http/httperr"
+
+	"github.com/go-chi/chi/v5"
+)
+
+func (rh repoHandler) infoRefs(w http.ResponseWriter, r *http.Request) error {
+	if r.URL.Query().Get("service") != "git-upload-pack" {
+		return httperr.Status(errors.New("pushing isn't supported via HTTP(S), use SSH"), http.StatusBadRequest)
+	}
+
+	w.Header().Set("Content-Type", "application/x-git-upload-pack-advertisement")
+	rp := filepath.Join(rh.s.RepoDir, chi.URLParam(r, "repo")+".git")
+	repo, err := git.NewProtocol(rp)
+	if err != nil {
+		return httperr.Error(err)
+	}
+	if err := repo.HTTPInfoRefs(Session{
+		w: w,
+		r: r,
+	}); err != nil {
+		return httperr.Error(err)
+	}
+
+	return nil
+}
+
+func (rh repoHandler) uploadPack(w http.ResponseWriter, r *http.Request) error {
+	w.Header().Set("content-type", "application/x-git-upload-pack-result")
+	rp := filepath.Join(rh.s.RepoDir, chi.URLParam(r, "repo")+".git")
+	repo, err := git.NewProtocol(rp)
+	if err != nil {
+		return httperr.Error(err)
+	}
+	if err := repo.HTTPUploadPack(Session{
+		w: w,
+		r: r,
+	}); err != nil {
+		return httperr.Error(err)
+	}
+
+	return nil
+}
I internal/http/http.go
diff --git a/internal/http/http.go b/internal/http/http.go
new file mode 100644
index 0000000000000000000000000000000000000000..d4d406766ce829b478bb98ae92190b7351dfa51d
--- /dev/null
+++ b/internal/http/http.go
@@ -0,0 +1,121 @@
+package http
+
+import (
+	"fmt"
+	"net/http"
+	"net/url"
+
+	"go.jolheiser.com/ugit/assets"
+	"go.jolheiser.com/ugit/internal/git"
+	"go.jolheiser.com/ugit/internal/html"
+	"go.jolheiser.com/ugit/internal/http/httperr"
+
+	"github.com/go-chi/chi/v5"
+	"github.com/go-chi/chi/v5/middleware"
+)
+
+// Server is the container struct for the HTTP server
+type Server struct {
+	port int
+	mux  *chi.Mux
+}
+
+// ListenAndServe simply wraps http.ListenAndServe to contain the functionality here
+func (s Server) ListenAndServe() error {
+	return http.ListenAndServe(fmt.Sprintf("localhost:%d", s.port), s.mux)
+}
+
+// Settings is the configuration for the HTTP server
+type Settings struct {
+	Title       string
+	Description string
+	CloneURL    string
+	Port        int
+	RepoDir     string
+	Profile     Profile
+}
+
+// Profile is the index profile
+type Profile struct {
+	Username string
+	Email    string
+	Links    []Link
+}
+
+// Link is a profile link
+type Link struct {
+	Name string
+	URL  string
+}
+
+func (s Settings) goGet(repo string) string {
+	u, _ := url.Parse(s.CloneURL)
+	return fmt.Sprintf(`<!DOCTYPE html><title>%[1]s</title><meta name="go-import" content="%[2]s/%[1]s git %[3]s/%[1]s.git"><meta name="go-source" content="%[2]s/%[1]s _ %[3]s/%[1]s/tree/main{/dir}/{file}#L{line}">`, repo, u.Hostname(), s.CloneURL)
+}
+
+// New returns a new HTTP server
+func New(settings Settings) Server {
+	mux := chi.NewMux()
+
+	mux.Use(middleware.Logger)
+	mux.Use(middleware.Recoverer)
+
+	rh := repoHandler{s: settings}
+	mux.Route("/{repo}.git", func(r chi.Router) {
+		r.Get("/info/refs", httperr.Handler(rh.infoRefs))
+		r.Post("/git-upload-pack", httperr.Handler(rh.uploadPack))
+	})
+
+	mux.Route("/", func(r chi.Router) {
+		r.Get("/", httperr.Handler(rh.index))
+		r.Route("/{repo}", func(r chi.Router) {
+			r.Get("/", func(w http.ResponseWriter, r *http.Request) {
+				if r.URL.Query().Has("go-get") {
+					repo := chi.URLParam(r, "repo")
+					w.Write([]byte(settings.goGet(repo)))
+					return
+				}
+				rh.repoTree("", "").ServeHTTP(w, r)
+			})
+			r.Get("/tree/{ref}/*", func(w http.ResponseWriter, r *http.Request) {
+				rh.repoTree(chi.URLParam(r, "ref"), chi.URLParam(r, "*")).ServeHTTP(w, r)
+			})
+		})
+	})
+
+	mux.Route("/_", func(r chi.Router) {
+		r.Get("/favicon.svg", func(w http.ResponseWriter, r *http.Request) {
+			w.Header().Set("Content-Type", "image/svg+xml")
+			w.Write(assets.LogoIcon)
+		})
+		r.Get("/tailwind.css", html.TailwindHandler)
+	})
+
+	return Server{mux: mux, port: settings.Port}
+}
+
+type repoHandler struct {
+	s Settings
+}
+
+func (rh repoHandler) baseContext() html.BaseContext {
+	return html.BaseContext{
+		Title:       rh.s.Title,
+		Description: rh.s.Description,
+	}
+}
+
+func (rh repoHandler) repoHeaderContext(repo *git.Repo, r *http.Request) html.RepoHeaderComponentContext {
+	return html.RepoHeaderComponentContext{
+		Description: repo.Meta.Description,
+		Name:        chi.URLParam(r, "repo"),
+		Ref:         chi.URLParam(r, "ref"),
+	}
+}
+
+// NoopLogger is a no-op logging middleware
+func NoopLogger(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		next.ServeHTTP(w, r)
+	})
+}
I internal/http/httperr/httperr.go
diff --git a/internal/http/httperr/httperr.go b/internal/http/httperr/httperr.go
new file mode 100644
index 0000000000000000000000000000000000000000..b6bcd1fd0a1b45c3797645548bba0eb24db5b36d
--- /dev/null
+++ b/internal/http/httperr/httperr.go
@@ -0,0 +1,45 @@
+package httperr
+
+import (
+	"errors"
+	"net/http"
+
+	"github.com/charmbracelet/log"
+)
+
+type httpError struct {
+	err    error
+	status int
+}
+
+func (h httpError) Error() string {
+	return h.err.Error()
+}
+
+func (h httpError) Unwrap() error {
+	return h.err
+}
+
+func Error(err error) httpError {
+	return Status(err, http.StatusInternalServerError)
+}
+
+func Status(err error, status int) httpError {
+	return httpError{err: err, status: status}
+}
+
+func Handler(fn func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		if err := fn(w, r); err != nil {
+			status := http.StatusInternalServerError
+
+			var httpErr httpError
+			if errors.As(err, &httpErr) {
+				status = httpErr.status
+			}
+
+			log.Error(err)
+			http.Error(w, http.StatusText(status), status)
+		}
+	}
+}
I internal/http/index.go
diff --git a/internal/http/index.go b/internal/http/index.go
new file mode 100644
index 0000000000000000000000000000000000000000..dfe761c69d3d410b25b048b4d0dbcd2cf3051ee7
--- /dev/null
+++ b/internal/http/index.go
@@ -0,0 +1,63 @@
+package http
+
+import (
+	"net/http"
+	"os"
+	"sort"
+	"time"
+
+	"go.jolheiser.com/ugit/internal/git"
+	"go.jolheiser.com/ugit/internal/html"
+	"go.jolheiser.com/ugit/internal/http/httperr"
+)
+
+func (rh repoHandler) index(w http.ResponseWriter, r *http.Request) error {
+	repoPaths, err := os.ReadDir(rh.s.RepoDir)
+	if err != nil {
+		return httperr.Error(err)
+	}
+
+	repos := make([]*git.Repo, 0, len(repoPaths))
+	for _, repoName := range repoPaths {
+		repo, err := git.NewRepo(rh.s.RepoDir, repoName.Name())
+		if err != nil {
+			return httperr.Error(err)
+		}
+		if !repo.Meta.Private {
+			repos = append(repos, repo)
+		}
+	}
+	sort.Slice(repos, func(i, j int) bool {
+		var when1, when2 time.Time
+		if c, err := repos[i].LastCommit(); err == nil {
+			when1 = c.Author.When
+		}
+		if c, err := repos[j].LastCommit(); err == nil {
+			when2 = c.Author.When
+		}
+		return when1.After(when2)
+	})
+
+	links := make([]html.IndexLink, 0, len(rh.s.Profile.Links))
+	for _, link := range rh.s.Profile.Links {
+		links = append(links, html.IndexLink{
+			Name: link.Name,
+			URL:  link.URL,
+		})
+	}
+
+	if err := html.Index(html.IndexContext{
+		BaseContext: rh.baseContext(),
+		Profile: html.IndexProfile{
+			Username: rh.s.Profile.Username,
+			Email:    rh.s.Profile.Email,
+			Links:    links,
+		},
+		CloneURL: rh.s.CloneURL,
+		Repos:    repos,
+	}).Render(r.Context(), w); err != nil {
+		return httperr.Error(err)
+	}
+
+	return nil
+}
I internal/http/repo.go
diff --git a/internal/http/repo.go b/internal/http/repo.go
new file mode 100644
index 0000000000000000000000000000000000000000..bdf82a252fe33762c304586820ed55bdb5996f7b
--- /dev/null
+++ b/internal/http/repo.go
@@ -0,0 +1,109 @@
+package http
+
+import (
+	"bytes"
+	"errors"
+	"io/fs"
+	"mime"
+	"net/http"
+	"path/filepath"
+
+	"go.jolheiser.com/ugit/internal/git"
+	"go.jolheiser.com/ugit/internal/html"
+	"go.jolheiser.com/ugit/internal/http/httperr"
+
+	"github.com/go-chi/chi/v5"
+	"github.com/go-git/go-git/v5/plumbing/object"
+)
+
+func (rh repoHandler) repoTree(ref, path string) http.HandlerFunc {
+	return httperr.Handler(func(w http.ResponseWriter, r *http.Request) error {
+		repoName := chi.URLParam(r, "repo")
+		repo, err := git.NewRepo(rh.s.RepoDir, repoName)
+		if err != nil {
+			httpErr := http.StatusInternalServerError
+			if errors.Is(err, fs.ErrNotExist) {
+				httpErr = http.StatusNotFound
+			}
+			return httperr.Status(err, httpErr)
+		}
+		if repo.Meta.Private {
+			return httperr.Status(errors.New("could not get git repo"), http.StatusNotFound)
+		}
+
+		if ref == "" {
+			ref, err = repo.DefaultBranch()
+			if err != nil {
+				return httperr.Error(err)
+			}
+		}
+
+		tree, err := repo.Dir(ref, path)
+		if err != nil {
+			if errors.Is(err, object.ErrDirectoryNotFound) {
+				return rh.repoFile(w, r, repo, ref, path)
+			}
+			return httperr.Error(err)
+		}
+
+		readmeContent, err := html.Readme(repo, ref, path)
+		if err != nil {
+			return httperr.Error(err)
+		}
+
+		var back string
+		if path != "" {
+			back = filepath.Dir(path)
+		}
+		if err := html.RepoTree(html.RepoTreeContext{
+			Description:                repo.Meta.Description,
+			BaseContext:                rh.baseContext(),
+			RepoHeaderComponentContext: rh.repoHeaderContext(repo, r),
+			RepoTreeComponentContext: html.RepoTreeComponentContext{
+				Repo: repoName,
+				Ref:  ref,
+				Tree: tree,
+				Back: back,
+			},
+			ReadmeComponentContext: html.ReadmeComponentContext{
+				Markdown: readmeContent,
+			},
+		}).Render(r.Context(), w); err != nil {
+			return httperr.Error(err)
+		}
+
+		return nil
+	})
+}
+
+func (rh repoHandler) repoFile(w http.ResponseWriter, r *http.Request, repo *git.Repo, ref, path string) error {
+	content, err := repo.FileContent(ref, path)
+	if err != nil {
+		return httperr.Error(err)
+	}
+
+	if r.URL.Query().Has("raw") {
+		if r.URL.Query().Has("pretty") {
+			ext := filepath.Ext(path)
+			w.Header().Set("Content-Type", mime.TypeByExtension(ext))
+		}
+		w.Write([]byte(content))
+		return nil
+	}
+
+	var buf bytes.Buffer
+	if err := html.Code.Convert([]byte(content), filepath.Base(path), &buf); err != nil {
+		return httperr.Error(err)
+	}
+
+	if err := html.RepoFile(html.RepoFileContext{
+		BaseContext:                rh.baseContext(),
+		RepoHeaderComponentContext: rh.repoHeaderContext(repo, r),
+		Code:                       buf.String(),
+		Path:                       path,
+	}).Render(r.Context(), w); err != nil {
+		return httperr.Error(err)
+	}
+
+	return nil
+}
I internal/http/session.go
diff --git a/internal/http/session.go b/internal/http/session.go
new file mode 100644
index 0000000000000000000000000000000000000000..b427f08abe58ddf096e570e618541f397cf09728
--- /dev/null
+++ b/internal/http/session.go
@@ -0,0 +1,32 @@
+package http
+
+import (
+	"context"
+	"net/http"
+)
+
+// Session fulfills git.ReadWriteContexter for an HTTP request
+type Session struct {
+	w http.ResponseWriter
+	r *http.Request
+}
+
+// Read implements io.Reader
+func (s Session) Read(p []byte) (n int, err error) {
+	return s.r.Body.Read(p)
+}
+
+// Write implements io.Writer
+func (s Session) Write(p []byte) (n int, err error) {
+	return s.w.Write(p)
+}
+
+// Close implements io.Closer
+func (s Session) Close() error {
+	return s.r.Body.Close()
+}
+
+// Context implements git.ReadWriteContexter
+func (s Session) Context() context.Context {
+	return s.r.Context()
+}
M internal/ssh/ssh.go -> internal/ssh/ssh.go
diff --git a/internal/ssh/ssh.go b/internal/ssh/ssh.go
index 4a494b653835ddc7d00ee5ad234eee5d02a02794..31c49eb29bb3d509f412a4542eedc8a0d2aefb00 100644
--- a/internal/ssh/ssh.go
+++ b/internal/ssh/ssh.go
@@ -3,25 +3,36 @@
 import (
 	"fmt"
 
+	"github.com/charmbracelet/log"
 	"github.com/charmbracelet/ssh"
 	"github.com/charmbracelet/wish"
-	"github.com/charmbracelet/wish/git"
 	"github.com/charmbracelet/wish/logging"
 )
 
+import (
 package ssh
+type Settings struct {
+	AuthorizedKeys string
-	s, err := wish.NewServer(
+	CloneURL       string
-package ssh
+	Port           int
+	HostKey        string
+	RepoDir        string
 
+	"fmt"
-package ssh
+
 import (
+	"github.com/charmbracelet/wish/logging"
+func New(settings Settings) (*ssh.Server, error) {
+package ssh
 package ssh
 	"fmt"
+	"fmt"
 package ssh
-	"github.com/charmbracelet/ssh"
-			git.Middleware(".ugit", app{}),
+		wish.WithHostKeyPath(settings.HostKey),
 package ssh
-	"github.com/charmbracelet/wish/git"
+	"github.com/charmbracelet/ssh"
+			Middleware(settings.RepoDir, settings.CloneURL, settings.Port, hooks{}),
+			logging.MiddlewareWithLogger(DefaultLogger),
 		),
 	)
 	if err != nil {
@@ -30,16 +40,21 @@
 	return s, nil
 }
 
-
+	"fmt"
 	"github.com/charmbracelet/ssh"
 
-
+	"fmt"
 	"github.com/charmbracelet/wish"
-
+	"fmt"
 	"github.com/charmbracelet/wish/git"
 
 	"fmt"
+	"github.com/charmbracelet/wish/logging"
+	DefaultLogger logging.Logger = log.StandardLog()
+	NoopLogger    logging.Logger = noopLogger{}
+)
 
-	"github.com/charmbracelet/wish/logging"
+type noopLogger struct{}
+
+	"github.com/charmbracelet/ssh"
 
-)
I internal/ssh/wish.go
diff --git a/internal/ssh/wish.go b/internal/ssh/wish.go
new file mode 100644
index 0000000000000000000000000000000000000000..89fd6ef63875ca728bd02a35aa741abc2cf73486
--- /dev/null
+++ b/internal/ssh/wish.go
@@ -0,0 +1,164 @@
+package ssh
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"io/fs"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"go.jolheiser.com/ugit/internal/git"
+
+	"github.com/charmbracelet/log"
+	"github.com/charmbracelet/ssh"
+	"github.com/charmbracelet/wish"
+)
+
+// ErrSystemMalfunction represents a general system error returned to clients.
+var ErrSystemMalfunction = errors.New("something went wrong")
+
+// ErrInvalidRepo represents an attempt to access a non-existent repo.
+var ErrInvalidRepo = errors.New("invalid repo")
+
+// Hooks is an interface that allows for custom authorization
+// implementations and post push/fetch notifications. Prior to git access,
+// AuthRepo will be called with the ssh.Session public key and the repo name.
+// Implementers return the appropriate AccessLevel.
+type Hooks interface {
+	Push(string, ssh.PublicKey)
+	Fetch(string, ssh.PublicKey)
+}
+
+// Session wraps sn ssh.Session to implement git.ReadWriteContexter
+type Session struct {
+	s ssh.Session
+}
+
+// Read implements io.Reader
+func (s Session) Read(p []byte) (n int, err error) {
+	return s.s.Read(p)
+}
+
+// Write implements io.Writer
+func (s Session) Write(p []byte) (n int, err error) {
+	return s.s.Write(p)
+}
+
+// Close implements io.Closer
+func (s Session) Close() error {
+	return nil
+}
+
+// Context returns an interface context.Context
+func (s Session) Context() context.Context {
+	return s.s.Context()
+}
+
+// Middleware adds Git server functionality to the ssh.Server. Repos are stored
+// in the specified repo directory. The provided Hooks implementation will be
+// checked for access on a per repo basis for a ssh.Session public key.
+// Hooks.Push and Hooks.Fetch will be called on successful completion of
+// their commands.
+func Middleware(repoDir string, cloneURL string, port int, gh Hooks) wish.Middleware {
+	return func(sh ssh.Handler) ssh.Handler {
+		return func(s ssh.Session) {
+			sess := Session{s: s}
+			cmd := s.Command()
+
+			// Git operations
+			if len(cmd) == 2 {
+				gc := cmd[0]
+				// repo should be in the form of "repo.git" or "user/repo.git"
+				repo := strings.TrimSuffix(strings.TrimPrefix(cmd[1], "/"), "/")
+				repo = filepath.Clean(repo)
+				if n := strings.Count(repo, "/"); n > 1 {
+					Fatal(s, ErrInvalidRepo)
+					return
+				}
+				pk := s.PublicKey()
+				switch gc {
+				case "git-receive-pack":
+					if err := gitPack(sess, gc, repoDir, repo); err != nil {
+						Fatal(s, ErrSystemMalfunction)
+					}
+					gh.Push(repo, pk)
+					return
+				case "git-upload-archive", "git-upload-pack":
+					if err := gitPack(sess, gc, repoDir, repo); err != nil {
+						if errors.Is(err, ErrInvalidRepo) {
+							Fatal(s, ErrInvalidRepo)
+						}
+						log.Error("unknown git error", "error", err)
+						Fatal(s, ErrSystemMalfunction)
+					}
+					gh.Fetch(repo, pk)
+					return
+				}
+			}
+
+			// Repo list
+			if len(cmd) == 0 {
+				des, err := os.ReadDir(repoDir)
+				if err != nil && err != fs.ErrNotExist {
+					log.Error("invalid repository", "error", err)
+				}
+				for _, de := range des {
+					fmt.Fprintln(s, de.Name())
+					fmt.Fprintf(s, "\tgit clone %s/%s\n", cloneURL, de.Name())
+				}
+			}
+			sh(s)
+		}
+	}
+}
+
+func gitPack(s Session, gitCmd string, repoDir string, repoName string) error {
+	rp := filepath.Join(repoDir, repoName)
+	protocol, err := git.NewProtocol(rp)
+	if err != nil {
+		return err
+	}
+	switch gitCmd {
+	case "git-upload-pack":
+		exists, err := git.PathExists(rp)
+		if !exists {
+			return ErrInvalidRepo
+		}
+		if err != nil {
+			return err
+		}
+		return protocol.SSHUploadPack(s)
+	case "git-receive-pack":
+		err := git.EnsureRepo(repoDir, repoName)
+		if err != nil {
+			return err
+		}
+		repo, err := git.NewRepo(repoDir, repoName)
+		if err != nil {
+			return err
+		}
+		err = protocol.SSHReceivePack(s, repo)
+		if err != nil {
+			return err
+		}
+		_, err = repo.DefaultBranch()
+		if err != nil {
+			return err
+		}
+		// Needed for git dumb http server
+		return git.UpdateServerInfo(rp)
+	default:
+		return fmt.Errorf("unknown git command: %s", gitCmd)
+	}
+}
+
+// Fatal prints to the session's STDOUT as a git response and exit 1.
+func Fatal(s ssh.Session, v ...interface{}) {
+	msg := fmt.Sprint(v...)
+	// hex length includes 4 byte length prefix and ending newline
+	pktLine := fmt.Sprintf("%04x%s\n", len(msg)+5, msg)
+	_, _ = wish.WriteString(s, pktLine)
+	s.Exit(1) // nolint: errcheck
+}